SPB Git

Repository Public MIT

admin.ts 11.4 KB 177 lines typescript

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
Files main
Menu

Theme