Repository Public MIT
1/**2 * Server console for the registry operator. Local only: it talks straight to the database and is3 * never exposed over HTTP. Secrets are printed once, to this terminal (or a 0600 file), and nowhere else.4 *5 * pnpm admin:bootstrap --email you@example.org --name "Your Name" --namespace yourns [--password-out FILE]6 * pnpm admin token create --user EMAIL --name claude-code-publisher --scopes a,b --namespace NS [--expires-in 90d] [--ip CIDR]7 * pnpm admin token list --user EMAIL | token revoke --id ID | token rotate --id ID8 * pnpm admin recover --email EMAIL [--password-out FILE]9 * pnpm admin user create --email EMAIL --name NAME --namespace NS [--password-out FILE]10 * pnpm admin collections seed | collection add --slug S --artifact @ns/slug [--note TEXT] | collection remove …11 * pnpm admin verify-namespace --name NS | cleanup | status12 */13import "dotenv/config";14import { writeFileSync } from "node:fs";15import { PUBLISHER_SCOPES } from "@skills/registry-core";16import { audit } from "../src/server/audit";17import { bootstrapOwner, createNamespace, createToken, hashPassword, listTokens, publicToken, revokeToken, rotateToken } from "../src/server/auth";18import { setCollectionItem, upsertCollection } from "../src/server/community";19import { makeCtx, type Ctx, type UserRow } from "../src/server/context";20import { generatePassword } from "../src/server/crypto";21import { db } from "../src/server/db";22import { discardStaging } from "../src/server/storage";2324const argv = process.argv.slice(2);25const positional: string[] = [];26const flags: Record<string, string> = {};27for (let i = 0; i < argv.length; i++) {28 const a = argv[i]!;29 if (a.startsWith("--")) {30 const next = argv[i + 1];31 if (next === undefined || next.startsWith("--")) flags[a.slice(2)] = "true";32 else { flags[a.slice(2)] = next; i++; }33 } else positional.push(a);34}35const need = (k: string): string => {36 const v = flags[k];37 if (!v || v === "true") fail(`missing --${k}`);38 return v!;39};40function fail(msg: string): never {41 console.error(`error: ${msg}`);42 process.exit(1);43}44const info = (s: string) => process.stderr.write(s + "\n");4546export function parseDuration(s: string): number {47 const m = /^(\d+(?:\.\d+)?)(m|h|d)$/.exec(s);48 if (!m) fail(`invalid duration "${s}" (use e.g. 30m, 12h, 90d)`);49 return Number(m![1]) * (m![2] === "m" ? 1 / 60 : m![2] === "h" ? 1 : 24);50}5152/** Emit a secret exactly once: into a 0600 file when asked, otherwise to stdout (pipe it; do not paste it). */53function emitSecret(label: string, secret: string) {54 if (flags["password-out"] || flags["token-out"]) {55 const f = flags["password-out"] ?? flags["token-out"]!;56 writeFileSync(f, secret + "\n", { mode: 0o600, flag: "wx" });57 info(`${label} written to ${f} (mode 600). It is not stored anywhere else in clear text.`);58 } else {59 info(`${label} (shown once; store it in your credential manager now):`);60 process.stdout.write(secret + "\n");61 }62}6364async function userByEmail(ctx: Ctx, email: string): Promise<UserRow> {65 const [u] = await ctx.sql<UserRow[]>`SELECT id, email, display_name, platform_role, status, must_change_password, email_verified_at FROM users WHERE email = ${email}`;66 if (!u) fail(`no user with email ${email}`);67 return u!;68}6970const COLLECTION_SEED = [71 ["academic-research", "Academic Research", "Literature review, manuscript preparation, peer review and replication tooling for researchers."],72 ["financial-econometrics", "Financial Econometrics", "Estimation, backtesting and market-data tooling for empirical finance."],73 ["scientific-writing", "Scientific Writing", "Drafting, editing, citation and LaTeX workflows for scholarly manuscripts."],74 ["macos-automation", "macOS Automation", "AppleScript, Shortcuts and system automation for agents running on a Mac."],75 ["local-llms", "Local LLMs", "Serving, evaluating and orchestrating models that run on your own hardware."],76 ["data-engineering", "Data Engineering", "Ingestion, transformation, validation and lakehouse tooling."],77 ["developer-productivity", "Developer Productivity", "Code review, testing, release and repository-maintenance capabilities."],78 ["evaluation", "Evaluation", "Benchmark harnesses, fixtures and reproducibility kits for agent capabilities."],79] as const;8081async function main() {82 const sql = db();83 const ctx = makeCtx({ sql, principal: { kind: "local", user: null }, ip: "local-console", userAgent: "skills-admin" });84 const [cmd, sub] = positional;8586 if (cmd === "bootstrap") {87 if (!process.stdin.isTTY && flags["non-interactive"] !== "true") fail("bootstrap must run in an interactive terminal on the server (or pass --non-interactive from a trusted deploy script)");88 const password = generatePassword();89 const { user, namespace } = await bootstrapOwner(ctx, { email: need("email"), displayName: need("name"), password, namespace: need("namespace") });90 info(`Owner created: ${user.email} (role owner), namespace @${namespace.name}. The email is recorded as operator-attested.`);91 emitSecret("Initial password — must be changed at first sign-in", password);92 } else if (cmd === "recover") {93 const u = await userByEmail(ctx, need("email"));94 const password = generatePassword();95 await sql`UPDATE users SET password_hash = ${await hashPassword(password)}, must_change_password = true, status = 'active', updated_at = now() WHERE id = ${u.id}`;96 await sql`UPDATE sessions SET revoked_at = now() WHERE user_id = ${u.id} AND revoked_at IS NULL`;97 await audit({ ...ctx, principal: { kind: "local", user: u } }, { action: "admin.recover", targetType: "user", targetId: u.id, targetLabel: u.email });98 info(`Password reset for ${u.email}; all sessions revoked.`);99 emitSecret("Temporary password — must be changed at next sign-in", password);100 } else if (cmd === "user" && sub === "create") {101 const password = generatePassword();102 const [u] = await sql<UserRow[]>`103 INSERT INTO users (email, email_verified_at, display_name, password_hash, must_change_password) VALUES (${need("email")}, now(), ${need("name")}, ${await hashPassword(password)}, true)104 RETURNING id, email, display_name, platform_role, status, must_change_password, email_verified_at`;105 const c2: Ctx = { ...ctx, principal: { kind: "local", user: u! } };106 await audit(c2, { action: "admin.user_create", targetType: "user", targetId: u!.id, targetLabel: u!.email });107 if (flags.namespace) await createNamespace(c2, flags.namespace, u!.id, { displayName: u!.display_name });108 info(`User created: ${u!.email}${flags.namespace ? `, namespace @${flags.namespace}` : ""}.`);109 emitSecret("Initial password — must be changed at first sign-in", password);110 } else if (cmd === "token") {111 if (sub === "create") {112 const u = await userByEmail(ctx, need("user"));113 const c2: Ctx = { ...ctx, principal: { kind: "local", user: u } };114 const scopes = flags.scopes ? flags.scopes.split(",").map((s) => s.trim()).filter(Boolean) : PUBLISHER_SCOPES;115 const { token, row } = await createToken(c2, {116 name: need("name"), scopes, namespaces: flags.namespace ? flags.namespace.split(",") : [], ipAllowlist: flags.ip ? flags.ip.split(",") : [],117 expiresInHours: flags["expires-in"] ? parseDuration(flags["expires-in"]) : undefined,118 });119 info(JSON.stringify({ ...publicToken(row), userEmail: u.email }, null, 2));120 emitSecret("API token", token);121 } else if (sub === "list") {122 const u = await userByEmail(ctx, need("user"));123 console.log(JSON.stringify((await listTokens({ ...ctx, principal: { kind: "local", user: u } })).map(publicToken), null, 2));124 } else if (sub === "revoke" || sub === "rotate") {125 const [t] = await sql<{ user_id: string }[]>`SELECT user_id FROM api_tokens WHERE id = ${need("id")}`;126 if (!t) fail("no such token");127 const [u] = await sql<UserRow[]>`SELECT id, email, display_name, platform_role, status, must_change_password, email_verified_at FROM users WHERE id = ${t!.user_id}`;128 const c2: Ctx = { ...ctx, principal: { kind: "local", user: u! } };129 if (sub === "revoke") {130 await revokeToken(c2, flags.id!, flags.reason ?? "revoked from server console");131 info("Token revoked.");132 } else {133 const { token, row } = await rotateToken(c2, flags.id!);134 info(JSON.stringify(publicToken(row), null, 2));135 emitSecret("Rotated API token", token);136 }137 } else fail("usage: token create|list|revoke|rotate");138 } else if (cmd === "collections" && sub === "seed") {139 const [owner] = await sql<UserRow[]>`SELECT id, email, display_name, platform_role, status, must_change_password, email_verified_at FROM users WHERE platform_role = 'owner'`;140 if (!owner) fail("bootstrap the owner first");141 const c2: Ctx = { ...ctx, principal: { kind: "local", user: owner! } };142 let i = 0;143 for (const [slug, title, description] of COLLECTION_SEED) await upsertCollection(c2, { slug, title, description, category: "research", position: i++ });144 info(`${COLLECTION_SEED.length} editorial collections ensured (they start empty; nothing is fabricated).`);145 } else if (cmd === "collection" && (sub === "add" || sub === "remove")) {146 const [owner] = await sql<UserRow[]>`SELECT id, email, display_name, platform_role, status, must_change_password, email_verified_at FROM users WHERE platform_role = 'owner'`;147 await setCollectionItem({ ...ctx, principal: { kind: "local", user: owner! } }, need("slug"), need("artifact"), sub, flags.note);148 info(`collection ${flags.slug}: ${sub} ${flags.artifact}`);149 } else if (cmd === "verify-namespace") {150 const r = await sql`UPDATE namespaces SET identity_verified = ${flags.revoke !== "true"} WHERE name = ${need("name")} RETURNING id`;151 if (r.length === 0) fail("no such namespace");152 await audit(ctx, { action: "admin.namespace_verify", targetType: "namespace", targetId: r[0]!.id, targetLabel: flags.name, detail: { verified: flags.revoke !== "true" } });153 info(`namespace @${flags.name} identity_verified = ${flags.revoke !== "true"}`);154 } else if (cmd === "cleanup") {155 const stale = await sql<{ id: string }[]>`UPDATE uploads SET state = 'expired', updated_at = now() WHERE state IN ('pending','uploaded','validating') AND expires_at < now() - interval '1 hour' RETURNING id`;156 for (const s of stale) discardStaging(s.id);157 const idem = await sql`DELETE FROM idempotency_keys WHERE created_at < now() - interval '2 days'`;158 const sess = await sql`DELETE FROM sessions WHERE expires_at < now() - interval '30 days'`;159 const dd = await sql`DELETE FROM download_dedupe WHERE day < current_date - 2`;160 info(`cleanup: ${stale.length} uploads expired, ${idem.count} idempotency keys, ${sess.count} old sessions, ${dd.count} dedupe hashes removed`);161 } else if (cmd === "status") {162 const [s] = await sql`SELECT (SELECT count(*)::int FROM users) AS users, (SELECT count(*)::int FROM artifacts) AS artifacts, (SELECT count(*)::int FROM releases) AS releases,163 (SELECT count(*)::int FROM api_tokens WHERE revoked_at IS NULL AND expires_at > now()) AS active_tokens, (SELECT count(*)::int FROM reports WHERE state = 'open') AS open_reports,164 (SELECT count(*)::int FROM releases WHERE state = 'quarantined') AS quarantined, (SELECT count(*)::int FROM audit_events) AS audit_events`;165 console.log(JSON.stringify(s, null, 2));166 } else {167 fail("unknown command. See the header of apps/web/scripts/admin.ts or docs/OPERATIONS.md");168 }169 await sql.end();170}171172main().catch(async (e) => {173 console.error(`error: ${(e as Error).message}`);174 await db().end().catch(() => {});175 process.exit(1);176});177