@openthink/stamp 1.5.0 → 1.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -1
- package/dist/server/authorized-keys.cjs +2 -2
- package/dist/server/authorized-keys.cjs.map +1 -1
- package/dist/server/mint-invite.cjs +2 -2
- package/dist/server/mint-invite.cjs.map +1 -1
- package/dist/server/seed-users.cjs +2 -2
- package/dist/server/seed-users.cjs.map +1 -1
- package/dist/server/start-http-server.cjs +2 -2
- package/dist/server/start-http-server.cjs.map +1 -1
- package/dist/server/users-cli.cjs +2 -2
- package/dist/server/users-cli.cjs.map +1 -1
- package/package.json +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/server/http-server.ts","../../src/lib/invites.ts","../../src/lib/serverDb.ts","../../src/lib/paths.ts","../../src/lib/sshKeys.ts","../../src/server/start-http-server.ts"],"sourcesContent":["/**\n * Stamp server HTTP listener — runs alongside sshd, exposes the invite-\n * accept endpoint so a new operator can redeem a token without first\n * having SSH access.\n *\n * Started by entrypoint.sh as the `git` user (which owns write access to\n * the membership sqlite via the root:git 0660 mode bits). Plain HTTP on\n * STAMP_HTTP_PORT (default 8080); TLS is the hosting platform's job\n * (Railway terminates TLS at its edge proxy; self-hosters terminate at\n * their own reverse proxy).\n *\n * Endpoints:\n *\n * POST /invite/accept\n * body: {token, ssh_pubkey, short_name, stamp_pubkey?}\n * 200: {ok:true, user_id, role, short_name}\n * 4xx: {ok:false, error:\"<reason>\"}\n *\n * GET /healthz\n * 200: {ok:true} — for orchestrator probes\n *\n * Hard cap on request body at 16 KiB. Sshd-style fail-open does NOT apply\n * here: this surface is the only path that mutates the users table from a\n * non-root context, so we surface real status codes and refuse the request\n * on any malformed input.\n */\n\nimport { createServer, type IncomingMessage, type ServerResponse } from \"node:http\";\nimport { consumeInviteToken, markInviteConsumer } from \"../lib/invites.js\";\nimport { insertUser, openServerDb } from \"../lib/serverDb.js\";\nimport { parseSshPubkey } from \"../lib/sshKeys.js\";\n\nconst DEFAULT_PORT = 8080;\nconst MAX_BODY_BYTES = 16 * 1024;\nconst SHORT_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,62}$/;\n// Stamp signing pubkeys are PEM-wrapped SPKI. Loose shape check here\n// (anchored so trailing/leading garbage doesn't slip in); the real\n// validation happens at trust-grant time when the key is consumed.\nconst STAMP_PUBKEY_PEM_RE =\n /^\\s*-----BEGIN PUBLIC KEY-----[A-Za-z0-9+/=\\s]+-----END PUBLIC KEY-----\\s*$/;\n\ninterface AcceptBody {\n token?: unknown;\n ssh_pubkey?: unknown;\n stamp_pubkey?: unknown;\n short_name?: unknown;\n}\n\nfunction logLine(level: \"info\" | \"warn\" | \"error\", msg: string): void {\n const ts = new Date().toISOString();\n const stream = level === \"error\" ? process.stderr : process.stdout;\n stream.write(`stamp-http-server ${ts} ${level} ${msg}\\n`);\n}\n\nfunction sendJson(\n res: ServerResponse,\n status: number,\n body: Record<string, unknown>,\n): void {\n const payload = JSON.stringify(body);\n res.writeHead(status, {\n \"Content-Type\": \"application/json; charset=utf-8\",\n \"Content-Length\": Buffer.byteLength(payload).toString(),\n // Hint to any future reverse proxy that responses here aren't cacheable\n // (they reflect single-use token state).\n \"Cache-Control\": \"no-store\",\n });\n res.end(payload);\n}\n\ninterface ReadBodyResult {\n buf: Buffer;\n tooLarge: boolean;\n}\n\nasync function readBody(req: IncomingMessage): Promise<ReadBodyResult> {\n return new Promise((resolve, reject) => {\n const chunks: Buffer[] = [];\n let total = 0;\n let tooLarge = false;\n req.on(\"data\", (chunk: Buffer) => {\n if (tooLarge) return;\n total += chunk.length;\n if (total > MAX_BODY_BYTES) {\n // Stop accumulating; the caller produces a 413 keyed on\n // `tooLarge` so the status-code contract is preserved. We don't\n // destroy the connection here — the response body still needs\n // to land.\n tooLarge = true;\n chunks.length = 0;\n return;\n }\n chunks.push(chunk);\n });\n req.on(\"end\", () => resolve({ buf: Buffer.concat(chunks), tooLarge }));\n req.on(\"error\", reject);\n });\n}\n\ninterface ValidatedAccept {\n token: string;\n ssh_pubkey: string;\n ssh_fp: string;\n short_name: string;\n stamp_pubkey: string | null;\n}\n\nfunction validateAcceptBody(body: AcceptBody):\n | { ok: true; data: ValidatedAccept }\n | { ok: false; status: number; error: string } {\n if (typeof body.token !== \"string\" || body.token.length === 0) {\n return { ok: false, status: 400, error: \"token_required\" };\n }\n // Bound the token length to defeat anyone passing a 16KB-shaped token\n // as a side-channel timing probe. Real tokens are 43 chars (32 bytes\n // base64url no padding).\n if (body.token.length > 128) {\n return { ok: false, status: 400, error: \"token_malformed\" };\n }\n if (typeof body.ssh_pubkey !== \"string\" || body.ssh_pubkey.length === 0) {\n return { ok: false, status: 400, error: \"ssh_pubkey_required\" };\n }\n if (typeof body.short_name !== \"string\" || !SHORT_NAME_RE.test(body.short_name)) {\n return { ok: false, status: 400, error: \"short_name_malformed\" };\n }\n let parsed;\n try {\n parsed = parseSshPubkey(body.ssh_pubkey);\n } catch (e) {\n return {\n ok: false,\n status: 400,\n error: `ssh_pubkey_invalid: ${(e as Error).message}`,\n };\n }\n\n let stamp_pubkey: string | null = null;\n if (body.stamp_pubkey !== undefined && body.stamp_pubkey !== null) {\n if (typeof body.stamp_pubkey !== \"string\") {\n return { ok: false, status: 400, error: \"stamp_pubkey_malformed\" };\n }\n if (!STAMP_PUBKEY_PEM_RE.test(body.stamp_pubkey)) {\n return { ok: false, status: 400, error: \"stamp_pubkey_not_pem\" };\n }\n stamp_pubkey = body.stamp_pubkey;\n }\n\n return {\n ok: true,\n data: {\n token: body.token,\n ssh_pubkey: parsed.full,\n ssh_fp: parsed.fingerprint,\n short_name: body.short_name,\n stamp_pubkey,\n },\n };\n}\n\ninterface AcceptOutcome {\n status: number;\n body: Record<string, unknown>;\n}\n\nfunction acceptInvite(data: ValidatedAccept): AcceptOutcome {\n // skipChmod: HTTP server runs as the git user, but the DB file is\n // root-owned (chmod fails with EPERM unless caller is owner).\n // entrypoint.sh handles boot-time perm tightening as root; the\n // in-process chmod would be redundant even if it could succeed.\n const db = openServerDb({ skipChmod: true });\n try {\n const consumed = consumeInviteToken(db, data.token);\n if (!consumed.ok) {\n const statusByReason: Record<typeof consumed.reason, number> = {\n not_found: 404,\n expired: 410,\n already_consumed: 410,\n };\n return {\n status: statusByReason[consumed.reason],\n body: { ok: false, error: `invite_${consumed.reason}` },\n };\n }\n\n // The consume above committed its own transaction; a failure on the\n // user insert below leaves the token consumed but no user row\n // created. We treat that as \"operator must mint a new invite\"\n // rather than rolling back — preserves the single-use property\n // against retry storms. UNIQUE-constraint collisions on\n // short_name / ssh_fp are operator/input errors (the invitee picked\n // a name already taken, or is trying to enroll a key already in the\n // table); other failures are propagated as internal_error upstream.\n let user_id: number;\n try {\n user_id = insertUser(db, {\n short_name: data.short_name,\n ssh_pubkey: data.ssh_pubkey,\n ssh_fp: data.ssh_fp,\n stamp_pubkey: data.stamp_pubkey,\n role: consumed.row.role,\n source: \"invite\",\n invited_by: consumed.row.invited_by,\n });\n } catch (e) {\n const msg = (e as Error).message;\n if (msg.includes(\"users.ssh_fp\")) {\n return {\n status: 409,\n body: { ok: false, error: \"ssh_pubkey_already_registered\" },\n };\n }\n if (msg.includes(\"users.short_name\")) {\n return {\n status: 409,\n body: { ok: false, error: \"short_name_taken\" },\n };\n }\n throw e;\n }\n\n markInviteConsumer(db, data.token, user_id);\n\n return {\n status: 200,\n body: {\n ok: true,\n user_id,\n role: consumed.row.role,\n short_name: data.short_name,\n },\n };\n } finally {\n db.close();\n }\n}\n\nasync function handlePost(req: IncomingMessage, res: ServerResponse): Promise<void> {\n if (req.headers[\"content-type\"]?.split(\";\")[0]?.trim() !== \"application/json\") {\n sendJson(res, 415, { ok: false, error: \"content_type_must_be_application_json\" });\n return;\n }\n let read: ReadBodyResult;\n try {\n read = await readBody(req);\n } catch (e) {\n logLine(\"warn\", `read body failed: ${(e as Error).message}`);\n sendJson(res, 400, { ok: false, error: \"body_read_failed\" });\n return;\n }\n if (read.tooLarge) {\n sendJson(res, 413, { ok: false, error: \"body_too_large\" });\n return;\n }\n let body: AcceptBody;\n try {\n body = JSON.parse(read.buf.toString(\"utf8\")) as AcceptBody;\n } catch {\n sendJson(res, 400, { ok: false, error: \"body_not_json\" });\n return;\n }\n const v = validateAcceptBody(body);\n if (!v.ok) {\n sendJson(res, v.status, { ok: false, error: v.error });\n return;\n }\n try {\n const outcome = acceptInvite(v.data);\n logLine(\n outcome.status === 200 ? \"info\" : \"warn\",\n `invite/accept short_name=${v.data.short_name} status=${outcome.status}`,\n );\n sendJson(res, outcome.status, outcome.body);\n } catch (e) {\n logLine(\"error\", `invite/accept internal error: ${(e as Error).message}`);\n sendJson(res, 500, { ok: false, error: \"internal_error\" });\n }\n}\n\nexport const HTTP_DEFAULT_PORT = DEFAULT_PORT;\n\nexport function startServer(port = DEFAULT_PORT): ReturnType<typeof createServer> {\n const server = createServer((req, res) => {\n const url = req.url ?? \"\";\n if (req.method === \"GET\" && (url === \"/healthz\" || url === \"/\")) {\n sendJson(res, 200, { ok: true });\n return;\n }\n if (req.method === \"POST\" && url === \"/invite/accept\") {\n void handlePost(req, res);\n return;\n }\n sendJson(res, 404, { ok: false, error: \"not_found\" });\n });\n server.listen(port, () => {\n logLine(\"info\", `listening on :${port}`);\n });\n return server;\n}\n","/**\n * Invite token mint / consume operations against the membership sqlite.\n *\n * Tokens are 32 bytes of cryptographically random data, base64url-encoded\n * (no padding) — 43 ASCII characters that survive copy-paste through\n * Slack/iMessage/wormhole/etc. without losing the trailing `=` padding\n * that several chat clients silently trim.\n *\n * TTL is 15 minutes. Tokens are single-use: consume marks `consumed_at`\n * and refuses subsequent consumption of the same token. Expiry is enforced\n * at consume time (the row stays in the DB for audit until phase 5 prune\n * support sweeps stale rows).\n *\n * The invite role is constrained to 'admin' or 'member' by the schema —\n * minting an owner-via-invite is not supported (owners are promoted via\n * the phase-3 self-promote path against an existing admin/member account).\n */\n\nimport { randomBytes } from \"node:crypto\";\nimport { DatabaseSync } from \"node:sqlite\";\nimport type { InviteRole, InviteRow } from \"./serverDb.js\";\n\nexport const INVITE_TTL_SECONDS = 15 * 60;\nexport const TOKEN_BYTES = 32;\nexport const TOKEN_LENGTH_CHARS = 43; // ceil(32 * 4 / 3) with no padding\n\n/** Generate a fresh single-use invite token: 32 bytes, base64url, no padding. */\nexport function generateInviteToken(): string {\n return randomBytes(TOKEN_BYTES).toString(\"base64url\");\n}\n\nexport interface MintInviteInput {\n role: InviteRole;\n invited_by: number;\n /** Override the wall-clock for deterministic tests. Seconds since epoch. */\n now?: number;\n /** Override the TTL for tests. Defaults to INVITE_TTL_SECONDS. */\n ttl_seconds?: number;\n}\n\nexport interface MintedInvite {\n token: string;\n expires_at: number;\n}\n\nexport function mintInvite(\n db: DatabaseSync,\n input: MintInviteInput,\n): MintedInvite {\n const token = generateInviteToken();\n const now = input.now ?? Math.floor(Date.now() / 1000);\n const ttl = input.ttl_seconds ?? INVITE_TTL_SECONDS;\n const expires_at = now + ttl;\n\n const stmt = db.prepare(\n `INSERT INTO invites (token, role, invited_by, created_at, expires_at)\n VALUES (?, ?, ?, ?, ?)`,\n );\n stmt.run(token, input.role, input.invited_by, now, expires_at);\n\n return { token, expires_at };\n}\n\nexport type ConsumeResult =\n | { ok: true; row: InviteRow }\n | { ok: false; reason: \"not_found\" | \"expired\" | \"already_consumed\" };\n\n/**\n * Atomically look up + consume a token in a single transaction. The atomic\n * read-then-write under SQLITE_BEGIN IMMEDIATE prevents two concurrent\n * accept requests from both succeeding on the same token — the second\n * sees `already_consumed` on its UPDATE.\n *\n * Caller is expected to ALSO write the user row inside the same DB call\n * sequence — but we expose consume as a separate primitive so the caller\n * can perform the user insert against the role from this invite without\n * carrying it through opaquely.\n */\nexport function consumeInviteToken(\n db: DatabaseSync,\n token: string,\n now?: number,\n): ConsumeResult {\n const wallclock = now ?? Math.floor(Date.now() / 1000);\n db.exec(\"BEGIN IMMEDIATE\");\n try {\n const selectStmt = db.prepare(`SELECT * FROM invites WHERE token = ?`);\n const row = selectStmt.get(token) as InviteRow | undefined;\n if (!row) {\n db.exec(\"ROLLBACK\");\n return { ok: false, reason: \"not_found\" };\n }\n if (row.consumed_at !== null) {\n db.exec(\"ROLLBACK\");\n return { ok: false, reason: \"already_consumed\" };\n }\n if (row.expires_at < wallclock) {\n db.exec(\"ROLLBACK\");\n return { ok: false, reason: \"expired\" };\n }\n\n // Mark consumed. consumed_by is set later by the caller once the user\n // row is inserted and we know its id.\n db.prepare(\n `UPDATE invites SET consumed_at = ? WHERE token = ? AND consumed_at IS NULL`,\n ).run(wallclock, token);\n\n db.exec(\"COMMIT\");\n return { ok: true, row };\n } catch (e) {\n try {\n db.exec(\"ROLLBACK\");\n } catch {\n // ignore — propagate the original error\n }\n throw e;\n }\n}\n\n/** Set the consumed_by user id on an already-consumed invite. */\nexport function markInviteConsumer(\n db: DatabaseSync,\n token: string,\n user_id: number,\n): void {\n db.prepare(`UPDATE invites SET consumed_by = ? WHERE token = ?`).run(\n user_id,\n token,\n );\n}\n\nexport function findInvite(db: DatabaseSync, token: string): InviteRow | null {\n const row = db\n .prepare(`SELECT * FROM invites WHERE token = ?`)\n .get(token) as InviteRow | undefined;\n return row ?? null;\n}\n","/**\n * Membership sqlite for the stamp server.\n *\n * Lives on the persistent volume at /srv/git/.stamp-state/users.db. Holds:\n * - users: SSH pubkey → role (owner/admin/member), optional stamp signing\n * pubkey, source provenance (env / bootstrap / invite / manual)\n * - invites: single-use, time-bounded tokens an admin mints to onboard\n * a teammate (phase 2)\n *\n * Two access modes:\n * - Writable (boot-time seed, admin operations): opens the DB read/write,\n * ensures schema, tightens perms on the file and parent dir.\n * - Read-only (sshd's AuthorizedKeysCommand): opens with readOnly:true so\n * the resolver process holds no write fd; lets us run the resolver as\n * an unprivileged user against a root:git 0640 DB without enabling\n * WAL-mode sidecars.\n *\n * Roles and invite roles are CHECK-constrained in the schema so a future\n * code-level bug introducing a typo'd role string fails at insert rather\n * than silently corrupting authorization data.\n */\n\nimport { chmodSync, existsSync } from \"node:fs\";\nimport { DatabaseSync } from \"node:sqlite\";\nimport { dirname } from \"node:path\";\nimport { ensureDir } from \"./paths.js\";\n\nexport type Role = \"owner\" | \"admin\" | \"member\";\nexport type UserSource = \"env\" | \"bootstrap\" | \"invite\" | \"manual\";\nexport type InviteRole = \"admin\" | \"member\";\n\nexport interface UserRow {\n id: number;\n short_name: string;\n ssh_pubkey: string;\n ssh_fp: string;\n stamp_pubkey: string | null;\n role: Role;\n source: UserSource;\n invited_by: number | null;\n created_at: number;\n last_seen_at: number | null;\n}\n\nexport interface InviteRow {\n token: string;\n role: InviteRole;\n invited_by: number;\n created_at: number;\n expires_at: number;\n consumed_at: number | null;\n consumed_by: number | null;\n}\n\n/** Default on-server path. Tests pass an explicit `path` instead. */\nexport const DEFAULT_SERVER_DB_PATH = \"/srv/git/.stamp-state/users.db\";\n\n/**\n * Resolve the effective DB path. Precedence:\n * 1. Explicit `opts.path` (tests, future config)\n * 2. STAMP_SERVER_DB_PATH env var (CLI-spawning tests; also a relief\n * valve for operators who want to relocate the DB on the volume)\n * 3. DEFAULT_SERVER_DB_PATH (production)\n */\nexport function resolveServerDbPath(explicit?: string): string {\n if (explicit) return explicit;\n const envPath = process.env[\"STAMP_SERVER_DB_PATH\"];\n if (envPath && envPath.length > 0) return envPath;\n return DEFAULT_SERVER_DB_PATH;\n}\n\nexport interface OpenServerDbOpts {\n /** Override the on-disk location. Required for tests. */\n path?: string;\n /** Open read-only. Skips schema init, skips chmod, and constructs the\n * DatabaseSync with readOnly:true. Used by the AuthorizedKeysCommand\n * resolver. */\n readOnly?: boolean;\n /** Skip filesystem-perm tightening of the DB file + parent dir. The\n * on-server boot path wants tightening; tests on tmpfs do not. */\n skipChmod?: boolean;\n}\n\nexport function openServerDb(opts: OpenServerDbOpts = {}): DatabaseSync {\n const path = resolveServerDbPath(opts.path);\n const readOnly = opts.readOnly ?? false;\n\n if (!readOnly) {\n const dir = dirname(path);\n ensureDir(dir, 0o750);\n if (!opts.skipChmod) {\n // ensureDir no-ops on an existing directory, so this explicit\n // chmod is what tightens perms on a redeploy where the dir was\n // created at a looser mode by an earlier image version.\n chmodSync(dir, 0o750);\n }\n }\n\n const db = new DatabaseSync(path, { readOnly });\n\n if (!readOnly) {\n db.exec(\"PRAGMA foreign_keys = ON\");\n initSchema(db);\n if (!opts.skipChmod && existsSync(path)) {\n // root:git 0660. The HTTP server (git user) writes new user rows\n // on invite-accept; the AuthorizedKeysCommand resolver also runs\n // as git but opens readOnly:true so the write bit is dormant on\n // its path. Chown is the operator's responsibility (entrypoint.sh\n // sets root:git after each boot); we only set the mode bits.\n //\n // Callers running as the git user (mint-invite, http-server) must\n // pass skipChmod:true — only the file owner can chmod on Linux,\n // and entrypoint.sh has already tightened perms by the time those\n // callers run.\n chmodSync(path, 0o660);\n }\n }\n\n return db;\n}\n\nfunction initSchema(db: DatabaseSync): void {\n db.exec(`\n CREATE TABLE IF NOT EXISTS users (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n short_name TEXT NOT NULL UNIQUE,\n ssh_pubkey TEXT NOT NULL,\n ssh_fp TEXT NOT NULL UNIQUE,\n stamp_pubkey TEXT,\n role TEXT NOT NULL CHECK (role IN ('owner','admin','member')),\n source TEXT NOT NULL DEFAULT 'invite' CHECK (source IN ('env','bootstrap','invite','manual')),\n invited_by INTEGER REFERENCES users(id),\n created_at INTEGER NOT NULL,\n last_seen_at INTEGER\n );\n\n CREATE INDEX IF NOT EXISTS idx_users_ssh_fp ON users(ssh_fp);\n\n CREATE TABLE IF NOT EXISTS invites (\n token TEXT PRIMARY KEY,\n role TEXT NOT NULL CHECK (role IN ('admin','member')),\n invited_by INTEGER NOT NULL REFERENCES users(id),\n created_at INTEGER NOT NULL,\n expires_at INTEGER NOT NULL,\n consumed_at INTEGER,\n consumed_by INTEGER REFERENCES users(id)\n );\n\n CREATE INDEX IF NOT EXISTS idx_invites_expires ON invites(expires_at);\n `);\n}\n\nexport interface InsertUserInput {\n short_name: string;\n ssh_pubkey: string;\n ssh_fp: string;\n stamp_pubkey?: string | null;\n role: Role;\n source: UserSource;\n invited_by?: number | null;\n}\n\n/** Insert a user. Throws if short_name or ssh_fp collide. */\nexport function insertUser(db: DatabaseSync, input: InsertUserInput): number {\n const stmt = db.prepare(\n `INSERT INTO users (short_name, ssh_pubkey, ssh_fp, stamp_pubkey, role, source, invited_by, created_at)\n VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,\n );\n const result = stmt.run(\n input.short_name,\n input.ssh_pubkey,\n input.ssh_fp,\n input.stamp_pubkey ?? null,\n input.role,\n input.source,\n input.invited_by ?? null,\n Math.floor(Date.now() / 1000),\n );\n return Number(result.lastInsertRowid);\n}\n\n/**\n * Idempotent insert keyed on ssh_fp. Returns the row id (newly-inserted or\n * pre-existing) and a `created` flag. Does NOT mutate role/short_name of an\n * existing row — the env-sync path runs on every boot, and we don't want\n * a manual admin demotion in the DB to be silently re-promoted by an\n * env-var entry that's still hanging around.\n *\n * If the caller's proposed short_name collides with an existing row that\n * has a DIFFERENT fingerprint, this throws. The seed-users entrypoint\n * handles that by appending a numeric suffix.\n */\nexport function upsertUserByFingerprint(\n db: DatabaseSync,\n input: InsertUserInput,\n): { id: number; created: boolean } {\n const existing = findUserBySshFingerprint(db, input.ssh_fp);\n if (existing) return { id: existing.id, created: false };\n const id = insertUser(db, input);\n return { id, created: true };\n}\n\nexport function findUserBySshFingerprint(\n db: DatabaseSync,\n ssh_fp: string,\n): UserRow | null {\n const stmt = db.prepare(\n `SELECT id, short_name, ssh_pubkey, ssh_fp, stamp_pubkey, role, source,\n invited_by, created_at, last_seen_at\n FROM users WHERE ssh_fp = ?`,\n );\n const row = stmt.get(ssh_fp) as UserRow | undefined;\n return row ?? null;\n}\n\nexport function findUserByShortName(\n db: DatabaseSync,\n short_name: string,\n): UserRow | null {\n const stmt = db.prepare(\n `SELECT id, short_name, ssh_pubkey, ssh_fp, stamp_pubkey, role, source,\n invited_by, created_at, last_seen_at\n FROM users WHERE short_name = ?`,\n );\n const row = stmt.get(short_name) as UserRow | undefined;\n return row ?? null;\n}\n\nexport function listUsers(db: DatabaseSync): UserRow[] {\n const stmt = db.prepare(\n `SELECT id, short_name, ssh_pubkey, ssh_fp, stamp_pubkey, role, source,\n invited_by, created_at, last_seen_at\n FROM users\n ORDER BY id`,\n );\n return stmt.all() as unknown as UserRow[];\n}\n\nexport function countByRole(db: DatabaseSync, role: Role): number {\n const stmt = db.prepare(`SELECT COUNT(*) AS n FROM users WHERE role = ?`);\n const row = stmt.get(role) as { n: number };\n return row.n;\n}\n\n/**\n * Generate a short_name that doesn't collide with any existing row. If\n * `desired` is free, returns it; otherwise appends `-2`, `-3`, ... until\n * a free slot is found. Used by the env-sync path where the proposed\n * short_name is derived from the SSH key's comment (often \"user@host\"),\n * which can collide if two keys share the same comment.\n */\nexport function suggestUniqueShortName(\n db: DatabaseSync,\n desired: string,\n): string {\n if (!findUserByShortName(db, desired)) return desired;\n for (let i = 2; i < 10000; i++) {\n const candidate = `${desired}-${i}`;\n if (!findUserByShortName(db, candidate)) return candidate;\n }\n throw new Error(\n `could not find a unique short_name for \"${desired}\" after 10000 attempts`,\n );\n}\n","import { existsSync, mkdirSync, readFileSync, statSync } from \"node:fs\";\nimport { homedir } from \"node:os\";\nimport { dirname, isAbsolute, join, resolve } from \"node:path\";\n\nexport function findRepoRoot(startFrom: string = process.cwd()): string {\n let current = resolve(startFrom);\n while (true) {\n if (existsSync(join(current, \".git\"))) return current;\n const parent = dirname(current);\n if (parent === current) {\n throw new Error(\n `not inside a git repository (searched up from ${startFrom})`,\n );\n }\n current = parent;\n }\n}\n\nexport function stampConfigDir(repoRoot: string): string {\n return join(repoRoot, \".stamp\");\n}\n\nexport function stampReviewersDir(repoRoot: string): string {\n return join(repoRoot, \".stamp\", \"reviewers\");\n}\n\nexport function stampTrustedKeysDir(repoRoot: string): string {\n return join(repoRoot, \".stamp\", \"trusted-keys\");\n}\n\nexport function stampConfigFile(repoRoot: string): string {\n return join(repoRoot, \".stamp\", \"config.yml\");\n}\n\nexport function stampStateDbPath(repoRoot: string): string {\n return join(gitCommonDir(repoRoot), \"stamp\", \"state.db\");\n}\n\n/**\n * Marker file that records \"we have shown the LLM data-flow notice in this\n * repo at least once.\" Lives next to state.db under the git common dir so\n * it's per-repo (not per-worktree, not committed).\n */\nexport function stampLlmNoticeMarkerPath(repoRoot: string): string {\n return join(gitCommonDir(repoRoot), \"stamp\", \"llm-notice-shown\");\n}\n\n/**\n * Resolve the git common directory for `repoRoot`. For a normal checkout this\n * is `<repoRoot>/.git`; for a worktree, `<repoRoot>/.git` is a *file* of the\n * form `gitdir: <path>` and the real common dir lives at `<gitdir>/commondir`\n * (a path relative to gitdir, typically `../..`). Mirrors `git rev-parse\n * --git-common-dir` without spawning git.\n *\n * State that should be shared across every worktree of one repository (review\n * verdicts, the per-machine sqlite db) lives under this common dir, so callers\n * resolve their paths through here rather than hard-coding `<repoRoot>/.git`.\n */\nexport function gitCommonDir(repoRoot: string): string {\n const dotGit = join(repoRoot, \".git\");\n const st = statSync(dotGit);\n if (st.isDirectory()) return dotGit;\n\n // Worktree (or submodule): `.git` is a file. Parse the `gitdir:` line, then\n // follow the `commondir` pointer from there. Submodules have no `commondir`,\n // so the gitdir itself is the writable common dir — fall through to that.\n const contents = readFileSync(dotGit, \"utf8\");\n const match = contents.match(/^gitdir:\\s*(.+)$/m);\n if (!match || !match[1]) {\n throw new Error(\n `expected '.git' at ${repoRoot} to be a directory or a 'gitdir:' pointer file, got: ${contents.slice(0, 120)}`,\n );\n }\n const gitdirRaw = match[1].trim();\n const gitdir = isAbsolute(gitdirRaw) ? gitdirRaw : resolve(repoRoot, gitdirRaw);\n\n const commondirPath = join(gitdir, \"commondir\");\n if (!existsSync(commondirPath)) return gitdir;\n const commondirRaw = readFileSync(commondirPath, \"utf8\").trim();\n return isAbsolute(commondirRaw) ? commondirRaw : resolve(gitdir, commondirRaw);\n}\n\nexport function userKeysDir(): string {\n return join(homedir(), \".stamp\", \"keys\");\n}\n\n/**\n * Per-user stamp-server config. Holds {host, port, user, repo_root_prefix}\n * so commands like `stamp provision` can reach the operator's stamp server\n * without making the agent guess at SSH endpoints.\n */\nexport function userServerConfigPath(): string {\n return join(homedir(), \".stamp\", \"server.yml\");\n}\n\n/**\n * Per-user stamp config. Today holds reviewer-model selections; structured\n * as a top-level object so future per-user knobs (telemetry sinks, default\n * timeouts, etc.) can land alongside without renaming the file. Lives\n * separately from per-repo `.stamp/config.yml` because cost/speed is\n * operator infrastructure rather than committed review policy — different\n * operators on the same repo are free to pick different models without\n * a merge-conflict over preference, and this file is intentionally\n * EXCLUDED from the v3 reviewer attestation hash chain.\n */\nexport function userConfigPath(): string {\n return join(homedir(), \".stamp\", \"config.yml\");\n}\n\nexport function ensureDir(path: string, mode = 0o755): void {\n if (!existsSync(path)) {\n mkdirSync(path, { recursive: true, mode });\n }\n}\n\nexport function isFile(path: string): boolean {\n try {\n return statSync(path).isFile();\n } catch {\n return false;\n }\n}\n","/**\n * Parse OpenSSH-format public keys and compute their SHA256 fingerprints\n * in the exact format sshd emits via the `%f` format specifier passed to\n * AuthorizedKeysCommand. That format is `SHA256:<base64-no-padding>`,\n * distinct from the `sha256:<hex>` form used elsewhere in stamp for stamp\n * signing keys (PEM/SPKI). Both formats exist; this module is the SSH\n * side only.\n *\n * The lookup keyed on this fingerprint is the load-bearing path for\n * sshd-based authentication of users stored in the membership sqlite —\n * any drift between this fingerprint and what sshd computes breaks\n * every connection. Test coverage pins the format to a known OpenSSH\n * fixture so the next regression here is loud.\n */\n\nimport { createHash } from \"node:crypto\";\n\nexport interface SshPubkey {\n /** Key algorithm token, e.g. \"ssh-ed25519\", \"ecdsa-sha2-nistp256\". */\n algorithm: string;\n /** Base64-decoded key blob (the bytes between the algorithm token and\n * the trailing comment in a public-key line). */\n keyBlob: Buffer;\n /** Trailing comment, typically \"user@host\". May be empty. */\n comment: string;\n /** The original single-line representation, trimmed of leading/trailing\n * whitespace. Stored verbatim in the membership DB so the value sshd\n * later prints back via AuthorizedKeysCommand is bit-identical to what\n * the operator submitted. */\n full: string;\n /** OpenSSH-style fingerprint: \"SHA256:<base64-no-padding>\". Matches the\n * `%f` value sshd passes to AuthorizedKeysCommand. */\n fingerprint: string;\n}\n\nconst ALLOWED_ALGOS = new Set([\n \"ssh-ed25519\",\n \"ssh-rsa\",\n \"ecdsa-sha2-nistp256\",\n \"ecdsa-sha2-nistp384\",\n \"ecdsa-sha2-nistp521\",\n]);\n\n/**\n * Parse a single OpenSSH-format public-key line into its components.\n * Rejects empty/blank lines and lines whose algorithm token is not on the\n * conservative allowlist (above) — keeps malformed input out of the DB\n * before we ever try to hand it to sshd.\n */\nexport function parseSshPubkey(line: string): SshPubkey {\n const trimmed = line.trim();\n if (trimmed.length === 0) {\n throw new Error(\"ssh pubkey line is empty\");\n }\n if (trimmed.startsWith(\"#\")) {\n throw new Error(\"ssh pubkey line is a comment\");\n }\n\n const parts = trimmed.split(/\\s+/);\n if (parts.length < 2) {\n throw new Error(\n \"ssh pubkey line must have at least <algorithm> <base64> tokens\",\n );\n }\n\n const [algorithm, b64, ...rest] = parts as [string, string, ...string[]];\n if (!ALLOWED_ALGOS.has(algorithm)) {\n throw new Error(`unsupported ssh pubkey algorithm: ${algorithm}`);\n }\n\n // Buffer.from(string, \"base64\") does NOT throw on invalid input — it\n // silently strips non-base64 characters. So a try/catch around this\n // call is dead code; the real validation is the re-encode comparison\n // below, which catches a paste with a stray quote/character that\n // would otherwise produce a key blob mismatched against sshd's view.\n const keyBlob = Buffer.from(b64, \"base64\");\n if (keyBlob.length === 0) {\n throw new Error(\"ssh pubkey base64 blob is empty\");\n }\n if (keyBlob.toString(\"base64\").replace(/=+$/, \"\") !== b64.replace(/=+$/, \"\")) {\n throw new Error(\"ssh pubkey base64 blob has trailing junk\");\n }\n\n return {\n algorithm,\n keyBlob,\n comment: rest.join(\" \"),\n full: trimmed,\n fingerprint: sshFingerprintFromBlob(keyBlob),\n };\n}\n\n/**\n * SHA256 fingerprint of a raw key blob in OpenSSH wire format. Output is\n * `SHA256:<base64-no-padding>` — the exact form sshd emits in logs and via\n * the `%f` format specifier.\n */\nexport function sshFingerprintFromBlob(keyBlob: Buffer): string {\n const hash = createHash(\"sha256\").update(keyBlob).digest();\n const b64 = hash.toString(\"base64\").replace(/=+$/, \"\");\n return `SHA256:${b64}`;\n}\n\n/**\n * Split a multi-line authorized_keys-style blob into individual valid pubkey\n * lines, dropping blanks and `#` comments. Returns parsed pubkeys and any\n * parse failures alongside their source line numbers — callers decide\n * whether to abort or log-and-continue.\n */\nexport function parseSshPubkeyList(blob: string): {\n pubkeys: SshPubkey[];\n errors: Array<{ lineNumber: number; line: string; error: string }>;\n} {\n const pubkeys: SshPubkey[] = [];\n const errors: Array<{ lineNumber: number; line: string; error: string }> = [];\n const lines = blob.split(\"\\n\");\n for (let i = 0; i < lines.length; i++) {\n const raw = lines[i] ?? \"\";\n const stripped = raw.trim();\n if (stripped.length === 0 || stripped.startsWith(\"#\")) continue;\n try {\n pubkeys.push(parseSshPubkey(stripped));\n } catch (e) {\n errors.push({\n lineNumber: i + 1,\n line: stripped,\n error: (e as Error).message,\n });\n }\n }\n return { pubkeys, errors };\n}\n","/**\n * Bundled entry point for the stamp server's HTTP listener.\n *\n * Ships as /usr/local/sbin/stamp-http-server. Entrypoint.sh spawns this\n * as the `git` user in the background before exec-ing sshd. The server\n * reads STAMP_HTTP_PORT (default 8080) for the listening port.\n *\n * Kept as a separate file from http-server.ts so the latter can be\n * imported by tests without spinning up a real socket at import time.\n */\n\nimport { HTTP_DEFAULT_PORT, startServer } from \"./http-server.js\";\n\nconst rawPort = process.env[\"STAMP_HTTP_PORT\"];\nconst port = rawPort ? Number(rawPort) : HTTP_DEFAULT_PORT;\nif (!Number.isInteger(port) || port < 1 || port > 65535) {\n process.stderr.write(\n `stamp-http-server: STAMP_HTTP_PORT must be an integer 1..65535 (got ${JSON.stringify(rawPort)})\\n`,\n );\n process.exit(2);\n}\nstartServer(port);\n"],"mappings":"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","names":["import_node_fs","import_node_path","import_node_crypto","resolve","port"]}
|
|
1
|
+
{"version":3,"sources":["../../src/server/http-server.ts","../../src/lib/invites.ts","../../src/lib/serverDb.ts","../../src/lib/paths.ts","../../src/lib/sshKeys.ts","../../src/server/start-http-server.ts"],"sourcesContent":["/**\n * Stamp server HTTP listener — runs alongside sshd, exposes the invite-\n * accept endpoint so a new operator can redeem a token without first\n * having SSH access.\n *\n * Started by entrypoint.sh as the `git` user (which owns write access to\n * the membership sqlite via the root:git 0660 mode bits). Plain HTTP on\n * STAMP_HTTP_PORT (default 8080); TLS is the hosting platform's job\n * (Railway terminates TLS at its edge proxy; self-hosters terminate at\n * their own reverse proxy).\n *\n * Endpoints:\n *\n * POST /invite/accept\n * body: {token, ssh_pubkey, short_name, stamp_pubkey?}\n * 200: {ok:true, user_id, role, short_name}\n * 4xx: {ok:false, error:\"<reason>\"}\n *\n * GET /healthz\n * 200: {ok:true} — for orchestrator probes\n *\n * Hard cap on request body at 16 KiB. Sshd-style fail-open does NOT apply\n * here: this surface is the only path that mutates the users table from a\n * non-root context, so we surface real status codes and refuse the request\n * on any malformed input.\n */\n\nimport { createServer, type IncomingMessage, type ServerResponse } from \"node:http\";\nimport { consumeInviteToken, markInviteConsumer } from \"../lib/invites.js\";\nimport { insertUser, openServerDb } from \"../lib/serverDb.js\";\nimport { parseSshPubkey } from \"../lib/sshKeys.js\";\n\nconst DEFAULT_PORT = 8080;\nconst MAX_BODY_BYTES = 16 * 1024;\nconst SHORT_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,62}$/;\n// Stamp signing pubkeys are PEM-wrapped SPKI. Loose shape check here\n// (anchored so trailing/leading garbage doesn't slip in); the real\n// validation happens at trust-grant time when the key is consumed.\nconst STAMP_PUBKEY_PEM_RE =\n /^\\s*-----BEGIN PUBLIC KEY-----[A-Za-z0-9+/=\\s]+-----END PUBLIC KEY-----\\s*$/;\n\ninterface AcceptBody {\n token?: unknown;\n ssh_pubkey?: unknown;\n stamp_pubkey?: unknown;\n short_name?: unknown;\n}\n\nfunction logLine(level: \"info\" | \"warn\" | \"error\", msg: string): void {\n const ts = new Date().toISOString();\n const stream = level === \"error\" ? process.stderr : process.stdout;\n stream.write(`stamp-http-server ${ts} ${level} ${msg}\\n`);\n}\n\nfunction sendJson(\n res: ServerResponse,\n status: number,\n body: Record<string, unknown>,\n): void {\n const payload = JSON.stringify(body);\n res.writeHead(status, {\n \"Content-Type\": \"application/json; charset=utf-8\",\n \"Content-Length\": Buffer.byteLength(payload).toString(),\n // Hint to any future reverse proxy that responses here aren't cacheable\n // (they reflect single-use token state).\n \"Cache-Control\": \"no-store\",\n });\n res.end(payload);\n}\n\ninterface ReadBodyResult {\n buf: Buffer;\n tooLarge: boolean;\n}\n\nasync function readBody(req: IncomingMessage): Promise<ReadBodyResult> {\n return new Promise((resolve, reject) => {\n const chunks: Buffer[] = [];\n let total = 0;\n let tooLarge = false;\n req.on(\"data\", (chunk: Buffer) => {\n if (tooLarge) return;\n total += chunk.length;\n if (total > MAX_BODY_BYTES) {\n // Stop accumulating; the caller produces a 413 keyed on\n // `tooLarge` so the status-code contract is preserved. We don't\n // destroy the connection here — the response body still needs\n // to land.\n tooLarge = true;\n chunks.length = 0;\n return;\n }\n chunks.push(chunk);\n });\n req.on(\"end\", () => resolve({ buf: Buffer.concat(chunks), tooLarge }));\n req.on(\"error\", reject);\n });\n}\n\ninterface ValidatedAccept {\n token: string;\n ssh_pubkey: string;\n ssh_fp: string;\n short_name: string;\n stamp_pubkey: string | null;\n}\n\nfunction validateAcceptBody(body: AcceptBody):\n | { ok: true; data: ValidatedAccept }\n | { ok: false; status: number; error: string } {\n if (typeof body.token !== \"string\" || body.token.length === 0) {\n return { ok: false, status: 400, error: \"token_required\" };\n }\n // Bound the token length to defeat anyone passing a 16KB-shaped token\n // as a side-channel timing probe. Real tokens are 43 chars (32 bytes\n // base64url no padding).\n if (body.token.length > 128) {\n return { ok: false, status: 400, error: \"token_malformed\" };\n }\n if (typeof body.ssh_pubkey !== \"string\" || body.ssh_pubkey.length === 0) {\n return { ok: false, status: 400, error: \"ssh_pubkey_required\" };\n }\n if (typeof body.short_name !== \"string\" || !SHORT_NAME_RE.test(body.short_name)) {\n return { ok: false, status: 400, error: \"short_name_malformed\" };\n }\n let parsed;\n try {\n parsed = parseSshPubkey(body.ssh_pubkey);\n } catch (e) {\n return {\n ok: false,\n status: 400,\n error: `ssh_pubkey_invalid: ${(e as Error).message}`,\n };\n }\n\n let stamp_pubkey: string | null = null;\n if (body.stamp_pubkey !== undefined && body.stamp_pubkey !== null) {\n if (typeof body.stamp_pubkey !== \"string\") {\n return { ok: false, status: 400, error: \"stamp_pubkey_malformed\" };\n }\n if (!STAMP_PUBKEY_PEM_RE.test(body.stamp_pubkey)) {\n return { ok: false, status: 400, error: \"stamp_pubkey_not_pem\" };\n }\n stamp_pubkey = body.stamp_pubkey;\n }\n\n return {\n ok: true,\n data: {\n token: body.token,\n ssh_pubkey: parsed.full,\n ssh_fp: parsed.fingerprint,\n short_name: body.short_name,\n stamp_pubkey,\n },\n };\n}\n\ninterface AcceptOutcome {\n status: number;\n body: Record<string, unknown>;\n}\n\nfunction acceptInvite(data: ValidatedAccept): AcceptOutcome {\n // skipChmod: HTTP server runs as the git user, but the DB file is\n // root-owned (chmod fails with EPERM unless caller is owner).\n // entrypoint.sh handles boot-time perm tightening as root; the\n // in-process chmod would be redundant even if it could succeed.\n const db = openServerDb({ skipChmod: true });\n try {\n const consumed = consumeInviteToken(db, data.token);\n if (!consumed.ok) {\n const statusByReason: Record<typeof consumed.reason, number> = {\n not_found: 404,\n expired: 410,\n already_consumed: 410,\n };\n return {\n status: statusByReason[consumed.reason],\n body: { ok: false, error: `invite_${consumed.reason}` },\n };\n }\n\n // The consume above committed its own transaction; a failure on the\n // user insert below leaves the token consumed but no user row\n // created. We treat that as \"operator must mint a new invite\"\n // rather than rolling back — preserves the single-use property\n // against retry storms. UNIQUE-constraint collisions on\n // short_name / ssh_fp are operator/input errors (the invitee picked\n // a name already taken, or is trying to enroll a key already in the\n // table); other failures are propagated as internal_error upstream.\n let user_id: number;\n try {\n user_id = insertUser(db, {\n short_name: data.short_name,\n ssh_pubkey: data.ssh_pubkey,\n ssh_fp: data.ssh_fp,\n stamp_pubkey: data.stamp_pubkey,\n role: consumed.row.role,\n source: \"invite\",\n invited_by: consumed.row.invited_by,\n });\n } catch (e) {\n const msg = (e as Error).message;\n if (msg.includes(\"users.ssh_fp\")) {\n return {\n status: 409,\n body: { ok: false, error: \"ssh_pubkey_already_registered\" },\n };\n }\n if (msg.includes(\"users.short_name\")) {\n return {\n status: 409,\n body: { ok: false, error: \"short_name_taken\" },\n };\n }\n throw e;\n }\n\n markInviteConsumer(db, data.token, user_id);\n\n return {\n status: 200,\n body: {\n ok: true,\n user_id,\n role: consumed.row.role,\n short_name: data.short_name,\n },\n };\n } finally {\n db.close();\n }\n}\n\nasync function handlePost(req: IncomingMessage, res: ServerResponse): Promise<void> {\n if (req.headers[\"content-type\"]?.split(\";\")[0]?.trim() !== \"application/json\") {\n sendJson(res, 415, { ok: false, error: \"content_type_must_be_application_json\" });\n return;\n }\n let read: ReadBodyResult;\n try {\n read = await readBody(req);\n } catch (e) {\n logLine(\"warn\", `read body failed: ${(e as Error).message}`);\n sendJson(res, 400, { ok: false, error: \"body_read_failed\" });\n return;\n }\n if (read.tooLarge) {\n sendJson(res, 413, { ok: false, error: \"body_too_large\" });\n return;\n }\n let body: AcceptBody;\n try {\n body = JSON.parse(read.buf.toString(\"utf8\")) as AcceptBody;\n } catch {\n sendJson(res, 400, { ok: false, error: \"body_not_json\" });\n return;\n }\n const v = validateAcceptBody(body);\n if (!v.ok) {\n sendJson(res, v.status, { ok: false, error: v.error });\n return;\n }\n try {\n const outcome = acceptInvite(v.data);\n logLine(\n outcome.status === 200 ? \"info\" : \"warn\",\n `invite/accept short_name=${v.data.short_name} status=${outcome.status}`,\n );\n sendJson(res, outcome.status, outcome.body);\n } catch (e) {\n logLine(\"error\", `invite/accept internal error: ${(e as Error).message}`);\n sendJson(res, 500, { ok: false, error: \"internal_error\" });\n }\n}\n\nexport const HTTP_DEFAULT_PORT = DEFAULT_PORT;\n\nexport function startServer(port = DEFAULT_PORT): ReturnType<typeof createServer> {\n const server = createServer((req, res) => {\n const url = req.url ?? \"\";\n if (req.method === \"GET\" && (url === \"/healthz\" || url === \"/\")) {\n sendJson(res, 200, { ok: true });\n return;\n }\n if (req.method === \"POST\" && url === \"/invite/accept\") {\n void handlePost(req, res);\n return;\n }\n sendJson(res, 404, { ok: false, error: \"not_found\" });\n });\n server.listen(port, () => {\n logLine(\"info\", `listening on :${port}`);\n });\n return server;\n}\n","/**\n * Invite token mint / consume operations against the membership sqlite.\n *\n * Tokens are 32 bytes of cryptographically random data, base64url-encoded\n * (no padding) — 43 ASCII characters that survive copy-paste through\n * Slack/iMessage/wormhole/etc. without losing the trailing `=` padding\n * that several chat clients silently trim.\n *\n * TTL is 15 minutes. Tokens are single-use: consume marks `consumed_at`\n * and refuses subsequent consumption of the same token. Expiry is enforced\n * at consume time (the row stays in the DB for audit until phase 5 prune\n * support sweeps stale rows).\n *\n * The invite role is constrained to 'admin' or 'member' by the schema —\n * minting an owner-via-invite is not supported (owners are promoted via\n * the phase-3 self-promote path against an existing admin/member account).\n */\n\nimport { randomBytes } from \"node:crypto\";\nimport { DatabaseSync } from \"node:sqlite\";\nimport type { InviteRole, InviteRow } from \"./serverDb.js\";\n\nexport const INVITE_TTL_SECONDS = 15 * 60;\nexport const TOKEN_BYTES = 32;\nexport const TOKEN_LENGTH_CHARS = 43; // ceil(32 * 4 / 3) with no padding\n\n/** Generate a fresh single-use invite token: 32 bytes, base64url, no padding. */\nexport function generateInviteToken(): string {\n return randomBytes(TOKEN_BYTES).toString(\"base64url\");\n}\n\nexport interface MintInviteInput {\n role: InviteRole;\n invited_by: number;\n /** Override the wall-clock for deterministic tests. Seconds since epoch. */\n now?: number;\n /** Override the TTL for tests. Defaults to INVITE_TTL_SECONDS. */\n ttl_seconds?: number;\n}\n\nexport interface MintedInvite {\n token: string;\n expires_at: number;\n}\n\nexport function mintInvite(\n db: DatabaseSync,\n input: MintInviteInput,\n): MintedInvite {\n const token = generateInviteToken();\n const now = input.now ?? Math.floor(Date.now() / 1000);\n const ttl = input.ttl_seconds ?? INVITE_TTL_SECONDS;\n const expires_at = now + ttl;\n\n const stmt = db.prepare(\n `INSERT INTO invites (token, role, invited_by, created_at, expires_at)\n VALUES (?, ?, ?, ?, ?)`,\n );\n stmt.run(token, input.role, input.invited_by, now, expires_at);\n\n return { token, expires_at };\n}\n\nexport type ConsumeResult =\n | { ok: true; row: InviteRow }\n | { ok: false; reason: \"not_found\" | \"expired\" | \"already_consumed\" };\n\n/**\n * Atomically look up + consume a token in a single transaction. The atomic\n * read-then-write under SQLITE_BEGIN IMMEDIATE prevents two concurrent\n * accept requests from both succeeding on the same token — the second\n * sees `already_consumed` on its UPDATE.\n *\n * Caller is expected to ALSO write the user row inside the same DB call\n * sequence — but we expose consume as a separate primitive so the caller\n * can perform the user insert against the role from this invite without\n * carrying it through opaquely.\n */\nexport function consumeInviteToken(\n db: DatabaseSync,\n token: string,\n now?: number,\n): ConsumeResult {\n const wallclock = now ?? Math.floor(Date.now() / 1000);\n db.exec(\"BEGIN IMMEDIATE\");\n try {\n const selectStmt = db.prepare(`SELECT * FROM invites WHERE token = ?`);\n const row = selectStmt.get(token) as InviteRow | undefined;\n if (!row) {\n db.exec(\"ROLLBACK\");\n return { ok: false, reason: \"not_found\" };\n }\n if (row.consumed_at !== null) {\n db.exec(\"ROLLBACK\");\n return { ok: false, reason: \"already_consumed\" };\n }\n if (row.expires_at < wallclock) {\n db.exec(\"ROLLBACK\");\n return { ok: false, reason: \"expired\" };\n }\n\n // Mark consumed. consumed_by is set later by the caller once the user\n // row is inserted and we know its id.\n db.prepare(\n `UPDATE invites SET consumed_at = ? WHERE token = ? AND consumed_at IS NULL`,\n ).run(wallclock, token);\n\n db.exec(\"COMMIT\");\n return { ok: true, row };\n } catch (e) {\n try {\n db.exec(\"ROLLBACK\");\n } catch {\n // ignore — propagate the original error\n }\n throw e;\n }\n}\n\n/** Set the consumed_by user id on an already-consumed invite. */\nexport function markInviteConsumer(\n db: DatabaseSync,\n token: string,\n user_id: number,\n): void {\n db.prepare(`UPDATE invites SET consumed_by = ? WHERE token = ?`).run(\n user_id,\n token,\n );\n}\n\nexport function findInvite(db: DatabaseSync, token: string): InviteRow | null {\n const row = db\n .prepare(`SELECT * FROM invites WHERE token = ?`)\n .get(token) as InviteRow | undefined;\n return row ?? null;\n}\n","/**\n * Membership sqlite for the stamp server.\n *\n * Lives on the persistent volume at /srv/git/.stamp-state/users.db. Holds:\n * - users: SSH pubkey → role (owner/admin/member), optional stamp signing\n * pubkey, source provenance (env / bootstrap / invite / manual)\n * - invites: single-use, time-bounded tokens an admin mints to onboard\n * a teammate (phase 2)\n *\n * Two access modes:\n * - Writable (boot-time seed, admin operations): opens the DB read/write,\n * ensures schema, tightens perms on the file and parent dir.\n * - Read-only (sshd's AuthorizedKeysCommand): opens with readOnly:true so\n * the resolver process holds no write fd; lets us run the resolver as\n * an unprivileged user against a root:git 0640 DB without enabling\n * WAL-mode sidecars.\n *\n * Roles and invite roles are CHECK-constrained in the schema so a future\n * code-level bug introducing a typo'd role string fails at insert rather\n * than silently corrupting authorization data.\n */\n\nimport { chmodSync, existsSync } from \"node:fs\";\nimport { DatabaseSync } from \"node:sqlite\";\nimport { dirname } from \"node:path\";\nimport { ensureDir } from \"./paths.js\";\n\nexport type Role = \"owner\" | \"admin\" | \"member\";\nexport type UserSource = \"env\" | \"bootstrap\" | \"invite\" | \"manual\";\nexport type InviteRole = \"admin\" | \"member\";\n\nexport interface UserRow {\n id: number;\n short_name: string;\n ssh_pubkey: string;\n ssh_fp: string;\n stamp_pubkey: string | null;\n role: Role;\n source: UserSource;\n invited_by: number | null;\n created_at: number;\n last_seen_at: number | null;\n}\n\nexport interface InviteRow {\n token: string;\n role: InviteRole;\n invited_by: number;\n created_at: number;\n expires_at: number;\n consumed_at: number | null;\n consumed_by: number | null;\n}\n\n/** Default on-server path. Tests pass an explicit `path` instead. */\nexport const DEFAULT_SERVER_DB_PATH = \"/srv/git/.stamp-state/users.db\";\n\n/**\n * Resolve the effective DB path. Precedence:\n * 1. Explicit `opts.path` (tests, future config)\n * 2. STAMP_SERVER_DB_PATH env var (CLI-spawning tests; also a relief\n * valve for operators who want to relocate the DB on the volume)\n * 3. DEFAULT_SERVER_DB_PATH (production)\n */\nexport function resolveServerDbPath(explicit?: string): string {\n if (explicit) return explicit;\n const envPath = process.env[\"STAMP_SERVER_DB_PATH\"];\n if (envPath && envPath.length > 0) return envPath;\n return DEFAULT_SERVER_DB_PATH;\n}\n\nexport interface OpenServerDbOpts {\n /** Override the on-disk location. Required for tests. */\n path?: string;\n /** Open read-only. Skips schema init, skips chmod, and constructs the\n * DatabaseSync with readOnly:true. Used by the AuthorizedKeysCommand\n * resolver. */\n readOnly?: boolean;\n /** Skip filesystem-perm tightening of the DB file + parent dir. The\n * on-server boot path wants tightening; tests on tmpfs do not. */\n skipChmod?: boolean;\n}\n\nexport function openServerDb(opts: OpenServerDbOpts = {}): DatabaseSync {\n const path = resolveServerDbPath(opts.path);\n const readOnly = opts.readOnly ?? false;\n\n if (!readOnly) {\n const dir = dirname(path);\n ensureDir(dir, 0o1770);\n if (!opts.skipChmod) {\n // 0o1770 = sticky bit (1) + rwx for owner + rwx for group + nothing\n // for other. Matches the chmod entrypoint.sh sets, intentionally:\n // - The 0o770 portion is required for sqlite to write its\n // `-journal` sidecar in this dir on every transaction (the\n // git user that runs the HTTP server and SSH wrappers needs\n // CREATE access). At 0o750 sqlite silently demotes the\n // connection to read-only and every UPDATE throws \"attempt\n // to write a readonly database\".\n // - The sticky bit prevents the git-group from renaming or\n // deleting files in this dir that ARE NOT owned by git —\n // so any future root-owned state file landing here is\n // protected from a git-shell-escapee even though the dir\n // is otherwise group-writable.\n // We must mirror the sticky bit here (not just rely on\n // entrypoint.sh) because seed-users.ts opens the DB writable\n // (no skipChmod) as root at boot, and a chmod 0o770 here\n // would silently STRIP the sticky bit entrypoint just set.\n // ensureDir no-ops on an existing directory, so this explicit\n // chmod is what re-applies sticky+770 on a redeploy where the\n // dir was created at an earlier looser mode.\n chmodSync(dir, 0o1770);\n }\n }\n\n const db = new DatabaseSync(path, { readOnly });\n\n if (!readOnly) {\n db.exec(\"PRAGMA foreign_keys = ON\");\n initSchema(db);\n if (!opts.skipChmod && existsSync(path)) {\n // root:git 0660. The HTTP server (git user) writes new user rows\n // on invite-accept; the AuthorizedKeysCommand resolver also runs\n // as git but opens readOnly:true so the write bit is dormant on\n // its path. Chown is the operator's responsibility (entrypoint.sh\n // sets root:git after each boot); we only set the mode bits.\n //\n // Callers running as the git user (mint-invite, http-server) must\n // pass skipChmod:true — only the file owner can chmod on Linux,\n // and entrypoint.sh has already tightened perms by the time those\n // callers run.\n chmodSync(path, 0o660);\n }\n }\n\n return db;\n}\n\nfunction initSchema(db: DatabaseSync): void {\n db.exec(`\n CREATE TABLE IF NOT EXISTS users (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n short_name TEXT NOT NULL UNIQUE,\n ssh_pubkey TEXT NOT NULL,\n ssh_fp TEXT NOT NULL UNIQUE,\n stamp_pubkey TEXT,\n role TEXT NOT NULL CHECK (role IN ('owner','admin','member')),\n source TEXT NOT NULL DEFAULT 'invite' CHECK (source IN ('env','bootstrap','invite','manual')),\n invited_by INTEGER REFERENCES users(id),\n created_at INTEGER NOT NULL,\n last_seen_at INTEGER\n );\n\n CREATE INDEX IF NOT EXISTS idx_users_ssh_fp ON users(ssh_fp);\n\n CREATE TABLE IF NOT EXISTS invites (\n token TEXT PRIMARY KEY,\n role TEXT NOT NULL CHECK (role IN ('admin','member')),\n invited_by INTEGER NOT NULL REFERENCES users(id),\n created_at INTEGER NOT NULL,\n expires_at INTEGER NOT NULL,\n consumed_at INTEGER,\n consumed_by INTEGER REFERENCES users(id)\n );\n\n CREATE INDEX IF NOT EXISTS idx_invites_expires ON invites(expires_at);\n `);\n}\n\nexport interface InsertUserInput {\n short_name: string;\n ssh_pubkey: string;\n ssh_fp: string;\n stamp_pubkey?: string | null;\n role: Role;\n source: UserSource;\n invited_by?: number | null;\n}\n\n/** Insert a user. Throws if short_name or ssh_fp collide. */\nexport function insertUser(db: DatabaseSync, input: InsertUserInput): number {\n const stmt = db.prepare(\n `INSERT INTO users (short_name, ssh_pubkey, ssh_fp, stamp_pubkey, role, source, invited_by, created_at)\n VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,\n );\n const result = stmt.run(\n input.short_name,\n input.ssh_pubkey,\n input.ssh_fp,\n input.stamp_pubkey ?? null,\n input.role,\n input.source,\n input.invited_by ?? null,\n Math.floor(Date.now() / 1000),\n );\n return Number(result.lastInsertRowid);\n}\n\n/**\n * Idempotent insert keyed on ssh_fp. Returns the row id (newly-inserted or\n * pre-existing) and a `created` flag. Does NOT mutate role/short_name of an\n * existing row — the env-sync path runs on every boot, and we don't want\n * a manual admin demotion in the DB to be silently re-promoted by an\n * env-var entry that's still hanging around.\n *\n * If the caller's proposed short_name collides with an existing row that\n * has a DIFFERENT fingerprint, this throws. The seed-users entrypoint\n * handles that by appending a numeric suffix.\n */\nexport function upsertUserByFingerprint(\n db: DatabaseSync,\n input: InsertUserInput,\n): { id: number; created: boolean } {\n const existing = findUserBySshFingerprint(db, input.ssh_fp);\n if (existing) return { id: existing.id, created: false };\n const id = insertUser(db, input);\n return { id, created: true };\n}\n\nexport function findUserBySshFingerprint(\n db: DatabaseSync,\n ssh_fp: string,\n): UserRow | null {\n const stmt = db.prepare(\n `SELECT id, short_name, ssh_pubkey, ssh_fp, stamp_pubkey, role, source,\n invited_by, created_at, last_seen_at\n FROM users WHERE ssh_fp = ?`,\n );\n const row = stmt.get(ssh_fp) as UserRow | undefined;\n return row ?? null;\n}\n\nexport function findUserByShortName(\n db: DatabaseSync,\n short_name: string,\n): UserRow | null {\n const stmt = db.prepare(\n `SELECT id, short_name, ssh_pubkey, ssh_fp, stamp_pubkey, role, source,\n invited_by, created_at, last_seen_at\n FROM users WHERE short_name = ?`,\n );\n const row = stmt.get(short_name) as UserRow | undefined;\n return row ?? null;\n}\n\nexport function listUsers(db: DatabaseSync): UserRow[] {\n const stmt = db.prepare(\n `SELECT id, short_name, ssh_pubkey, ssh_fp, stamp_pubkey, role, source,\n invited_by, created_at, last_seen_at\n FROM users\n ORDER BY id`,\n );\n return stmt.all() as unknown as UserRow[];\n}\n\nexport function countByRole(db: DatabaseSync, role: Role): number {\n const stmt = db.prepare(`SELECT COUNT(*) AS n FROM users WHERE role = ?`);\n const row = stmt.get(role) as { n: number };\n return row.n;\n}\n\n/**\n * Generate a short_name that doesn't collide with any existing row. If\n * `desired` is free, returns it; otherwise appends `-2`, `-3`, ... until\n * a free slot is found. Used by the env-sync path where the proposed\n * short_name is derived from the SSH key's comment (often \"user@host\"),\n * which can collide if two keys share the same comment.\n */\nexport function suggestUniqueShortName(\n db: DatabaseSync,\n desired: string,\n): string {\n if (!findUserByShortName(db, desired)) return desired;\n for (let i = 2; i < 10000; i++) {\n const candidate = `${desired}-${i}`;\n if (!findUserByShortName(db, candidate)) return candidate;\n }\n throw new Error(\n `could not find a unique short_name for \"${desired}\" after 10000 attempts`,\n );\n}\n","import { existsSync, mkdirSync, readFileSync, statSync } from \"node:fs\";\nimport { homedir } from \"node:os\";\nimport { dirname, isAbsolute, join, resolve } from \"node:path\";\n\nexport function findRepoRoot(startFrom: string = process.cwd()): string {\n let current = resolve(startFrom);\n while (true) {\n if (existsSync(join(current, \".git\"))) return current;\n const parent = dirname(current);\n if (parent === current) {\n throw new Error(\n `not inside a git repository (searched up from ${startFrom})`,\n );\n }\n current = parent;\n }\n}\n\nexport function stampConfigDir(repoRoot: string): string {\n return join(repoRoot, \".stamp\");\n}\n\nexport function stampReviewersDir(repoRoot: string): string {\n return join(repoRoot, \".stamp\", \"reviewers\");\n}\n\nexport function stampTrustedKeysDir(repoRoot: string): string {\n return join(repoRoot, \".stamp\", \"trusted-keys\");\n}\n\nexport function stampConfigFile(repoRoot: string): string {\n return join(repoRoot, \".stamp\", \"config.yml\");\n}\n\nexport function stampStateDbPath(repoRoot: string): string {\n return join(gitCommonDir(repoRoot), \"stamp\", \"state.db\");\n}\n\n/**\n * Marker file that records \"we have shown the LLM data-flow notice in this\n * repo at least once.\" Lives next to state.db under the git common dir so\n * it's per-repo (not per-worktree, not committed).\n */\nexport function stampLlmNoticeMarkerPath(repoRoot: string): string {\n return join(gitCommonDir(repoRoot), \"stamp\", \"llm-notice-shown\");\n}\n\n/**\n * Resolve the git common directory for `repoRoot`. For a normal checkout this\n * is `<repoRoot>/.git`; for a worktree, `<repoRoot>/.git` is a *file* of the\n * form `gitdir: <path>` and the real common dir lives at `<gitdir>/commondir`\n * (a path relative to gitdir, typically `../..`). Mirrors `git rev-parse\n * --git-common-dir` without spawning git.\n *\n * State that should be shared across every worktree of one repository (review\n * verdicts, the per-machine sqlite db) lives under this common dir, so callers\n * resolve their paths through here rather than hard-coding `<repoRoot>/.git`.\n */\nexport function gitCommonDir(repoRoot: string): string {\n const dotGit = join(repoRoot, \".git\");\n const st = statSync(dotGit);\n if (st.isDirectory()) return dotGit;\n\n // Worktree (or submodule): `.git` is a file. Parse the `gitdir:` line, then\n // follow the `commondir` pointer from there. Submodules have no `commondir`,\n // so the gitdir itself is the writable common dir — fall through to that.\n const contents = readFileSync(dotGit, \"utf8\");\n const match = contents.match(/^gitdir:\\s*(.+)$/m);\n if (!match || !match[1]) {\n throw new Error(\n `expected '.git' at ${repoRoot} to be a directory or a 'gitdir:' pointer file, got: ${contents.slice(0, 120)}`,\n );\n }\n const gitdirRaw = match[1].trim();\n const gitdir = isAbsolute(gitdirRaw) ? gitdirRaw : resolve(repoRoot, gitdirRaw);\n\n const commondirPath = join(gitdir, \"commondir\");\n if (!existsSync(commondirPath)) return gitdir;\n const commondirRaw = readFileSync(commondirPath, \"utf8\").trim();\n return isAbsolute(commondirRaw) ? commondirRaw : resolve(gitdir, commondirRaw);\n}\n\nexport function userKeysDir(): string {\n return join(homedir(), \".stamp\", \"keys\");\n}\n\n/**\n * Per-user stamp-server config. Holds {host, port, user, repo_root_prefix}\n * so commands like `stamp provision` can reach the operator's stamp server\n * without making the agent guess at SSH endpoints.\n */\nexport function userServerConfigPath(): string {\n return join(homedir(), \".stamp\", \"server.yml\");\n}\n\n/**\n * Per-user stamp config. Today holds reviewer-model selections; structured\n * as a top-level object so future per-user knobs (telemetry sinks, default\n * timeouts, etc.) can land alongside without renaming the file. Lives\n * separately from per-repo `.stamp/config.yml` because cost/speed is\n * operator infrastructure rather than committed review policy — different\n * operators on the same repo are free to pick different models without\n * a merge-conflict over preference, and this file is intentionally\n * EXCLUDED from the v3 reviewer attestation hash chain.\n */\nexport function userConfigPath(): string {\n return join(homedir(), \".stamp\", \"config.yml\");\n}\n\nexport function ensureDir(path: string, mode = 0o755): void {\n if (!existsSync(path)) {\n mkdirSync(path, { recursive: true, mode });\n }\n}\n\nexport function isFile(path: string): boolean {\n try {\n return statSync(path).isFile();\n } catch {\n return false;\n }\n}\n","/**\n * Parse OpenSSH-format public keys and compute their SHA256 fingerprints\n * in the exact format sshd emits via the `%f` format specifier passed to\n * AuthorizedKeysCommand. That format is `SHA256:<base64-no-padding>`,\n * distinct from the `sha256:<hex>` form used elsewhere in stamp for stamp\n * signing keys (PEM/SPKI). Both formats exist; this module is the SSH\n * side only.\n *\n * The lookup keyed on this fingerprint is the load-bearing path for\n * sshd-based authentication of users stored in the membership sqlite —\n * any drift between this fingerprint and what sshd computes breaks\n * every connection. Test coverage pins the format to a known OpenSSH\n * fixture so the next regression here is loud.\n */\n\nimport { createHash } from \"node:crypto\";\n\nexport interface SshPubkey {\n /** Key algorithm token, e.g. \"ssh-ed25519\", \"ecdsa-sha2-nistp256\". */\n algorithm: string;\n /** Base64-decoded key blob (the bytes between the algorithm token and\n * the trailing comment in a public-key line). */\n keyBlob: Buffer;\n /** Trailing comment, typically \"user@host\". May be empty. */\n comment: string;\n /** The original single-line representation, trimmed of leading/trailing\n * whitespace. Stored verbatim in the membership DB so the value sshd\n * later prints back via AuthorizedKeysCommand is bit-identical to what\n * the operator submitted. */\n full: string;\n /** OpenSSH-style fingerprint: \"SHA256:<base64-no-padding>\". Matches the\n * `%f` value sshd passes to AuthorizedKeysCommand. */\n fingerprint: string;\n}\n\nconst ALLOWED_ALGOS = new Set([\n \"ssh-ed25519\",\n \"ssh-rsa\",\n \"ecdsa-sha2-nistp256\",\n \"ecdsa-sha2-nistp384\",\n \"ecdsa-sha2-nistp521\",\n]);\n\n/**\n * Parse a single OpenSSH-format public-key line into its components.\n * Rejects empty/blank lines and lines whose algorithm token is not on the\n * conservative allowlist (above) — keeps malformed input out of the DB\n * before we ever try to hand it to sshd.\n */\nexport function parseSshPubkey(line: string): SshPubkey {\n const trimmed = line.trim();\n if (trimmed.length === 0) {\n throw new Error(\"ssh pubkey line is empty\");\n }\n if (trimmed.startsWith(\"#\")) {\n throw new Error(\"ssh pubkey line is a comment\");\n }\n\n const parts = trimmed.split(/\\s+/);\n if (parts.length < 2) {\n throw new Error(\n \"ssh pubkey line must have at least <algorithm> <base64> tokens\",\n );\n }\n\n const [algorithm, b64, ...rest] = parts as [string, string, ...string[]];\n if (!ALLOWED_ALGOS.has(algorithm)) {\n throw new Error(`unsupported ssh pubkey algorithm: ${algorithm}`);\n }\n\n // Buffer.from(string, \"base64\") does NOT throw on invalid input — it\n // silently strips non-base64 characters. So a try/catch around this\n // call is dead code; the real validation is the re-encode comparison\n // below, which catches a paste with a stray quote/character that\n // would otherwise produce a key blob mismatched against sshd's view.\n const keyBlob = Buffer.from(b64, \"base64\");\n if (keyBlob.length === 0) {\n throw new Error(\"ssh pubkey base64 blob is empty\");\n }\n if (keyBlob.toString(\"base64\").replace(/=+$/, \"\") !== b64.replace(/=+$/, \"\")) {\n throw new Error(\"ssh pubkey base64 blob has trailing junk\");\n }\n\n return {\n algorithm,\n keyBlob,\n comment: rest.join(\" \"),\n full: trimmed,\n fingerprint: sshFingerprintFromBlob(keyBlob),\n };\n}\n\n/**\n * SHA256 fingerprint of a raw key blob in OpenSSH wire format. Output is\n * `SHA256:<base64-no-padding>` — the exact form sshd emits in logs and via\n * the `%f` format specifier.\n */\nexport function sshFingerprintFromBlob(keyBlob: Buffer): string {\n const hash = createHash(\"sha256\").update(keyBlob).digest();\n const b64 = hash.toString(\"base64\").replace(/=+$/, \"\");\n return `SHA256:${b64}`;\n}\n\n/**\n * Split a multi-line authorized_keys-style blob into individual valid pubkey\n * lines, dropping blanks and `#` comments. Returns parsed pubkeys and any\n * parse failures alongside their source line numbers — callers decide\n * whether to abort or log-and-continue.\n */\nexport function parseSshPubkeyList(blob: string): {\n pubkeys: SshPubkey[];\n errors: Array<{ lineNumber: number; line: string; error: string }>;\n} {\n const pubkeys: SshPubkey[] = [];\n const errors: Array<{ lineNumber: number; line: string; error: string }> = [];\n const lines = blob.split(\"\\n\");\n for (let i = 0; i < lines.length; i++) {\n const raw = lines[i] ?? \"\";\n const stripped = raw.trim();\n if (stripped.length === 0 || stripped.startsWith(\"#\")) continue;\n try {\n pubkeys.push(parseSshPubkey(stripped));\n } catch (e) {\n errors.push({\n lineNumber: i + 1,\n line: stripped,\n error: (e as Error).message,\n });\n }\n }\n return { pubkeys, errors };\n}\n","/**\n * Bundled entry point for the stamp server's HTTP listener.\n *\n * Ships as /usr/local/sbin/stamp-http-server. Entrypoint.sh spawns this\n * as the `git` user in the background before exec-ing sshd. The server\n * reads STAMP_HTTP_PORT (default 8080) for the listening port.\n *\n * Kept as a separate file from http-server.ts so the latter can be\n * imported by tests without spinning up a real socket at import time.\n */\n\nimport { HTTP_DEFAULT_PORT, startServer } from \"./http-server.js\";\n\nconst rawPort = process.env[\"STAMP_HTTP_PORT\"];\nconst port = rawPort ? Number(rawPort) : HTTP_DEFAULT_PORT;\nif (!Number.isInteger(port) || port < 1 || port > 65535) {\n process.stderr.write(\n `stamp-http-server: STAMP_HTTP_PORT must be an integer 1..65535 (got ${JSON.stringify(rawPort)})\\n`,\n );\n process.exit(2);\n}\nstartServer(port);\n"],"mappings":";;;;AA2BA,uBAAwE;;;ACTxE,yBAA4B;AAIrB,IAAM,qBAAqB,KAAK;AAwDhC,SAAS,mBACd,IACA,OACA,KACe;AACf,QAAM,YAAY,OAAO,KAAK,MAAM,KAAK,IAAI,IAAI,GAAI;AACrD,KAAG,KAAK,iBAAiB;AACzB,MAAI;AACF,UAAM,aAAa,GAAG,QAAQ,uCAAuC;AACrE,UAAM,MAAM,WAAW,IAAI,KAAK;AAChC,QAAI,CAAC,KAAK;AACR,SAAG,KAAK,UAAU;AAClB,aAAO,EAAE,IAAI,OAAO,QAAQ,YAAY;AAAA,IAC1C;AACA,QAAI,IAAI,gBAAgB,MAAM;AAC5B,SAAG,KAAK,UAAU;AAClB,aAAO,EAAE,IAAI,OAAO,QAAQ,mBAAmB;AAAA,IACjD;AACA,QAAI,IAAI,aAAa,WAAW;AAC9B,SAAG,KAAK,UAAU;AAClB,aAAO,EAAE,IAAI,OAAO,QAAQ,UAAU;AAAA,IACxC;AAIA,OAAG;AAAA,MACD;AAAA,IACF,EAAE,IAAI,WAAW,KAAK;AAEtB,OAAG,KAAK,QAAQ;AAChB,WAAO,EAAE,IAAI,MAAM,IAAI;AAAA,EACzB,SAAS,GAAG;AACV,QAAI;AACF,SAAG,KAAK,UAAU;AAAA,IACpB,QAAQ;AAAA,IAER;AACA,UAAM;AAAA,EACR;AACF;AAGO,SAAS,mBACd,IACA,OACA,SACM;AACN,KAAG,QAAQ,oDAAoD,EAAE;AAAA,IAC/D;AAAA,IACA;AAAA,EACF;AACF;;;AC3GA,IAAAA,kBAAsC;AACtC,yBAA6B;AAC7B,IAAAC,oBAAwB;;;ACxBxB,qBAA8D;AAC9D,qBAAwB;AACxB,uBAAmD;AA2G5C,SAAS,UAAU,MAAc,OAAO,KAAa;AAC1D,MAAI,KAAC,2BAAW,IAAI,GAAG;AACrB,kCAAU,MAAM,EAAE,WAAW,MAAM,KAAK,CAAC;AAAA,EAC3C;AACF;;;AD1DO,IAAM,yBAAyB;AAS/B,SAAS,oBAAoB,UAA2B;AAC7D,MAAI,SAAU,QAAO;AACrB,QAAM,UAAU,QAAQ,IAAI,sBAAsB;AAClD,MAAI,WAAW,QAAQ,SAAS,EAAG,QAAO;AAC1C,SAAO;AACT;AAcO,SAAS,aAAa,OAAyB,CAAC,GAAiB;AACtE,QAAM,OAAO,oBAAoB,KAAK,IAAI;AAC1C,QAAM,WAAW,KAAK,YAAY;AAElC,MAAI,CAAC,UAAU;AACb,UAAM,UAAM,2BAAQ,IAAI;AACxB,cAAU,KAAK,IAAM;AACrB,QAAI,CAAC,KAAK,WAAW;AAqBnB,qCAAU,KAAK,IAAM;AAAA,IACvB;AAAA,EACF;AAEA,QAAM,KAAK,IAAI,gCAAa,MAAM,EAAE,SAAS,CAAC;AAE9C,MAAI,CAAC,UAAU;AACb,OAAG,KAAK,0BAA0B;AAClC,eAAW,EAAE;AACb,QAAI,CAAC,KAAK,iBAAa,4BAAW,IAAI,GAAG;AAWvC,qCAAU,MAAM,GAAK;AAAA,IACvB;AAAA,EACF;AAEA,SAAO;AACT;AAEA,SAAS,WAAW,IAAwB;AAC1C,KAAG,KAAK;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,GA2BP;AACH;AAaO,SAAS,WAAW,IAAkB,OAAgC;AAC3E,QAAM,OAAO,GAAG;AAAA,IACd;AAAA;AAAA,EAEF;AACA,QAAM,SAAS,KAAK;AAAA,IAClB,MAAM;AAAA,IACN,MAAM;AAAA,IACN,MAAM;AAAA,IACN,MAAM,gBAAgB;AAAA,IACtB,MAAM;AAAA,IACN,MAAM;AAAA,IACN,MAAM,cAAc;AAAA,IACpB,KAAK,MAAM,KAAK,IAAI,IAAI,GAAI;AAAA,EAC9B;AACA,SAAO,OAAO,OAAO,eAAe;AACtC;;;AErLA,IAAAC,sBAA2B;AAoB3B,IAAM,gBAAgB,oBAAI,IAAI;AAAA,EAC5B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,CAAC;AAQM,SAAS,eAAe,MAAyB;AACtD,QAAM,UAAU,KAAK,KAAK;AAC1B,MAAI,QAAQ,WAAW,GAAG;AACxB,UAAM,IAAI,MAAM,0BAA0B;AAAA,EAC5C;AACA,MAAI,QAAQ,WAAW,GAAG,GAAG;AAC3B,UAAM,IAAI,MAAM,8BAA8B;AAAA,EAChD;AAEA,QAAM,QAAQ,QAAQ,MAAM,KAAK;AACjC,MAAI,MAAM,SAAS,GAAG;AACpB,UAAM,IAAI;AAAA,MACR;AAAA,IACF;AAAA,EACF;AAEA,QAAM,CAAC,WAAW,KAAK,GAAG,IAAI,IAAI;AAClC,MAAI,CAAC,cAAc,IAAI,SAAS,GAAG;AACjC,UAAM,IAAI,MAAM,qCAAqC,SAAS,EAAE;AAAA,EAClE;AAOA,QAAM,UAAU,OAAO,KAAK,KAAK,QAAQ;AACzC,MAAI,QAAQ,WAAW,GAAG;AACxB,UAAM,IAAI,MAAM,iCAAiC;AAAA,EACnD;AACA,MAAI,QAAQ,SAAS,QAAQ,EAAE,QAAQ,OAAO,EAAE,MAAM,IAAI,QAAQ,OAAO,EAAE,GAAG;AAC5E,UAAM,IAAI,MAAM,0CAA0C;AAAA,EAC5D;AAEA,SAAO;AAAA,IACL;AAAA,IACA;AAAA,IACA,SAAS,KAAK,KAAK,GAAG;AAAA,IACtB,MAAM;AAAA,IACN,aAAa,uBAAuB,OAAO;AAAA,EAC7C;AACF;AAOO,SAAS,uBAAuB,SAAyB;AAC9D,QAAM,WAAO,gCAAW,QAAQ,EAAE,OAAO,OAAO,EAAE,OAAO;AACzD,QAAM,MAAM,KAAK,SAAS,QAAQ,EAAE,QAAQ,OAAO,EAAE;AACrD,SAAO,UAAU,GAAG;AACtB;;;AJrEA,IAAM,eAAe;AACrB,IAAM,iBAAiB,KAAK;AAC5B,IAAM,gBAAgB;AAItB,IAAM,sBACJ;AASF,SAAS,QAAQ,OAAkC,KAAmB;AACpE,QAAM,MAAK,oBAAI,KAAK,GAAE,YAAY;AAClC,QAAM,SAAS,UAAU,UAAU,QAAQ,SAAS,QAAQ;AAC5D,SAAO,MAAM,qBAAqB,EAAE,IAAI,KAAK,IAAI,GAAG;AAAA,CAAI;AAC1D;AAEA,SAAS,SACP,KACA,QACA,MACM;AACN,QAAM,UAAU,KAAK,UAAU,IAAI;AACnC,MAAI,UAAU,QAAQ;AAAA,IACpB,gBAAgB;AAAA,IAChB,kBAAkB,OAAO,WAAW,OAAO,EAAE,SAAS;AAAA;AAAA;AAAA,IAGtD,iBAAiB;AAAA,EACnB,CAAC;AACD,MAAI,IAAI,OAAO;AACjB;AAOA,eAAe,SAAS,KAA+C;AACrE,SAAO,IAAI,QAAQ,CAACC,UAAS,WAAW;AACtC,UAAM,SAAmB,CAAC;AAC1B,QAAI,QAAQ;AACZ,QAAI,WAAW;AACf,QAAI,GAAG,QAAQ,CAAC,UAAkB;AAChC,UAAI,SAAU;AACd,eAAS,MAAM;AACf,UAAI,QAAQ,gBAAgB;AAK1B,mBAAW;AACX,eAAO,SAAS;AAChB;AAAA,MACF;AACA,aAAO,KAAK,KAAK;AAAA,IACnB,CAAC;AACD,QAAI,GAAG,OAAO,MAAMA,SAAQ,EAAE,KAAK,OAAO,OAAO,MAAM,GAAG,SAAS,CAAC,CAAC;AACrE,QAAI,GAAG,SAAS,MAAM;AAAA,EACxB,CAAC;AACH;AAUA,SAAS,mBAAmB,MAEqB;AAC/C,MAAI,OAAO,KAAK,UAAU,YAAY,KAAK,MAAM,WAAW,GAAG;AAC7D,WAAO,EAAE,IAAI,OAAO,QAAQ,KAAK,OAAO,iBAAiB;AAAA,EAC3D;AAIA,MAAI,KAAK,MAAM,SAAS,KAAK;AAC3B,WAAO,EAAE,IAAI,OAAO,QAAQ,KAAK,OAAO,kBAAkB;AAAA,EAC5D;AACA,MAAI,OAAO,KAAK,eAAe,YAAY,KAAK,WAAW,WAAW,GAAG;AACvE,WAAO,EAAE,IAAI,OAAO,QAAQ,KAAK,OAAO,sBAAsB;AAAA,EAChE;AACA,MAAI,OAAO,KAAK,eAAe,YAAY,CAAC,cAAc,KAAK,KAAK,UAAU,GAAG;AAC/E,WAAO,EAAE,IAAI,OAAO,QAAQ,KAAK,OAAO,uBAAuB;AAAA,EACjE;AACA,MAAI;AACJ,MAAI;AACF,aAAS,eAAe,KAAK,UAAU;AAAA,EACzC,SAAS,GAAG;AACV,WAAO;AAAA,MACL,IAAI;AAAA,MACJ,QAAQ;AAAA,MACR,OAAO,uBAAwB,EAAY,OAAO;AAAA,IACpD;AAAA,EACF;AAEA,MAAI,eAA8B;AAClC,MAAI,KAAK,iBAAiB,UAAa,KAAK,iBAAiB,MAAM;AACjE,QAAI,OAAO,KAAK,iBAAiB,UAAU;AACzC,aAAO,EAAE,IAAI,OAAO,QAAQ,KAAK,OAAO,yBAAyB;AAAA,IACnE;AACA,QAAI,CAAC,oBAAoB,KAAK,KAAK,YAAY,GAAG;AAChD,aAAO,EAAE,IAAI,OAAO,QAAQ,KAAK,OAAO,uBAAuB;AAAA,IACjE;AACA,mBAAe,KAAK;AAAA,EACtB;AAEA,SAAO;AAAA,IACL,IAAI;AAAA,IACJ,MAAM;AAAA,MACJ,OAAO,KAAK;AAAA,MACZ,YAAY,OAAO;AAAA,MACnB,QAAQ,OAAO;AAAA,MACf,YAAY,KAAK;AAAA,MACjB;AAAA,IACF;AAAA,EACF;AACF;AAOA,SAAS,aAAa,MAAsC;AAK1D,QAAM,KAAK,aAAa,EAAE,WAAW,KAAK,CAAC;AAC3C,MAAI;AACF,UAAM,WAAW,mBAAmB,IAAI,KAAK,KAAK;AAClD,QAAI,CAAC,SAAS,IAAI;AAChB,YAAM,iBAAyD;AAAA,QAC7D,WAAW;AAAA,QACX,SAAS;AAAA,QACT,kBAAkB;AAAA,MACpB;AACA,aAAO;AAAA,QACL,QAAQ,eAAe,SAAS,MAAM;AAAA,QACtC,MAAM,EAAE,IAAI,OAAO,OAAO,UAAU,SAAS,MAAM,GAAG;AAAA,MACxD;AAAA,IACF;AAUA,QAAI;AACJ,QAAI;AACF,gBAAU,WAAW,IAAI;AAAA,QACvB,YAAY,KAAK;AAAA,QACjB,YAAY,KAAK;AAAA,QACjB,QAAQ,KAAK;AAAA,QACb,cAAc,KAAK;AAAA,QACnB,MAAM,SAAS,IAAI;AAAA,QACnB,QAAQ;AAAA,QACR,YAAY,SAAS,IAAI;AAAA,MAC3B,CAAC;AAAA,IACH,SAAS,GAAG;AACV,YAAM,MAAO,EAAY;AACzB,UAAI,IAAI,SAAS,cAAc,GAAG;AAChC,eAAO;AAAA,UACL,QAAQ;AAAA,UACR,MAAM,EAAE,IAAI,OAAO,OAAO,gCAAgC;AAAA,QAC5D;AAAA,MACF;AACA,UAAI,IAAI,SAAS,kBAAkB,GAAG;AACpC,eAAO;AAAA,UACL,QAAQ;AAAA,UACR,MAAM,EAAE,IAAI,OAAO,OAAO,mBAAmB;AAAA,QAC/C;AAAA,MACF;AACA,YAAM;AAAA,IACR;AAEA,uBAAmB,IAAI,KAAK,OAAO,OAAO;AAE1C,WAAO;AAAA,MACL,QAAQ;AAAA,MACR,MAAM;AAAA,QACJ,IAAI;AAAA,QACJ;AAAA,QACA,MAAM,SAAS,IAAI;AAAA,QACnB,YAAY,KAAK;AAAA,MACnB;AAAA,IACF;AAAA,EACF,UAAE;AACA,OAAG,MAAM;AAAA,EACX;AACF;AAEA,eAAe,WAAW,KAAsB,KAAoC;AAClF,MAAI,IAAI,QAAQ,cAAc,GAAG,MAAM,GAAG,EAAE,CAAC,GAAG,KAAK,MAAM,oBAAoB;AAC7E,aAAS,KAAK,KAAK,EAAE,IAAI,OAAO,OAAO,wCAAwC,CAAC;AAChF;AAAA,EACF;AACA,MAAI;AACJ,MAAI;AACF,WAAO,MAAM,SAAS,GAAG;AAAA,EAC3B,SAAS,GAAG;AACV,YAAQ,QAAQ,qBAAsB,EAAY,OAAO,EAAE;AAC3D,aAAS,KAAK,KAAK,EAAE,IAAI,OAAO,OAAO,mBAAmB,CAAC;AAC3D;AAAA,EACF;AACA,MAAI,KAAK,UAAU;AACjB,aAAS,KAAK,KAAK,EAAE,IAAI,OAAO,OAAO,iBAAiB,CAAC;AACzD;AAAA,EACF;AACA,MAAI;AACJ,MAAI;AACF,WAAO,KAAK,MAAM,KAAK,IAAI,SAAS,MAAM,CAAC;AAAA,EAC7C,QAAQ;AACN,aAAS,KAAK,KAAK,EAAE,IAAI,OAAO,OAAO,gBAAgB,CAAC;AACxD;AAAA,EACF;AACA,QAAM,IAAI,mBAAmB,IAAI;AACjC,MAAI,CAAC,EAAE,IAAI;AACT,aAAS,KAAK,EAAE,QAAQ,EAAE,IAAI,OAAO,OAAO,EAAE,MAAM,CAAC;AACrD;AAAA,EACF;AACA,MAAI;AACF,UAAM,UAAU,aAAa,EAAE,IAAI;AACnC;AAAA,MACE,QAAQ,WAAW,MAAM,SAAS;AAAA,MAClC,4BAA4B,EAAE,KAAK,UAAU,WAAW,QAAQ,MAAM;AAAA,IACxE;AACA,aAAS,KAAK,QAAQ,QAAQ,QAAQ,IAAI;AAAA,EAC5C,SAAS,GAAG;AACV,YAAQ,SAAS,iCAAkC,EAAY,OAAO,EAAE;AACxE,aAAS,KAAK,KAAK,EAAE,IAAI,OAAO,OAAO,iBAAiB,CAAC;AAAA,EAC3D;AACF;AAEO,IAAM,oBAAoB;AAE1B,SAAS,YAAYC,QAAO,cAA+C;AAChF,QAAM,aAAS,+BAAa,CAAC,KAAK,QAAQ;AACxC,UAAM,MAAM,IAAI,OAAO;AACvB,QAAI,IAAI,WAAW,UAAU,QAAQ,cAAc,QAAQ,MAAM;AAC/D,eAAS,KAAK,KAAK,EAAE,IAAI,KAAK,CAAC;AAC/B;AAAA,IACF;AACA,QAAI,IAAI,WAAW,UAAU,QAAQ,kBAAkB;AACrD,WAAK,WAAW,KAAK,GAAG;AACxB;AAAA,IACF;AACA,aAAS,KAAK,KAAK,EAAE,IAAI,OAAO,OAAO,YAAY,CAAC;AAAA,EACtD,CAAC;AACD,SAAO,OAAOA,OAAM,MAAM;AACxB,YAAQ,QAAQ,iBAAiBA,KAAI,EAAE;AAAA,EACzC,CAAC;AACD,SAAO;AACT;;;AK5RA,IAAM,UAAU,QAAQ,IAAI,iBAAiB;AAC7C,IAAM,OAAO,UAAU,OAAO,OAAO,IAAI;AACzC,IAAI,CAAC,OAAO,UAAU,IAAI,KAAK,OAAO,KAAK,OAAO,OAAO;AACvD,UAAQ,OAAO;AAAA,IACb,uEAAuE,KAAK,UAAU,OAAO,CAAC;AAAA;AAAA,EAChG;AACA,UAAQ,KAAK,CAAC;AAChB;AACA,YAAY,IAAI;","names":["import_node_fs","import_node_path","import_node_crypto","resolve","port"]}
|
|
@@ -29,9 +29,9 @@ function openServerDb(opts = {}) {
|
|
|
29
29
|
const readOnly = opts.readOnly ?? false;
|
|
30
30
|
if (!readOnly) {
|
|
31
31
|
const dir = (0, import_node_path2.dirname)(path);
|
|
32
|
-
ensureDir(dir,
|
|
32
|
+
ensureDir(dir, 1016);
|
|
33
33
|
if (!opts.skipChmod) {
|
|
34
|
-
(0, import_node_fs2.chmodSync)(dir,
|
|
34
|
+
(0, import_node_fs2.chmodSync)(dir, 1016);
|
|
35
35
|
}
|
|
36
36
|
}
|
|
37
37
|
const db = new import_node_sqlite.DatabaseSync(path, { readOnly });
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/lib/serverDb.ts","../../src/lib/paths.ts","../../src/lib/userOps.ts","../../src/lib/sshUserAuth.ts","../../src/lib/sshKeys.ts","../../src/server/users-cli.ts"],"sourcesContent":["/**\n * Membership sqlite for the stamp server.\n *\n * Lives on the persistent volume at /srv/git/.stamp-state/users.db. Holds:\n * - users: SSH pubkey → role (owner/admin/member), optional stamp signing\n * pubkey, source provenance (env / bootstrap / invite / manual)\n * - invites: single-use, time-bounded tokens an admin mints to onboard\n * a teammate (phase 2)\n *\n * Two access modes:\n * - Writable (boot-time seed, admin operations): opens the DB read/write,\n * ensures schema, tightens perms on the file and parent dir.\n * - Read-only (sshd's AuthorizedKeysCommand): opens with readOnly:true so\n * the resolver process holds no write fd; lets us run the resolver as\n * an unprivileged user against a root:git 0640 DB without enabling\n * WAL-mode sidecars.\n *\n * Roles and invite roles are CHECK-constrained in the schema so a future\n * code-level bug introducing a typo'd role string fails at insert rather\n * than silently corrupting authorization data.\n */\n\nimport { chmodSync, existsSync } from \"node:fs\";\nimport { DatabaseSync } from \"node:sqlite\";\nimport { dirname } from \"node:path\";\nimport { ensureDir } from \"./paths.js\";\n\nexport type Role = \"owner\" | \"admin\" | \"member\";\nexport type UserSource = \"env\" | \"bootstrap\" | \"invite\" | \"manual\";\nexport type InviteRole = \"admin\" | \"member\";\n\nexport interface UserRow {\n id: number;\n short_name: string;\n ssh_pubkey: string;\n ssh_fp: string;\n stamp_pubkey: string | null;\n role: Role;\n source: UserSource;\n invited_by: number | null;\n created_at: number;\n last_seen_at: number | null;\n}\n\nexport interface InviteRow {\n token: string;\n role: InviteRole;\n invited_by: number;\n created_at: number;\n expires_at: number;\n consumed_at: number | null;\n consumed_by: number | null;\n}\n\n/** Default on-server path. Tests pass an explicit `path` instead. */\nexport const DEFAULT_SERVER_DB_PATH = \"/srv/git/.stamp-state/users.db\";\n\n/**\n * Resolve the effective DB path. Precedence:\n * 1. Explicit `opts.path` (tests, future config)\n * 2. STAMP_SERVER_DB_PATH env var (CLI-spawning tests; also a relief\n * valve for operators who want to relocate the DB on the volume)\n * 3. DEFAULT_SERVER_DB_PATH (production)\n */\nexport function resolveServerDbPath(explicit?: string): string {\n if (explicit) return explicit;\n const envPath = process.env[\"STAMP_SERVER_DB_PATH\"];\n if (envPath && envPath.length > 0) return envPath;\n return DEFAULT_SERVER_DB_PATH;\n}\n\nexport interface OpenServerDbOpts {\n /** Override the on-disk location. Required for tests. */\n path?: string;\n /** Open read-only. Skips schema init, skips chmod, and constructs the\n * DatabaseSync with readOnly:true. Used by the AuthorizedKeysCommand\n * resolver. */\n readOnly?: boolean;\n /** Skip filesystem-perm tightening of the DB file + parent dir. The\n * on-server boot path wants tightening; tests on tmpfs do not. */\n skipChmod?: boolean;\n}\n\nexport function openServerDb(opts: OpenServerDbOpts = {}): DatabaseSync {\n const path = resolveServerDbPath(opts.path);\n const readOnly = opts.readOnly ?? false;\n\n if (!readOnly) {\n const dir = dirname(path);\n ensureDir(dir, 0o750);\n if (!opts.skipChmod) {\n // ensureDir no-ops on an existing directory, so this explicit\n // chmod is what tightens perms on a redeploy where the dir was\n // created at a looser mode by an earlier image version.\n chmodSync(dir, 0o750);\n }\n }\n\n const db = new DatabaseSync(path, { readOnly });\n\n if (!readOnly) {\n db.exec(\"PRAGMA foreign_keys = ON\");\n initSchema(db);\n if (!opts.skipChmod && existsSync(path)) {\n // root:git 0660. The HTTP server (git user) writes new user rows\n // on invite-accept; the AuthorizedKeysCommand resolver also runs\n // as git but opens readOnly:true so the write bit is dormant on\n // its path. Chown is the operator's responsibility (entrypoint.sh\n // sets root:git after each boot); we only set the mode bits.\n //\n // Callers running as the git user (mint-invite, http-server) must\n // pass skipChmod:true — only the file owner can chmod on Linux,\n // and entrypoint.sh has already tightened perms by the time those\n // callers run.\n chmodSync(path, 0o660);\n }\n }\n\n return db;\n}\n\nfunction initSchema(db: DatabaseSync): void {\n db.exec(`\n CREATE TABLE IF NOT EXISTS users (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n short_name TEXT NOT NULL UNIQUE,\n ssh_pubkey TEXT NOT NULL,\n ssh_fp TEXT NOT NULL UNIQUE,\n stamp_pubkey TEXT,\n role TEXT NOT NULL CHECK (role IN ('owner','admin','member')),\n source TEXT NOT NULL DEFAULT 'invite' CHECK (source IN ('env','bootstrap','invite','manual')),\n invited_by INTEGER REFERENCES users(id),\n created_at INTEGER NOT NULL,\n last_seen_at INTEGER\n );\n\n CREATE INDEX IF NOT EXISTS idx_users_ssh_fp ON users(ssh_fp);\n\n CREATE TABLE IF NOT EXISTS invites (\n token TEXT PRIMARY KEY,\n role TEXT NOT NULL CHECK (role IN ('admin','member')),\n invited_by INTEGER NOT NULL REFERENCES users(id),\n created_at INTEGER NOT NULL,\n expires_at INTEGER NOT NULL,\n consumed_at INTEGER,\n consumed_by INTEGER REFERENCES users(id)\n );\n\n CREATE INDEX IF NOT EXISTS idx_invites_expires ON invites(expires_at);\n `);\n}\n\nexport interface InsertUserInput {\n short_name: string;\n ssh_pubkey: string;\n ssh_fp: string;\n stamp_pubkey?: string | null;\n role: Role;\n source: UserSource;\n invited_by?: number | null;\n}\n\n/** Insert a user. Throws if short_name or ssh_fp collide. */\nexport function insertUser(db: DatabaseSync, input: InsertUserInput): number {\n const stmt = db.prepare(\n `INSERT INTO users (short_name, ssh_pubkey, ssh_fp, stamp_pubkey, role, source, invited_by, created_at)\n VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,\n );\n const result = stmt.run(\n input.short_name,\n input.ssh_pubkey,\n input.ssh_fp,\n input.stamp_pubkey ?? null,\n input.role,\n input.source,\n input.invited_by ?? null,\n Math.floor(Date.now() / 1000),\n );\n return Number(result.lastInsertRowid);\n}\n\n/**\n * Idempotent insert keyed on ssh_fp. Returns the row id (newly-inserted or\n * pre-existing) and a `created` flag. Does NOT mutate role/short_name of an\n * existing row — the env-sync path runs on every boot, and we don't want\n * a manual admin demotion in the DB to be silently re-promoted by an\n * env-var entry that's still hanging around.\n *\n * If the caller's proposed short_name collides with an existing row that\n * has a DIFFERENT fingerprint, this throws. The seed-users entrypoint\n * handles that by appending a numeric suffix.\n */\nexport function upsertUserByFingerprint(\n db: DatabaseSync,\n input: InsertUserInput,\n): { id: number; created: boolean } {\n const existing = findUserBySshFingerprint(db, input.ssh_fp);\n if (existing) return { id: existing.id, created: false };\n const id = insertUser(db, input);\n return { id, created: true };\n}\n\nexport function findUserBySshFingerprint(\n db: DatabaseSync,\n ssh_fp: string,\n): UserRow | null {\n const stmt = db.prepare(\n `SELECT id, short_name, ssh_pubkey, ssh_fp, stamp_pubkey, role, source,\n invited_by, created_at, last_seen_at\n FROM users WHERE ssh_fp = ?`,\n );\n const row = stmt.get(ssh_fp) as UserRow | undefined;\n return row ?? null;\n}\n\nexport function findUserByShortName(\n db: DatabaseSync,\n short_name: string,\n): UserRow | null {\n const stmt = db.prepare(\n `SELECT id, short_name, ssh_pubkey, ssh_fp, stamp_pubkey, role, source,\n invited_by, created_at, last_seen_at\n FROM users WHERE short_name = ?`,\n );\n const row = stmt.get(short_name) as UserRow | undefined;\n return row ?? null;\n}\n\nexport function listUsers(db: DatabaseSync): UserRow[] {\n const stmt = db.prepare(\n `SELECT id, short_name, ssh_pubkey, ssh_fp, stamp_pubkey, role, source,\n invited_by, created_at, last_seen_at\n FROM users\n ORDER BY id`,\n );\n return stmt.all() as unknown as UserRow[];\n}\n\nexport function countByRole(db: DatabaseSync, role: Role): number {\n const stmt = db.prepare(`SELECT COUNT(*) AS n FROM users WHERE role = ?`);\n const row = stmt.get(role) as { n: number };\n return row.n;\n}\n\n/**\n * Generate a short_name that doesn't collide with any existing row. If\n * `desired` is free, returns it; otherwise appends `-2`, `-3`, ... until\n * a free slot is found. Used by the env-sync path where the proposed\n * short_name is derived from the SSH key's comment (often \"user@host\"),\n * which can collide if two keys share the same comment.\n */\nexport function suggestUniqueShortName(\n db: DatabaseSync,\n desired: string,\n): string {\n if (!findUserByShortName(db, desired)) return desired;\n for (let i = 2; i < 10000; i++) {\n const candidate = `${desired}-${i}`;\n if (!findUserByShortName(db, candidate)) return candidate;\n }\n throw new Error(\n `could not find a unique short_name for \"${desired}\" after 10000 attempts`,\n );\n}\n","import { existsSync, mkdirSync, readFileSync, statSync } from \"node:fs\";\nimport { homedir } from \"node:os\";\nimport { dirname, isAbsolute, join, resolve } from \"node:path\";\n\nexport function findRepoRoot(startFrom: string = process.cwd()): string {\n let current = resolve(startFrom);\n while (true) {\n if (existsSync(join(current, \".git\"))) return current;\n const parent = dirname(current);\n if (parent === current) {\n throw new Error(\n `not inside a git repository (searched up from ${startFrom})`,\n );\n }\n current = parent;\n }\n}\n\nexport function stampConfigDir(repoRoot: string): string {\n return join(repoRoot, \".stamp\");\n}\n\nexport function stampReviewersDir(repoRoot: string): string {\n return join(repoRoot, \".stamp\", \"reviewers\");\n}\n\nexport function stampTrustedKeysDir(repoRoot: string): string {\n return join(repoRoot, \".stamp\", \"trusted-keys\");\n}\n\nexport function stampConfigFile(repoRoot: string): string {\n return join(repoRoot, \".stamp\", \"config.yml\");\n}\n\nexport function stampStateDbPath(repoRoot: string): string {\n return join(gitCommonDir(repoRoot), \"stamp\", \"state.db\");\n}\n\n/**\n * Marker file that records \"we have shown the LLM data-flow notice in this\n * repo at least once.\" Lives next to state.db under the git common dir so\n * it's per-repo (not per-worktree, not committed).\n */\nexport function stampLlmNoticeMarkerPath(repoRoot: string): string {\n return join(gitCommonDir(repoRoot), \"stamp\", \"llm-notice-shown\");\n}\n\n/**\n * Resolve the git common directory for `repoRoot`. For a normal checkout this\n * is `<repoRoot>/.git`; for a worktree, `<repoRoot>/.git` is a *file* of the\n * form `gitdir: <path>` and the real common dir lives at `<gitdir>/commondir`\n * (a path relative to gitdir, typically `../..`). Mirrors `git rev-parse\n * --git-common-dir` without spawning git.\n *\n * State that should be shared across every worktree of one repository (review\n * verdicts, the per-machine sqlite db) lives under this common dir, so callers\n * resolve their paths through here rather than hard-coding `<repoRoot>/.git`.\n */\nexport function gitCommonDir(repoRoot: string): string {\n const dotGit = join(repoRoot, \".git\");\n const st = statSync(dotGit);\n if (st.isDirectory()) return dotGit;\n\n // Worktree (or submodule): `.git` is a file. Parse the `gitdir:` line, then\n // follow the `commondir` pointer from there. Submodules have no `commondir`,\n // so the gitdir itself is the writable common dir — fall through to that.\n const contents = readFileSync(dotGit, \"utf8\");\n const match = contents.match(/^gitdir:\\s*(.+)$/m);\n if (!match || !match[1]) {\n throw new Error(\n `expected '.git' at ${repoRoot} to be a directory or a 'gitdir:' pointer file, got: ${contents.slice(0, 120)}`,\n );\n }\n const gitdirRaw = match[1].trim();\n const gitdir = isAbsolute(gitdirRaw) ? gitdirRaw : resolve(repoRoot, gitdirRaw);\n\n const commondirPath = join(gitdir, \"commondir\");\n if (!existsSync(commondirPath)) return gitdir;\n const commondirRaw = readFileSync(commondirPath, \"utf8\").trim();\n return isAbsolute(commondirRaw) ? commondirRaw : resolve(gitdir, commondirRaw);\n}\n\nexport function userKeysDir(): string {\n return join(homedir(), \".stamp\", \"keys\");\n}\n\n/**\n * Per-user stamp-server config. Holds {host, port, user, repo_root_prefix}\n * so commands like `stamp provision` can reach the operator's stamp server\n * without making the agent guess at SSH endpoints.\n */\nexport function userServerConfigPath(): string {\n return join(homedir(), \".stamp\", \"server.yml\");\n}\n\n/**\n * Per-user stamp config. Today holds reviewer-model selections; structured\n * as a top-level object so future per-user knobs (telemetry sinks, default\n * timeouts, etc.) can land alongside without renaming the file. Lives\n * separately from per-repo `.stamp/config.yml` because cost/speed is\n * operator infrastructure rather than committed review policy — different\n * operators on the same repo are free to pick different models without\n * a merge-conflict over preference, and this file is intentionally\n * EXCLUDED from the v3 reviewer attestation hash chain.\n */\nexport function userConfigPath(): string {\n return join(homedir(), \".stamp\", \"config.yml\");\n}\n\nexport function ensureDir(path: string, mode = 0o755): void {\n if (!existsSync(path)) {\n mkdirSync(path, { recursive: true, mode });\n }\n}\n\nexport function isFile(path: string): boolean {\n try {\n return statSync(path).isFile();\n } catch {\n return false;\n }\n}\n","/**\n * User-management operations against the membership sqlite, with the\n * role authority matrix and last-owner guard enforced in one place.\n *\n * Authority matrix:\n *\n * - Owner: full control. May change anyone's role to anything (subject\n * to the last-owner guard) and remove anyone (subject to the same).\n * - Admin: may manage MEMBERS only. May not modify admins or owners,\n * and may not promote anyone to admin/owner. Bootstrap exception:\n * when no owners exist anywhere in the table, an admin may promote\n * THEMSELVES to owner — exactly once per server lifetime, since\n * after that promotion the no-owners precondition no longer holds.\n * - Member: no user-management surface at all.\n *\n * Last-owner guard: any operation that would leave zero owners (demoting\n * the last owner, or removing the last owner) is refused with a\n * `last_owner_would_be_lost` reason. Operators are expected to promote a\n * successor first.\n *\n * These rules are server-side and authoritative; the CLI sends the\n * caller's request and the server decides. Putting them in a lib module\n * (not the SSH wrapper) so unit tests can exercise the full matrix\n * without spawning subprocesses.\n */\n\nimport { DatabaseSync } from \"node:sqlite\";\nimport {\n countByRole,\n findUserByShortName,\n type Role,\n type UserRow,\n} from \"./serverDb.js\";\n\nexport type SetRoleDenial =\n | \"target_not_found\"\n | \"caller_lacks_authority\"\n | \"last_owner_would_be_lost\"\n | \"invalid_target_role\";\n\nexport type SetRoleResult =\n | { ok: true; old_role: Role; new_role: Role; no_change: boolean }\n | { ok: false; reason: SetRoleDenial };\n\nconst VALID_ROLES: ReadonlySet<Role> = new Set([\"owner\", \"admin\", \"member\"]);\n\n/**\n * Decide whether `caller` may change `target`'s role to `newRole`.\n * Returns null on approval, or the denial reason string.\n *\n * Authority is checked BEFORE the last-owner guard so a non-authoritative\n * caller gets a generic `caller_lacks_authority` rather than a\n * `last_owner_would_be_lost` reason that would leak \"target is the last\n * owner\" downstream. The guard still applies to authoritative callers\n * (e.g. a sole owner attempting to demote themselves).\n */\nfunction checkSetRoleAuthority(\n caller: UserRow,\n target: UserRow,\n newRole: Role,\n ownerCount: number,\n): SetRoleDenial | null {\n // Authority check first.\n let authority_ok = false;\n if (caller.role === \"owner\") {\n // Owners may set any target to any role.\n authority_ok = true;\n } else if (caller.role === \"admin\") {\n // Bootstrap: zero owners exist and admin is promoting THEMSELVES to\n // owner. This is the chicken-and-egg escape — without it, a server\n // seeded only from AUTHORIZED_KEYS (everyone admin, no owner) has\n // no path to a first owner.\n if (\n ownerCount === 0 &&\n newRole === \"owner\" &&\n target.id === caller.id\n ) {\n authority_ok = true;\n } else if (target.role === \"member\" && newRole === \"member\") {\n // Admins may manage members. Promotion to admin/owner is\n // owner-only; touching admins/owners is also owner-only.\n authority_ok = true;\n }\n }\n if (!authority_ok) return \"caller_lacks_authority\";\n\n // Last-owner guard runs after authority. By construction, only owners\n // can reach this branch with a target that is currently owner.\n if (target.role === \"owner\" && newRole !== \"owner\" && ownerCount <= 1) {\n return \"last_owner_would_be_lost\";\n }\n\n return null;\n}\n\n/**\n * Set `target_short_name`'s role to `newRole`, gated by the authority\n * matrix above. Returns a tagged result so callers can map specific\n * denial reasons to specific HTTP statuses / CLI exit codes.\n */\nexport function setUserRole(\n db: DatabaseSync,\n caller: UserRow,\n target_short_name: string,\n newRole: Role,\n): SetRoleResult {\n if (!VALID_ROLES.has(newRole)) {\n return { ok: false, reason: \"invalid_target_role\" };\n }\n const target = findUserByShortName(db, target_short_name);\n if (!target) return { ok: false, reason: \"target_not_found\" };\n\n const ownerCount = countByRole(db, \"owner\");\n const denial = checkSetRoleAuthority(caller, target, newRole, ownerCount);\n if (denial) return { ok: false, reason: denial };\n\n const old_role = target.role;\n if (old_role === newRole) {\n // No-change is still an approved outcome — the caller had authority\n // for the transition, the row simply already holds the requested\n // role. Reported separately so CLI prose can call it out without\n // pretending a change happened.\n return { ok: true, old_role, new_role: newRole, no_change: true };\n }\n\n db.prepare(`UPDATE users SET role = ? WHERE id = ?`).run(newRole, target.id);\n return { ok: true, old_role, new_role: newRole, no_change: false };\n}\n\nexport type RemoveUserDenial =\n | \"target_not_found\"\n | \"caller_lacks_authority\"\n | \"last_owner_would_be_lost\"\n | \"cannot_remove_self\";\n\nexport type RemoveUserResult =\n | { ok: true; removed: UserRow }\n | { ok: false; reason: RemoveUserDenial };\n\nexport function removeUser(\n db: DatabaseSync,\n caller: UserRow,\n target_short_name: string,\n): RemoveUserResult {\n const target = findUserByShortName(db, target_short_name);\n if (!target) return { ok: false, reason: \"target_not_found\" };\n\n // Self-removal is explicitly disallowed. Operators who want to leave\n // a server should have another admin remove their row — prevents the\n // foot-gun of an admin accidentally deleting themselves mid-session\n // and losing access to fix it.\n if (target.id === caller.id) return { ok: false, reason: \"cannot_remove_self\" };\n\n // Authority first (same reason as setUserRole): a non-authoritative\n // caller gets a generic denial rather than a leak that the target is\n // the last owner.\n if (caller.role === \"owner\") {\n // Owners may remove anyone except via the guards above/below.\n } else if (caller.role === \"admin\") {\n // Admins may remove members only.\n if (target.role !== \"member\") {\n return { ok: false, reason: \"caller_lacks_authority\" };\n }\n } else {\n return { ok: false, reason: \"caller_lacks_authority\" };\n }\n\n // Last-owner guard. With authority already enforced, only owners can\n // reach this branch with a target that is currently owner; combined\n // with the self-removal block above, the guard is structurally\n // shadowed today (an owner can never remove \"the last owner\" who is\n // someone else, because if only one owner exists, the caller IS that\n // owner and was caught above). Kept for defense in depth — a future\n // code path that bypasses cannot_remove_self would otherwise zero out\n // ownership.\n const ownerCount = countByRole(db, \"owner\");\n if (target.role === \"owner\" && ownerCount <= 1) {\n return { ok: false, reason: \"last_owner_would_be_lost\" };\n }\n\n db.prepare(`DELETE FROM users WHERE id = ?`).run(target.id);\n return { ok: true, removed: target };\n}\n\nexport type ListUsersDenial = \"caller_lacks_authority\";\n\nexport type ListUsersResult =\n | { ok: true; users: UserRow[] }\n | { ok: false; reason: ListUsersDenial };\n\n/**\n * List all users. Everyone authenticated (member, admin, owner) may\n * call this. The data exposed (short_name, role, ssh pubkey comments)\n * is the same set teammates would see in any other multi-user\n * collaboration tool; keeping the list freely readable lowers the\n * coordination cost (\"who else has access here?\") without revealing\n * anything sensitive.\n */\nexport function listUsersForCaller(\n db: DatabaseSync,\n // Argument kept for API symmetry with setUserRole/removeUser (and so\n // future role-gated read tiers like \"guest\" have an obvious place to\n // land), but every current `Role` value is permitted to list — the\n // exposed columns are coordination-data (who else has access) not\n // secrets. See doc comment above.\n _caller: UserRow,\n): ListUsersResult {\n const rows = db\n .prepare(\n `SELECT id, short_name, ssh_pubkey, ssh_fp, stamp_pubkey, role, source,\n invited_by, created_at, last_seen_at\n FROM users\n ORDER BY\n CASE role WHEN 'owner' THEN 0 WHEN 'admin' THEN 1 ELSE 2 END,\n short_name`,\n )\n .all() as unknown as UserRow[];\n return { ok: true, users: rows };\n}\n","/**\n * Parse the SSH_USER_AUTH file that sshd writes during connection setup\n * when `ExposeAuthInfo yes` is set in sshd_config. Each line is one\n * auth method that successfully authenticated the user; for pubkey auth\n * the line looks like:\n *\n * publickey ssh-ed25519 AAAA... user@host\n *\n * The pubkey blob portion is the same wire format AuthorizedKeysFile\n * lines use, so we hand it through parseSshPubkey to compute the\n * fingerprint that keys the membership DB lookup.\n *\n * This is the load-bearing identity-binding step for SSH-invoked admin\n * commands (stamp-mint-invite, future user-management wrappers): without\n * it, a connected client could claim any role at the wrapper level. With\n * it, the wrapper trusts only sshd's already-completed pubkey auth.\n */\n\nimport { readFileSync } from \"node:fs\";\nimport { parseSshPubkey, type SshPubkey } from \"./sshKeys.js\";\n\n/**\n * Read SSH_USER_AUTH from the process env and return the first publickey\n * entry's parsed pubkey. Returns null when:\n * - SSH_USER_AUTH is unset (not run under sshd with ExposeAuthInfo)\n * - the file is missing or unreadable\n * - no `publickey` line is present (auth via a non-publickey method)\n *\n * Callers should treat null as \"no authenticated identity available\" and\n * refuse to proceed with admin actions — the absence of an identity is\n * never a green-light, only an opt-out-of-the-action signal.\n */\nexport function readAuthenticatedPubkey(): SshPubkey | null {\n const path = process.env[\"SSH_USER_AUTH\"];\n if (!path) return null;\n\n let raw: string;\n try {\n raw = readFileSync(path, \"utf8\");\n } catch {\n return null;\n }\n\n for (const line of raw.split(\"\\n\")) {\n const trimmed = line.trim();\n if (!trimmed.startsWith(\"publickey \")) continue;\n // Strip the leading \"publickey \" token; the rest is a normal pubkey line.\n const pubkeyLine = trimmed.slice(\"publickey \".length).trim();\n try {\n return parseSshPubkey(pubkeyLine);\n } catch {\n // Malformed line — keep looking; sshd may emit multiple successful\n // methods if more than one is configured.\n continue;\n }\n }\n return null;\n}\n","/**\n * Parse OpenSSH-format public keys and compute their SHA256 fingerprints\n * in the exact format sshd emits via the `%f` format specifier passed to\n * AuthorizedKeysCommand. That format is `SHA256:<base64-no-padding>`,\n * distinct from the `sha256:<hex>` form used elsewhere in stamp for stamp\n * signing keys (PEM/SPKI). Both formats exist; this module is the SSH\n * side only.\n *\n * The lookup keyed on this fingerprint is the load-bearing path for\n * sshd-based authentication of users stored in the membership sqlite —\n * any drift between this fingerprint and what sshd computes breaks\n * every connection. Test coverage pins the format to a known OpenSSH\n * fixture so the next regression here is loud.\n */\n\nimport { createHash } from \"node:crypto\";\n\nexport interface SshPubkey {\n /** Key algorithm token, e.g. \"ssh-ed25519\", \"ecdsa-sha2-nistp256\". */\n algorithm: string;\n /** Base64-decoded key blob (the bytes between the algorithm token and\n * the trailing comment in a public-key line). */\n keyBlob: Buffer;\n /** Trailing comment, typically \"user@host\". May be empty. */\n comment: string;\n /** The original single-line representation, trimmed of leading/trailing\n * whitespace. Stored verbatim in the membership DB so the value sshd\n * later prints back via AuthorizedKeysCommand is bit-identical to what\n * the operator submitted. */\n full: string;\n /** OpenSSH-style fingerprint: \"SHA256:<base64-no-padding>\". Matches the\n * `%f` value sshd passes to AuthorizedKeysCommand. */\n fingerprint: string;\n}\n\nconst ALLOWED_ALGOS = new Set([\n \"ssh-ed25519\",\n \"ssh-rsa\",\n \"ecdsa-sha2-nistp256\",\n \"ecdsa-sha2-nistp384\",\n \"ecdsa-sha2-nistp521\",\n]);\n\n/**\n * Parse a single OpenSSH-format public-key line into its components.\n * Rejects empty/blank lines and lines whose algorithm token is not on the\n * conservative allowlist (above) — keeps malformed input out of the DB\n * before we ever try to hand it to sshd.\n */\nexport function parseSshPubkey(line: string): SshPubkey {\n const trimmed = line.trim();\n if (trimmed.length === 0) {\n throw new Error(\"ssh pubkey line is empty\");\n }\n if (trimmed.startsWith(\"#\")) {\n throw new Error(\"ssh pubkey line is a comment\");\n }\n\n const parts = trimmed.split(/\\s+/);\n if (parts.length < 2) {\n throw new Error(\n \"ssh pubkey line must have at least <algorithm> <base64> tokens\",\n );\n }\n\n const [algorithm, b64, ...rest] = parts as [string, string, ...string[]];\n if (!ALLOWED_ALGOS.has(algorithm)) {\n throw new Error(`unsupported ssh pubkey algorithm: ${algorithm}`);\n }\n\n // Buffer.from(string, \"base64\") does NOT throw on invalid input — it\n // silently strips non-base64 characters. So a try/catch around this\n // call is dead code; the real validation is the re-encode comparison\n // below, which catches a paste with a stray quote/character that\n // would otherwise produce a key blob mismatched against sshd's view.\n const keyBlob = Buffer.from(b64, \"base64\");\n if (keyBlob.length === 0) {\n throw new Error(\"ssh pubkey base64 blob is empty\");\n }\n if (keyBlob.toString(\"base64\").replace(/=+$/, \"\") !== b64.replace(/=+$/, \"\")) {\n throw new Error(\"ssh pubkey base64 blob has trailing junk\");\n }\n\n return {\n algorithm,\n keyBlob,\n comment: rest.join(\" \"),\n full: trimmed,\n fingerprint: sshFingerprintFromBlob(keyBlob),\n };\n}\n\n/**\n * SHA256 fingerprint of a raw key blob in OpenSSH wire format. Output is\n * `SHA256:<base64-no-padding>` — the exact form sshd emits in logs and via\n * the `%f` format specifier.\n */\nexport function sshFingerprintFromBlob(keyBlob: Buffer): string {\n const hash = createHash(\"sha256\").update(keyBlob).digest();\n const b64 = hash.toString(\"base64\").replace(/=+$/, \"\");\n return `SHA256:${b64}`;\n}\n\n/**\n * Split a multi-line authorized_keys-style blob into individual valid pubkey\n * lines, dropping blanks and `#` comments. Returns parsed pubkeys and any\n * parse failures alongside their source line numbers — callers decide\n * whether to abort or log-and-continue.\n */\nexport function parseSshPubkeyList(blob: string): {\n pubkeys: SshPubkey[];\n errors: Array<{ lineNumber: number; line: string; error: string }>;\n} {\n const pubkeys: SshPubkey[] = [];\n const errors: Array<{ lineNumber: number; line: string; error: string }> = [];\n const lines = blob.split(\"\\n\");\n for (let i = 0; i < lines.length; i++) {\n const raw = lines[i] ?? \"\";\n const stripped = raw.trim();\n if (stripped.length === 0 || stripped.startsWith(\"#\")) continue;\n try {\n pubkeys.push(parseSshPubkey(stripped));\n } catch (e) {\n errors.push({\n lineNumber: i + 1,\n line: stripped,\n error: (e as Error).message,\n });\n }\n }\n return { pubkeys, errors };\n}\n","/**\n * SSH-invoked user-management dispatcher, reachable as:\n *\n * ssh git@<host> stamp-users list\n * ssh git@<host> stamp-users promote <name> --to <admin|owner>\n * ssh git@<host> stamp-users demote <name> --to <admin|member>\n * ssh git@<host> stamp-users remove <name>\n *\n * Symlinked into /home/git/git-shell-commands/stamp-users on the server.\n * Authenticates the caller by reading SSH_USER_AUTH (requires\n * ExposeAuthInfo yes in sshd_config — already enabled by phase 2) and\n * dispatches to the authority-matrix-aware lib operations in userOps.ts.\n *\n * Exit codes (consumed by the CLI client for specific operator prose):\n *\n * 0 — success\n * 1 — server-side config error (DB unreadable, identity binding\n * failure, etc.)\n * 2 — usage error (missing/bad argv, unknown subcommand)\n * 3 — authority denial (caller's role doesn't permit the action)\n * 4 — target not found\n * 5 — last-owner-would-be-lost guard\n * 6 — cannot remove self\n *\n * stdout = machine-readable payload (a JSON object for `list`, an empty\n * body for write operations). stderr = human-readable prose using the\n * lowercase `note:` / `error:` convention that crosses the SSH boundary\n * unchanged into the operator's terminal.\n */\n\nimport {\n listUsersForCaller,\n removeUser,\n setUserRole,\n type ListUsersDenial,\n type RemoveUserDenial,\n type SetRoleDenial,\n} from \"../lib/userOps.js\";\nimport {\n findUserByShortName,\n findUserBySshFingerprint,\n openServerDb,\n type Role,\n type UserRow,\n} from \"../lib/serverDb.js\";\nimport { readAuthenticatedPubkey } from \"../lib/sshUserAuth.js\";\n\nconst EXIT = {\n OK: 0,\n CONFIG: 1,\n USAGE: 2,\n AUTHORITY: 3,\n NOT_FOUND: 4,\n LAST_OWNER: 5,\n CANNOT_REMOVE_SELF: 6,\n} as const;\n\nfunction fail(message: string, code: number): never {\n process.stderr.write(`error: ${message}\\n`);\n process.exit(code);\n}\n\nfunction usage(): never {\n process.stderr.write(\n \"usage:\\n\" +\n \" stamp-users list\\n\" +\n \" stamp-users promote <short_name> --to <admin|owner>\\n\" +\n \" stamp-users demote <short_name> --to <admin|member>\\n\" +\n \" stamp-users remove <short_name>\\n\" +\n \" stamp-users get-stamp-pubkey <short_name>\\n\",\n );\n process.exit(EXIT.USAGE);\n}\n\ninterface ParsedSetRole {\n subcommand: \"promote\" | \"demote\";\n short_name: string;\n to: Role;\n}\n\ninterface ParsedRemove {\n subcommand: \"remove\";\n short_name: string;\n}\n\ninterface ParsedList {\n subcommand: \"list\";\n}\n\ninterface ParsedGetStampPubkey {\n subcommand: \"get-stamp-pubkey\";\n short_name: string;\n}\n\ntype Parsed =\n | ParsedSetRole\n | ParsedRemove\n | ParsedList\n | ParsedGetStampPubkey;\n\nconst VALID_PROMOTE_TARGETS: ReadonlySet<Role> = new Set([\"admin\", \"owner\"]);\nconst VALID_DEMOTE_TARGETS: ReadonlySet<Role> = new Set([\"admin\", \"member\"]);\n\nfunction parseArgs(argv: string[]): Parsed {\n if (argv.length === 0) usage();\n const [sub, ...rest] = argv as [string, ...string[]];\n if (sub === \"list\") {\n if (rest.length > 0) fail(`'list' takes no arguments (got ${rest.length})`, EXIT.USAGE);\n return { subcommand: \"list\" };\n }\n if (sub === \"promote\" || sub === \"demote\") {\n let short_name = \"\";\n let to: Role | \"\" = \"\";\n for (let i = 0; i < rest.length; i++) {\n const arg = rest[i]!;\n if (arg === \"--to\") {\n const next = rest[i + 1];\n if (!next) fail(`'--to' requires a value`, EXIT.USAGE);\n if (next !== \"admin\" && next !== \"member\" && next !== \"owner\") {\n fail(`--to must be 'admin', 'member', or 'owner' (got ${JSON.stringify(next)})`, EXIT.USAGE);\n }\n to = next;\n i++;\n } else if (arg.startsWith(\"--\")) {\n fail(`unknown flag: ${arg}`, EXIT.USAGE);\n } else if (!short_name) {\n short_name = arg;\n } else {\n fail(`unexpected positional argument: ${arg}`, EXIT.USAGE);\n }\n }\n if (!short_name) fail(`missing <short_name>`, EXIT.USAGE);\n if (!to) fail(`'${sub}' requires --to <role>`, EXIT.USAGE);\n if (sub === \"promote\" && !VALID_PROMOTE_TARGETS.has(to)) {\n fail(`promote --to must be 'admin' or 'owner' (got '${to}')`, EXIT.USAGE);\n }\n if (sub === \"demote\" && !VALID_DEMOTE_TARGETS.has(to)) {\n fail(`demote --to must be 'admin' or 'member' (got '${to}')`, EXIT.USAGE);\n }\n return { subcommand: sub, short_name, to };\n }\n if (sub === \"remove\") {\n if (rest.length === 0) fail(`missing <short_name>`, EXIT.USAGE);\n if (rest.length > 1) fail(`unexpected positional argument: ${rest[1]}`, EXIT.USAGE);\n return { subcommand: \"remove\", short_name: rest[0]! };\n }\n if (sub === \"get-stamp-pubkey\") {\n if (rest.length === 0) fail(`missing <short_name>`, EXIT.USAGE);\n if (rest.length > 1) fail(`unexpected positional argument: ${rest[1]}`, EXIT.USAGE);\n return { subcommand: \"get-stamp-pubkey\", short_name: rest[0]! };\n }\n fail(`unknown subcommand: ${sub}`, EXIT.USAGE);\n}\n\nfunction exitFromSetRoleDenial(reason: SetRoleDenial): number {\n switch (reason) {\n case \"target_not_found\":\n return EXIT.NOT_FOUND;\n case \"caller_lacks_authority\":\n return EXIT.AUTHORITY;\n case \"last_owner_would_be_lost\":\n return EXIT.LAST_OWNER;\n case \"invalid_target_role\":\n return EXIT.USAGE;\n }\n}\n\nfunction exitFromRemoveDenial(reason: RemoveUserDenial): number {\n switch (reason) {\n case \"target_not_found\":\n return EXIT.NOT_FOUND;\n case \"caller_lacks_authority\":\n return EXIT.AUTHORITY;\n case \"last_owner_would_be_lost\":\n return EXIT.LAST_OWNER;\n case \"cannot_remove_self\":\n return EXIT.CANNOT_REMOVE_SELF;\n }\n}\n\nfunction exitFromListDenial(_reason: ListUsersDenial): number {\n return EXIT.AUTHORITY;\n}\n\nfunction resolveCaller(): UserRow {\n const pubkey = readAuthenticatedPubkey();\n if (!pubkey) {\n fail(\n \"could not determine authenticated identity (SSH_USER_AUTH unset or has no publickey entry). \" +\n \"Server may be missing 'ExposeAuthInfo yes' in sshd_config.\",\n EXIT.CONFIG,\n );\n }\n // skipChmod: this wrapper runs as the git user, root-owned file; entrypoint.sh\n // handles boot-time perm tightening. See src/lib/serverDb.ts comment.\n const db = openServerDb({ skipChmod: true });\n try {\n const caller = findUserBySshFingerprint(db, pubkey.fingerprint);\n if (!caller) {\n fail(\n `caller fingerprint ${pubkey.fingerprint} is not in the membership DB. ` +\n `Likely cause: phase-1 env-var sync hasn't run on this server yet.`,\n EXIT.CONFIG,\n );\n }\n return caller;\n } finally {\n db.close();\n }\n}\n\nfunction runList(): void {\n const caller = resolveCaller();\n const db = openServerDb({ skipChmod: true });\n try {\n const result = listUsersForCaller(db, caller);\n if (!result.ok) {\n fail(`listing users failed: ${result.reason}`, exitFromListDenial(result.reason));\n }\n // JSON output for the CLI to format. Includes ssh_pubkey for\n // human-readable comment but excludes any future secret-bearing\n // fields (last_seen_at is operational metadata, included).\n const payload = result.users.map((u) => ({\n id: u.id,\n short_name: u.short_name,\n role: u.role,\n source: u.source,\n ssh_fp: u.ssh_fp,\n has_stamp_pubkey: u.stamp_pubkey !== null,\n invited_by: u.invited_by,\n created_at: u.created_at,\n last_seen_at: u.last_seen_at,\n }));\n process.stdout.write(JSON.stringify({ users: payload }) + \"\\n\");\n } finally {\n db.close();\n }\n}\n\nfunction runSetRole(parsed: ParsedSetRole): void {\n const caller = resolveCaller();\n const db = openServerDb({ skipChmod: true });\n try {\n const result = setUserRole(db, caller, parsed.short_name, parsed.to);\n if (!result.ok) {\n fail(\n `${parsed.subcommand} ${parsed.short_name} --to ${parsed.to}: ${result.reason}`,\n exitFromSetRoleDenial(result.reason),\n );\n }\n if (result.no_change) {\n process.stderr.write(\n `note: ${parsed.short_name} was already ${result.new_role} (no change)\\n`,\n );\n } else {\n process.stderr.write(\n `note: ${parsed.short_name} ${result.old_role} → ${result.new_role}\\n`,\n );\n }\n } finally {\n db.close();\n }\n}\n\nfunction runRemove(parsed: ParsedRemove): void {\n const caller = resolveCaller();\n const db = openServerDb({ skipChmod: true });\n try {\n const result = removeUser(db, caller, parsed.short_name);\n if (!result.ok) {\n fail(\n `remove ${parsed.short_name}: ${result.reason}`,\n exitFromRemoveDenial(result.reason),\n );\n }\n process.stderr.write(\n `note: removed ${result.removed.short_name} (was ${result.removed.role})\\n`,\n );\n } finally {\n db.close();\n }\n}\n\nfunction runGetStampPubkey(parsed: ParsedGetStampPubkey): void {\n // Identity binding still required (so this surface stays consistent\n // with the rest of stamp-users — only authenticated users can read\n // the membership DB) but no role check beyond \"you're enrolled\".\n // The phase-4 trust-grant flow goes through the standard stamp gate\n // anyway, so an enrolled member who fetches a peer's stamp_pubkey\n // can't unilaterally widen anyone's trust.\n resolveCaller();\n const db = openServerDb({ skipChmod: true });\n try {\n const target = findUserByShortName(db, parsed.short_name);\n if (!target) {\n fail(`user ${JSON.stringify(parsed.short_name)} not found`, EXIT.NOT_FOUND);\n }\n if (target.stamp_pubkey === null) {\n fail(\n `user ${JSON.stringify(parsed.short_name)} has no stamp signing pubkey on file ` +\n `— ask them to re-enroll via stamp invites accept with --stamp-pubkey`,\n EXIT.NOT_FOUND,\n );\n }\n // PEM goes to stdout exactly as stored. The receiving CLI pipes\n // this verbatim into the repo's .stamp/trusted-keys/<name>.pub\n // file, so any drift here is observable in the next diff review.\n process.stdout.write(target.stamp_pubkey);\n if (!target.stamp_pubkey.endsWith(\"\\n\")) {\n process.stdout.write(\"\\n\");\n }\n } finally {\n db.close();\n }\n}\n\nfunction main(): void {\n const parsed = parseArgs(process.argv.slice(2));\n switch (parsed.subcommand) {\n case \"list\":\n runList();\n break;\n case \"promote\":\n case \"demote\":\n runSetRole(parsed);\n break;\n case \"remove\":\n runRemove(parsed);\n break;\n case \"get-stamp-pubkey\":\n runGetStampPubkey(parsed);\n break;\n }\n}\n\nmain();\n"],"mappings":";;;;AAsBA,IAAAA,kBAAsC;AACtC,yBAA6B;AAC7B,IAAAC,oBAAwB;;;ACxBxB,qBAA8D;AAC9D,qBAAwB;AACxB,uBAAmD;AA2G5C,SAAS,UAAU,MAAc,OAAO,KAAa;AAC1D,MAAI,KAAC,2BAAW,IAAI,GAAG;AACrB,kCAAU,MAAM,EAAE,WAAW,MAAM,KAAK,CAAC;AAAA,EAC3C;AACF;;;AD1DO,IAAM,yBAAyB;AAS/B,SAAS,oBAAoB,UAA2B;AAC7D,MAAI,SAAU,QAAO;AACrB,QAAM,UAAU,QAAQ,IAAI,sBAAsB;AAClD,MAAI,WAAW,QAAQ,SAAS,EAAG,QAAO;AAC1C,SAAO;AACT;AAcO,SAAS,aAAa,OAAyB,CAAC,GAAiB;AACtE,QAAM,OAAO,oBAAoB,KAAK,IAAI;AAC1C,QAAM,WAAW,KAAK,YAAY;AAElC,MAAI,CAAC,UAAU;AACb,UAAM,UAAM,2BAAQ,IAAI;AACxB,cAAU,KAAK,GAAK;AACpB,QAAI,CAAC,KAAK,WAAW;AAInB,qCAAU,KAAK,GAAK;AAAA,IACtB;AAAA,EACF;AAEA,QAAM,KAAK,IAAI,gCAAa,MAAM,EAAE,SAAS,CAAC;AAE9C,MAAI,CAAC,UAAU;AACb,OAAG,KAAK,0BAA0B;AAClC,eAAW,EAAE;AACb,QAAI,CAAC,KAAK,iBAAa,4BAAW,IAAI,GAAG;AAWvC,qCAAU,MAAM,GAAK;AAAA,IACvB;AAAA,EACF;AAEA,SAAO;AACT;AAEA,SAAS,WAAW,IAAwB;AAC1C,KAAG,KAAK;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,GA2BP;AACH;AAoDO,SAAS,yBACd,IACA,QACgB;AAChB,QAAM,OAAO,GAAG;AAAA,IACd;AAAA;AAAA;AAAA,EAGF;AACA,QAAM,MAAM,KAAK,IAAI,MAAM;AAC3B,SAAO,OAAO;AAChB;AAEO,SAAS,oBACd,IACA,YACgB;AAChB,QAAM,OAAO,GAAG;AAAA,IACd;AAAA;AAAA;AAAA,EAGF;AACA,QAAM,MAAM,KAAK,IAAI,UAAU;AAC/B,SAAO,OAAO;AAChB;AAYO,SAAS,YAAY,IAAkB,MAAoB;AAChE,QAAM,OAAO,GAAG,QAAQ,gDAAgD;AACxE,QAAM,MAAM,KAAK,IAAI,IAAI;AACzB,SAAO,IAAI;AACb;;;AEtMA,IAAM,cAAiC,oBAAI,IAAI,CAAC,SAAS,SAAS,QAAQ,CAAC;AAY3E,SAAS,sBACP,QACA,QACA,SACA,YACsB;AAEtB,MAAI,eAAe;AACnB,MAAI,OAAO,SAAS,SAAS;AAE3B,mBAAe;AAAA,EACjB,WAAW,OAAO,SAAS,SAAS;AAKlC,QACE,eAAe,KACf,YAAY,WACZ,OAAO,OAAO,OAAO,IACrB;AACA,qBAAe;AAAA,IACjB,WAAW,OAAO,SAAS,YAAY,YAAY,UAAU;AAG3D,qBAAe;AAAA,IACjB;AAAA,EACF;AACA,MAAI,CAAC,aAAc,QAAO;AAI1B,MAAI,OAAO,SAAS,WAAW,YAAY,WAAW,cAAc,GAAG;AACrE,WAAO;AAAA,EACT;AAEA,SAAO;AACT;AAOO,SAAS,YACd,IACA,QACA,mBACA,SACe;AACf,MAAI,CAAC,YAAY,IAAI,OAAO,GAAG;AAC7B,WAAO,EAAE,IAAI,OAAO,QAAQ,sBAAsB;AAAA,EACpD;AACA,QAAM,SAAS,oBAAoB,IAAI,iBAAiB;AACxD,MAAI,CAAC,OAAQ,QAAO,EAAE,IAAI,OAAO,QAAQ,mBAAmB;AAE5D,QAAM,aAAa,YAAY,IAAI,OAAO;AAC1C,QAAM,SAAS,sBAAsB,QAAQ,QAAQ,SAAS,UAAU;AACxE,MAAI,OAAQ,QAAO,EAAE,IAAI,OAAO,QAAQ,OAAO;AAE/C,QAAM,WAAW,OAAO;AACxB,MAAI,aAAa,SAAS;AAKxB,WAAO,EAAE,IAAI,MAAM,UAAU,UAAU,SAAS,WAAW,KAAK;AAAA,EAClE;AAEA,KAAG,QAAQ,wCAAwC,EAAE,IAAI,SAAS,OAAO,EAAE;AAC3E,SAAO,EAAE,IAAI,MAAM,UAAU,UAAU,SAAS,WAAW,MAAM;AACnE;AAYO,SAAS,WACd,IACA,QACA,mBACkB;AAClB,QAAM,SAAS,oBAAoB,IAAI,iBAAiB;AACxD,MAAI,CAAC,OAAQ,QAAO,EAAE,IAAI,OAAO,QAAQ,mBAAmB;AAM5D,MAAI,OAAO,OAAO,OAAO,GAAI,QAAO,EAAE,IAAI,OAAO,QAAQ,qBAAqB;AAK9E,MAAI,OAAO,SAAS,SAAS;AAAA,EAE7B,WAAW,OAAO,SAAS,SAAS;AAElC,QAAI,OAAO,SAAS,UAAU;AAC5B,aAAO,EAAE,IAAI,OAAO,QAAQ,yBAAyB;AAAA,IACvD;AAAA,EACF,OAAO;AACL,WAAO,EAAE,IAAI,OAAO,QAAQ,yBAAyB;AAAA,EACvD;AAUA,QAAM,aAAa,YAAY,IAAI,OAAO;AAC1C,MAAI,OAAO,SAAS,WAAW,cAAc,GAAG;AAC9C,WAAO,EAAE,IAAI,OAAO,QAAQ,2BAA2B;AAAA,EACzD;AAEA,KAAG,QAAQ,gCAAgC,EAAE,IAAI,OAAO,EAAE;AAC1D,SAAO,EAAE,IAAI,MAAM,SAAS,OAAO;AACrC;AAgBO,SAAS,mBACd,IAMA,SACiB;AACjB,QAAM,OAAO,GACV;AAAA,IACC;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAMF,EACC,IAAI;AACP,SAAO,EAAE,IAAI,MAAM,OAAO,KAAK;AACjC;;;ACxMA,IAAAC,kBAA6B;;;ACH7B,yBAA2B;AAoB3B,IAAM,gBAAgB,oBAAI,IAAI;AAAA,EAC5B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,CAAC;AAQM,SAAS,eAAe,MAAyB;AACtD,QAAM,UAAU,KAAK,KAAK;AAC1B,MAAI,QAAQ,WAAW,GAAG;AACxB,UAAM,IAAI,MAAM,0BAA0B;AAAA,EAC5C;AACA,MAAI,QAAQ,WAAW,GAAG,GAAG;AAC3B,UAAM,IAAI,MAAM,8BAA8B;AAAA,EAChD;AAEA,QAAM,QAAQ,QAAQ,MAAM,KAAK;AACjC,MAAI,MAAM,SAAS,GAAG;AACpB,UAAM,IAAI;AAAA,MACR;AAAA,IACF;AAAA,EACF;AAEA,QAAM,CAAC,WAAW,KAAK,GAAG,IAAI,IAAI;AAClC,MAAI,CAAC,cAAc,IAAI,SAAS,GAAG;AACjC,UAAM,IAAI,MAAM,qCAAqC,SAAS,EAAE;AAAA,EAClE;AAOA,QAAM,UAAU,OAAO,KAAK,KAAK,QAAQ;AACzC,MAAI,QAAQ,WAAW,GAAG;AACxB,UAAM,IAAI,MAAM,iCAAiC;AAAA,EACnD;AACA,MAAI,QAAQ,SAAS,QAAQ,EAAE,QAAQ,OAAO,EAAE,MAAM,IAAI,QAAQ,OAAO,EAAE,GAAG;AAC5E,UAAM,IAAI,MAAM,0CAA0C;AAAA,EAC5D;AAEA,SAAO;AAAA,IACL;AAAA,IACA;AAAA,IACA,SAAS,KAAK,KAAK,GAAG;AAAA,IACtB,MAAM;AAAA,IACN,aAAa,uBAAuB,OAAO;AAAA,EAC7C;AACF;AAOO,SAAS,uBAAuB,SAAyB;AAC9D,QAAM,WAAO,+BAAW,QAAQ,EAAE,OAAO,OAAO,EAAE,OAAO;AACzD,QAAM,MAAM,KAAK,SAAS,QAAQ,EAAE,QAAQ,OAAO,EAAE;AACrD,SAAO,UAAU,GAAG;AACtB;;;ADrEO,SAAS,0BAA4C;AAC1D,QAAM,OAAO,QAAQ,IAAI,eAAe;AACxC,MAAI,CAAC,KAAM,QAAO;AAElB,MAAI;AACJ,MAAI;AACF,cAAM,8BAAa,MAAM,MAAM;AAAA,EACjC,QAAQ;AACN,WAAO;AAAA,EACT;AAEA,aAAW,QAAQ,IAAI,MAAM,IAAI,GAAG;AAClC,UAAM,UAAU,KAAK,KAAK;AAC1B,QAAI,CAAC,QAAQ,WAAW,YAAY,EAAG;AAEvC,UAAM,aAAa,QAAQ,MAAM,aAAa,MAAM,EAAE,KAAK;AAC3D,QAAI;AACF,aAAO,eAAe,UAAU;AAAA,IAClC,QAAQ;AAGN;AAAA,IACF;AAAA,EACF;AACA,SAAO;AACT;;;AEVA,IAAM,OAAO;AAAA,EACX,IAAI;AAAA,EACJ,QAAQ;AAAA,EACR,OAAO;AAAA,EACP,WAAW;AAAA,EACX,WAAW;AAAA,EACX,YAAY;AAAA,EACZ,oBAAoB;AACtB;AAEA,SAAS,KAAK,SAAiB,MAAqB;AAClD,UAAQ,OAAO,MAAM,UAAU,OAAO;AAAA,CAAI;AAC1C,UAAQ,KAAK,IAAI;AACnB;AAEA,SAAS,QAAe;AACtB,UAAQ,OAAO;AAAA,IACb;AAAA,EAMF;AACA,UAAQ,KAAK,KAAK,KAAK;AACzB;AA4BA,IAAM,wBAA2C,oBAAI,IAAI,CAAC,SAAS,OAAO,CAAC;AAC3E,IAAM,uBAA0C,oBAAI,IAAI,CAAC,SAAS,QAAQ,CAAC;AAE3E,SAAS,UAAU,MAAwB;AACzC,MAAI,KAAK,WAAW,EAAG,OAAM;AAC7B,QAAM,CAAC,KAAK,GAAG,IAAI,IAAI;AACvB,MAAI,QAAQ,QAAQ;AAClB,QAAI,KAAK,SAAS,EAAG,MAAK,kCAAkC,KAAK,MAAM,KAAK,KAAK,KAAK;AACtF,WAAO,EAAE,YAAY,OAAO;AAAA,EAC9B;AACA,MAAI,QAAQ,aAAa,QAAQ,UAAU;AACzC,QAAI,aAAa;AACjB,QAAI,KAAgB;AACpB,aAAS,IAAI,GAAG,IAAI,KAAK,QAAQ,KAAK;AACpC,YAAM,MAAM,KAAK,CAAC;AAClB,UAAI,QAAQ,QAAQ;AAClB,cAAM,OAAO,KAAK,IAAI,CAAC;AACvB,YAAI,CAAC,KAAM,MAAK,2BAA2B,KAAK,KAAK;AACrD,YAAI,SAAS,WAAW,SAAS,YAAY,SAAS,SAAS;AAC7D,eAAK,mDAAmD,KAAK,UAAU,IAAI,CAAC,KAAK,KAAK,KAAK;AAAA,QAC7F;AACA,aAAK;AACL;AAAA,MACF,WAAW,IAAI,WAAW,IAAI,GAAG;AAC/B,aAAK,iBAAiB,GAAG,IAAI,KAAK,KAAK;AAAA,MACzC,WAAW,CAAC,YAAY;AACtB,qBAAa;AAAA,MACf,OAAO;AACL,aAAK,mCAAmC,GAAG,IAAI,KAAK,KAAK;AAAA,MAC3D;AAAA,IACF;AACA,QAAI,CAAC,WAAY,MAAK,wBAAwB,KAAK,KAAK;AACxD,QAAI,CAAC,GAAI,MAAK,IAAI,GAAG,0BAA0B,KAAK,KAAK;AACzD,QAAI,QAAQ,aAAa,CAAC,sBAAsB,IAAI,EAAE,GAAG;AACvD,WAAK,iDAAiD,EAAE,MAAM,KAAK,KAAK;AAAA,IAC1E;AACA,QAAI,QAAQ,YAAY,CAAC,qBAAqB,IAAI,EAAE,GAAG;AACrD,WAAK,iDAAiD,EAAE,MAAM,KAAK,KAAK;AAAA,IAC1E;AACA,WAAO,EAAE,YAAY,KAAK,YAAY,GAAG;AAAA,EAC3C;AACA,MAAI,QAAQ,UAAU;AACpB,QAAI,KAAK,WAAW,EAAG,MAAK,wBAAwB,KAAK,KAAK;AAC9D,QAAI,KAAK,SAAS,EAAG,MAAK,mCAAmC,KAAK,CAAC,CAAC,IAAI,KAAK,KAAK;AAClF,WAAO,EAAE,YAAY,UAAU,YAAY,KAAK,CAAC,EAAG;AAAA,EACtD;AACA,MAAI,QAAQ,oBAAoB;AAC9B,QAAI,KAAK,WAAW,EAAG,MAAK,wBAAwB,KAAK,KAAK;AAC9D,QAAI,KAAK,SAAS,EAAG,MAAK,mCAAmC,KAAK,CAAC,CAAC,IAAI,KAAK,KAAK;AAClF,WAAO,EAAE,YAAY,oBAAoB,YAAY,KAAK,CAAC,EAAG;AAAA,EAChE;AACA,OAAK,uBAAuB,GAAG,IAAI,KAAK,KAAK;AAC/C;AAEA,SAAS,sBAAsB,QAA+B;AAC5D,UAAQ,QAAQ;AAAA,IACd,KAAK;AACH,aAAO,KAAK;AAAA,IACd,KAAK;AACH,aAAO,KAAK;AAAA,IACd,KAAK;AACH,aAAO,KAAK;AAAA,IACd,KAAK;AACH,aAAO,KAAK;AAAA,EAChB;AACF;AAEA,SAAS,qBAAqB,QAAkC;AAC9D,UAAQ,QAAQ;AAAA,IACd,KAAK;AACH,aAAO,KAAK;AAAA,IACd,KAAK;AACH,aAAO,KAAK;AAAA,IACd,KAAK;AACH,aAAO,KAAK;AAAA,IACd,KAAK;AACH,aAAO,KAAK;AAAA,EAChB;AACF;AAEA,SAAS,mBAAmB,SAAkC;AAC5D,SAAO,KAAK;AACd;AAEA,SAAS,gBAAyB;AAChC,QAAM,SAAS,wBAAwB;AACvC,MAAI,CAAC,QAAQ;AACX;AAAA,MACE;AAAA,MAEA,KAAK;AAAA,IACP;AAAA,EACF;AAGA,QAAM,KAAK,aAAa,EAAE,WAAW,KAAK,CAAC;AAC3C,MAAI;AACF,UAAM,SAAS,yBAAyB,IAAI,OAAO,WAAW;AAC9D,QAAI,CAAC,QAAQ;AACX;AAAA,QACE,sBAAsB,OAAO,WAAW;AAAA,QAExC,KAAK;AAAA,MACP;AAAA,IACF;AACA,WAAO;AAAA,EACT,UAAE;AACA,OAAG,MAAM;AAAA,EACX;AACF;AAEA,SAAS,UAAgB;AACvB,QAAM,SAAS,cAAc;AAC7B,QAAM,KAAK,aAAa,EAAE,WAAW,KAAK,CAAC;AAC3C,MAAI;AACF,UAAM,SAAS,mBAAmB,IAAI,MAAM;AAC5C,QAAI,CAAC,OAAO,IAAI;AACd,WAAK,yBAAyB,OAAO,MAAM,IAAI,mBAAmB,OAAO,MAAM,CAAC;AAAA,IAClF;AAIA,UAAM,UAAU,OAAO,MAAM,IAAI,CAAC,OAAO;AAAA,MACvC,IAAI,EAAE;AAAA,MACN,YAAY,EAAE;AAAA,MACd,MAAM,EAAE;AAAA,MACR,QAAQ,EAAE;AAAA,MACV,QAAQ,EAAE;AAAA,MACV,kBAAkB,EAAE,iBAAiB;AAAA,MACrC,YAAY,EAAE;AAAA,MACd,YAAY,EAAE;AAAA,MACd,cAAc,EAAE;AAAA,IAClB,EAAE;AACF,YAAQ,OAAO,MAAM,KAAK,UAAU,EAAE,OAAO,QAAQ,CAAC,IAAI,IAAI;AAAA,EAChE,UAAE;AACA,OAAG,MAAM;AAAA,EACX;AACF;AAEA,SAAS,WAAW,QAA6B;AAC/C,QAAM,SAAS,cAAc;AAC7B,QAAM,KAAK,aAAa,EAAE,WAAW,KAAK,CAAC;AAC3C,MAAI;AACF,UAAM,SAAS,YAAY,IAAI,QAAQ,OAAO,YAAY,OAAO,EAAE;AACnE,QAAI,CAAC,OAAO,IAAI;AACd;AAAA,QACE,GAAG,OAAO,UAAU,IAAI,OAAO,UAAU,SAAS,OAAO,EAAE,KAAK,OAAO,MAAM;AAAA,QAC7E,sBAAsB,OAAO,MAAM;AAAA,MACrC;AAAA,IACF;AACA,QAAI,OAAO,WAAW;AACpB,cAAQ,OAAO;AAAA,QACb,SAAS,OAAO,UAAU,gBAAgB,OAAO,QAAQ;AAAA;AAAA,MAC3D;AAAA,IACF,OAAO;AACL,cAAQ,OAAO;AAAA,QACb,SAAS,OAAO,UAAU,IAAI,OAAO,QAAQ,WAAM,OAAO,QAAQ;AAAA;AAAA,MACpE;AAAA,IACF;AAAA,EACF,UAAE;AACA,OAAG,MAAM;AAAA,EACX;AACF;AAEA,SAAS,UAAU,QAA4B;AAC7C,QAAM,SAAS,cAAc;AAC7B,QAAM,KAAK,aAAa,EAAE,WAAW,KAAK,CAAC;AAC3C,MAAI;AACF,UAAM,SAAS,WAAW,IAAI,QAAQ,OAAO,UAAU;AACvD,QAAI,CAAC,OAAO,IAAI;AACd;AAAA,QACE,UAAU,OAAO,UAAU,KAAK,OAAO,MAAM;AAAA,QAC7C,qBAAqB,OAAO,MAAM;AAAA,MACpC;AAAA,IACF;AACA,YAAQ,OAAO;AAAA,MACb,iBAAiB,OAAO,QAAQ,UAAU,SAAS,OAAO,QAAQ,IAAI;AAAA;AAAA,IACxE;AAAA,EACF,UAAE;AACA,OAAG,MAAM;AAAA,EACX;AACF;AAEA,SAAS,kBAAkB,QAAoC;AAO7D,gBAAc;AACd,QAAM,KAAK,aAAa,EAAE,WAAW,KAAK,CAAC;AAC3C,MAAI;AACF,UAAM,SAAS,oBAAoB,IAAI,OAAO,UAAU;AACxD,QAAI,CAAC,QAAQ;AACX,WAAK,QAAQ,KAAK,UAAU,OAAO,UAAU,CAAC,cAAc,KAAK,SAAS;AAAA,IAC5E;AACA,QAAI,OAAO,iBAAiB,MAAM;AAChC;AAAA,QACE,QAAQ,KAAK,UAAU,OAAO,UAAU,CAAC;AAAA,QAEzC,KAAK;AAAA,MACP;AAAA,IACF;AAIA,YAAQ,OAAO,MAAM,OAAO,YAAY;AACxC,QAAI,CAAC,OAAO,aAAa,SAAS,IAAI,GAAG;AACvC,cAAQ,OAAO,MAAM,IAAI;AAAA,IAC3B;AAAA,EACF,UAAE;AACA,OAAG,MAAM;AAAA,EACX;AACF;AAEA,SAAS,OAAa;AACpB,QAAM,SAAS,UAAU,QAAQ,KAAK,MAAM,CAAC,CAAC;AAC9C,UAAQ,OAAO,YAAY;AAAA,IACzB,KAAK;AACH,cAAQ;AACR;AAAA,IACF,KAAK;AAAA,IACL,KAAK;AACH,iBAAW,MAAM;AACjB;AAAA,IACF,KAAK;AACH,gBAAU,MAAM;AAChB;AAAA,IACF,KAAK;AACH,wBAAkB,MAAM;AACxB;AAAA,EACJ;AACF;AAEA,KAAK;","names":["import_node_fs","import_node_path","import_node_fs"]}
|
|
1
|
+
{"version":3,"sources":["../../src/lib/serverDb.ts","../../src/lib/paths.ts","../../src/lib/userOps.ts","../../src/lib/sshUserAuth.ts","../../src/lib/sshKeys.ts","../../src/server/users-cli.ts"],"sourcesContent":["/**\n * Membership sqlite for the stamp server.\n *\n * Lives on the persistent volume at /srv/git/.stamp-state/users.db. Holds:\n * - users: SSH pubkey → role (owner/admin/member), optional stamp signing\n * pubkey, source provenance (env / bootstrap / invite / manual)\n * - invites: single-use, time-bounded tokens an admin mints to onboard\n * a teammate (phase 2)\n *\n * Two access modes:\n * - Writable (boot-time seed, admin operations): opens the DB read/write,\n * ensures schema, tightens perms on the file and parent dir.\n * - Read-only (sshd's AuthorizedKeysCommand): opens with readOnly:true so\n * the resolver process holds no write fd; lets us run the resolver as\n * an unprivileged user against a root:git 0640 DB without enabling\n * WAL-mode sidecars.\n *\n * Roles and invite roles are CHECK-constrained in the schema so a future\n * code-level bug introducing a typo'd role string fails at insert rather\n * than silently corrupting authorization data.\n */\n\nimport { chmodSync, existsSync } from \"node:fs\";\nimport { DatabaseSync } from \"node:sqlite\";\nimport { dirname } from \"node:path\";\nimport { ensureDir } from \"./paths.js\";\n\nexport type Role = \"owner\" | \"admin\" | \"member\";\nexport type UserSource = \"env\" | \"bootstrap\" | \"invite\" | \"manual\";\nexport type InviteRole = \"admin\" | \"member\";\n\nexport interface UserRow {\n id: number;\n short_name: string;\n ssh_pubkey: string;\n ssh_fp: string;\n stamp_pubkey: string | null;\n role: Role;\n source: UserSource;\n invited_by: number | null;\n created_at: number;\n last_seen_at: number | null;\n}\n\nexport interface InviteRow {\n token: string;\n role: InviteRole;\n invited_by: number;\n created_at: number;\n expires_at: number;\n consumed_at: number | null;\n consumed_by: number | null;\n}\n\n/** Default on-server path. Tests pass an explicit `path` instead. */\nexport const DEFAULT_SERVER_DB_PATH = \"/srv/git/.stamp-state/users.db\";\n\n/**\n * Resolve the effective DB path. Precedence:\n * 1. Explicit `opts.path` (tests, future config)\n * 2. STAMP_SERVER_DB_PATH env var (CLI-spawning tests; also a relief\n * valve for operators who want to relocate the DB on the volume)\n * 3. DEFAULT_SERVER_DB_PATH (production)\n */\nexport function resolveServerDbPath(explicit?: string): string {\n if (explicit) return explicit;\n const envPath = process.env[\"STAMP_SERVER_DB_PATH\"];\n if (envPath && envPath.length > 0) return envPath;\n return DEFAULT_SERVER_DB_PATH;\n}\n\nexport interface OpenServerDbOpts {\n /** Override the on-disk location. Required for tests. */\n path?: string;\n /** Open read-only. Skips schema init, skips chmod, and constructs the\n * DatabaseSync with readOnly:true. Used by the AuthorizedKeysCommand\n * resolver. */\n readOnly?: boolean;\n /** Skip filesystem-perm tightening of the DB file + parent dir. The\n * on-server boot path wants tightening; tests on tmpfs do not. */\n skipChmod?: boolean;\n}\n\nexport function openServerDb(opts: OpenServerDbOpts = {}): DatabaseSync {\n const path = resolveServerDbPath(opts.path);\n const readOnly = opts.readOnly ?? false;\n\n if (!readOnly) {\n const dir = dirname(path);\n ensureDir(dir, 0o1770);\n if (!opts.skipChmod) {\n // 0o1770 = sticky bit (1) + rwx for owner + rwx for group + nothing\n // for other. Matches the chmod entrypoint.sh sets, intentionally:\n // - The 0o770 portion is required for sqlite to write its\n // `-journal` sidecar in this dir on every transaction (the\n // git user that runs the HTTP server and SSH wrappers needs\n // CREATE access). At 0o750 sqlite silently demotes the\n // connection to read-only and every UPDATE throws \"attempt\n // to write a readonly database\".\n // - The sticky bit prevents the git-group from renaming or\n // deleting files in this dir that ARE NOT owned by git —\n // so any future root-owned state file landing here is\n // protected from a git-shell-escapee even though the dir\n // is otherwise group-writable.\n // We must mirror the sticky bit here (not just rely on\n // entrypoint.sh) because seed-users.ts opens the DB writable\n // (no skipChmod) as root at boot, and a chmod 0o770 here\n // would silently STRIP the sticky bit entrypoint just set.\n // ensureDir no-ops on an existing directory, so this explicit\n // chmod is what re-applies sticky+770 on a redeploy where the\n // dir was created at an earlier looser mode.\n chmodSync(dir, 0o1770);\n }\n }\n\n const db = new DatabaseSync(path, { readOnly });\n\n if (!readOnly) {\n db.exec(\"PRAGMA foreign_keys = ON\");\n initSchema(db);\n if (!opts.skipChmod && existsSync(path)) {\n // root:git 0660. The HTTP server (git user) writes new user rows\n // on invite-accept; the AuthorizedKeysCommand resolver also runs\n // as git but opens readOnly:true so the write bit is dormant on\n // its path. Chown is the operator's responsibility (entrypoint.sh\n // sets root:git after each boot); we only set the mode bits.\n //\n // Callers running as the git user (mint-invite, http-server) must\n // pass skipChmod:true — only the file owner can chmod on Linux,\n // and entrypoint.sh has already tightened perms by the time those\n // callers run.\n chmodSync(path, 0o660);\n }\n }\n\n return db;\n}\n\nfunction initSchema(db: DatabaseSync): void {\n db.exec(`\n CREATE TABLE IF NOT EXISTS users (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n short_name TEXT NOT NULL UNIQUE,\n ssh_pubkey TEXT NOT NULL,\n ssh_fp TEXT NOT NULL UNIQUE,\n stamp_pubkey TEXT,\n role TEXT NOT NULL CHECK (role IN ('owner','admin','member')),\n source TEXT NOT NULL DEFAULT 'invite' CHECK (source IN ('env','bootstrap','invite','manual')),\n invited_by INTEGER REFERENCES users(id),\n created_at INTEGER NOT NULL,\n last_seen_at INTEGER\n );\n\n CREATE INDEX IF NOT EXISTS idx_users_ssh_fp ON users(ssh_fp);\n\n CREATE TABLE IF NOT EXISTS invites (\n token TEXT PRIMARY KEY,\n role TEXT NOT NULL CHECK (role IN ('admin','member')),\n invited_by INTEGER NOT NULL REFERENCES users(id),\n created_at INTEGER NOT NULL,\n expires_at INTEGER NOT NULL,\n consumed_at INTEGER,\n consumed_by INTEGER REFERENCES users(id)\n );\n\n CREATE INDEX IF NOT EXISTS idx_invites_expires ON invites(expires_at);\n `);\n}\n\nexport interface InsertUserInput {\n short_name: string;\n ssh_pubkey: string;\n ssh_fp: string;\n stamp_pubkey?: string | null;\n role: Role;\n source: UserSource;\n invited_by?: number | null;\n}\n\n/** Insert a user. Throws if short_name or ssh_fp collide. */\nexport function insertUser(db: DatabaseSync, input: InsertUserInput): number {\n const stmt = db.prepare(\n `INSERT INTO users (short_name, ssh_pubkey, ssh_fp, stamp_pubkey, role, source, invited_by, created_at)\n VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,\n );\n const result = stmt.run(\n input.short_name,\n input.ssh_pubkey,\n input.ssh_fp,\n input.stamp_pubkey ?? null,\n input.role,\n input.source,\n input.invited_by ?? null,\n Math.floor(Date.now() / 1000),\n );\n return Number(result.lastInsertRowid);\n}\n\n/**\n * Idempotent insert keyed on ssh_fp. Returns the row id (newly-inserted or\n * pre-existing) and a `created` flag. Does NOT mutate role/short_name of an\n * existing row — the env-sync path runs on every boot, and we don't want\n * a manual admin demotion in the DB to be silently re-promoted by an\n * env-var entry that's still hanging around.\n *\n * If the caller's proposed short_name collides with an existing row that\n * has a DIFFERENT fingerprint, this throws. The seed-users entrypoint\n * handles that by appending a numeric suffix.\n */\nexport function upsertUserByFingerprint(\n db: DatabaseSync,\n input: InsertUserInput,\n): { id: number; created: boolean } {\n const existing = findUserBySshFingerprint(db, input.ssh_fp);\n if (existing) return { id: existing.id, created: false };\n const id = insertUser(db, input);\n return { id, created: true };\n}\n\nexport function findUserBySshFingerprint(\n db: DatabaseSync,\n ssh_fp: string,\n): UserRow | null {\n const stmt = db.prepare(\n `SELECT id, short_name, ssh_pubkey, ssh_fp, stamp_pubkey, role, source,\n invited_by, created_at, last_seen_at\n FROM users WHERE ssh_fp = ?`,\n );\n const row = stmt.get(ssh_fp) as UserRow | undefined;\n return row ?? null;\n}\n\nexport function findUserByShortName(\n db: DatabaseSync,\n short_name: string,\n): UserRow | null {\n const stmt = db.prepare(\n `SELECT id, short_name, ssh_pubkey, ssh_fp, stamp_pubkey, role, source,\n invited_by, created_at, last_seen_at\n FROM users WHERE short_name = ?`,\n );\n const row = stmt.get(short_name) as UserRow | undefined;\n return row ?? null;\n}\n\nexport function listUsers(db: DatabaseSync): UserRow[] {\n const stmt = db.prepare(\n `SELECT id, short_name, ssh_pubkey, ssh_fp, stamp_pubkey, role, source,\n invited_by, created_at, last_seen_at\n FROM users\n ORDER BY id`,\n );\n return stmt.all() as unknown as UserRow[];\n}\n\nexport function countByRole(db: DatabaseSync, role: Role): number {\n const stmt = db.prepare(`SELECT COUNT(*) AS n FROM users WHERE role = ?`);\n const row = stmt.get(role) as { n: number };\n return row.n;\n}\n\n/**\n * Generate a short_name that doesn't collide with any existing row. If\n * `desired` is free, returns it; otherwise appends `-2`, `-3`, ... until\n * a free slot is found. Used by the env-sync path where the proposed\n * short_name is derived from the SSH key's comment (often \"user@host\"),\n * which can collide if two keys share the same comment.\n */\nexport function suggestUniqueShortName(\n db: DatabaseSync,\n desired: string,\n): string {\n if (!findUserByShortName(db, desired)) return desired;\n for (let i = 2; i < 10000; i++) {\n const candidate = `${desired}-${i}`;\n if (!findUserByShortName(db, candidate)) return candidate;\n }\n throw new Error(\n `could not find a unique short_name for \"${desired}\" after 10000 attempts`,\n );\n}\n","import { existsSync, mkdirSync, readFileSync, statSync } from \"node:fs\";\nimport { homedir } from \"node:os\";\nimport { dirname, isAbsolute, join, resolve } from \"node:path\";\n\nexport function findRepoRoot(startFrom: string = process.cwd()): string {\n let current = resolve(startFrom);\n while (true) {\n if (existsSync(join(current, \".git\"))) return current;\n const parent = dirname(current);\n if (parent === current) {\n throw new Error(\n `not inside a git repository (searched up from ${startFrom})`,\n );\n }\n current = parent;\n }\n}\n\nexport function stampConfigDir(repoRoot: string): string {\n return join(repoRoot, \".stamp\");\n}\n\nexport function stampReviewersDir(repoRoot: string): string {\n return join(repoRoot, \".stamp\", \"reviewers\");\n}\n\nexport function stampTrustedKeysDir(repoRoot: string): string {\n return join(repoRoot, \".stamp\", \"trusted-keys\");\n}\n\nexport function stampConfigFile(repoRoot: string): string {\n return join(repoRoot, \".stamp\", \"config.yml\");\n}\n\nexport function stampStateDbPath(repoRoot: string): string {\n return join(gitCommonDir(repoRoot), \"stamp\", \"state.db\");\n}\n\n/**\n * Marker file that records \"we have shown the LLM data-flow notice in this\n * repo at least once.\" Lives next to state.db under the git common dir so\n * it's per-repo (not per-worktree, not committed).\n */\nexport function stampLlmNoticeMarkerPath(repoRoot: string): string {\n return join(gitCommonDir(repoRoot), \"stamp\", \"llm-notice-shown\");\n}\n\n/**\n * Resolve the git common directory for `repoRoot`. For a normal checkout this\n * is `<repoRoot>/.git`; for a worktree, `<repoRoot>/.git` is a *file* of the\n * form `gitdir: <path>` and the real common dir lives at `<gitdir>/commondir`\n * (a path relative to gitdir, typically `../..`). Mirrors `git rev-parse\n * --git-common-dir` without spawning git.\n *\n * State that should be shared across every worktree of one repository (review\n * verdicts, the per-machine sqlite db) lives under this common dir, so callers\n * resolve their paths through here rather than hard-coding `<repoRoot>/.git`.\n */\nexport function gitCommonDir(repoRoot: string): string {\n const dotGit = join(repoRoot, \".git\");\n const st = statSync(dotGit);\n if (st.isDirectory()) return dotGit;\n\n // Worktree (or submodule): `.git` is a file. Parse the `gitdir:` line, then\n // follow the `commondir` pointer from there. Submodules have no `commondir`,\n // so the gitdir itself is the writable common dir — fall through to that.\n const contents = readFileSync(dotGit, \"utf8\");\n const match = contents.match(/^gitdir:\\s*(.+)$/m);\n if (!match || !match[1]) {\n throw new Error(\n `expected '.git' at ${repoRoot} to be a directory or a 'gitdir:' pointer file, got: ${contents.slice(0, 120)}`,\n );\n }\n const gitdirRaw = match[1].trim();\n const gitdir = isAbsolute(gitdirRaw) ? gitdirRaw : resolve(repoRoot, gitdirRaw);\n\n const commondirPath = join(gitdir, \"commondir\");\n if (!existsSync(commondirPath)) return gitdir;\n const commondirRaw = readFileSync(commondirPath, \"utf8\").trim();\n return isAbsolute(commondirRaw) ? commondirRaw : resolve(gitdir, commondirRaw);\n}\n\nexport function userKeysDir(): string {\n return join(homedir(), \".stamp\", \"keys\");\n}\n\n/**\n * Per-user stamp-server config. Holds {host, port, user, repo_root_prefix}\n * so commands like `stamp provision` can reach the operator's stamp server\n * without making the agent guess at SSH endpoints.\n */\nexport function userServerConfigPath(): string {\n return join(homedir(), \".stamp\", \"server.yml\");\n}\n\n/**\n * Per-user stamp config. Today holds reviewer-model selections; structured\n * as a top-level object so future per-user knobs (telemetry sinks, default\n * timeouts, etc.) can land alongside without renaming the file. Lives\n * separately from per-repo `.stamp/config.yml` because cost/speed is\n * operator infrastructure rather than committed review policy — different\n * operators on the same repo are free to pick different models without\n * a merge-conflict over preference, and this file is intentionally\n * EXCLUDED from the v3 reviewer attestation hash chain.\n */\nexport function userConfigPath(): string {\n return join(homedir(), \".stamp\", \"config.yml\");\n}\n\nexport function ensureDir(path: string, mode = 0o755): void {\n if (!existsSync(path)) {\n mkdirSync(path, { recursive: true, mode });\n }\n}\n\nexport function isFile(path: string): boolean {\n try {\n return statSync(path).isFile();\n } catch {\n return false;\n }\n}\n","/**\n * User-management operations against the membership sqlite, with the\n * role authority matrix and last-owner guard enforced in one place.\n *\n * Authority matrix:\n *\n * - Owner: full control. May change anyone's role to anything (subject\n * to the last-owner guard) and remove anyone (subject to the same).\n * - Admin: may manage MEMBERS only. May not modify admins or owners,\n * and may not promote anyone to admin/owner. Bootstrap exception:\n * when no owners exist anywhere in the table, an admin may promote\n * THEMSELVES to owner — exactly once per server lifetime, since\n * after that promotion the no-owners precondition no longer holds.\n * - Member: no user-management surface at all.\n *\n * Last-owner guard: any operation that would leave zero owners (demoting\n * the last owner, or removing the last owner) is refused with a\n * `last_owner_would_be_lost` reason. Operators are expected to promote a\n * successor first.\n *\n * These rules are server-side and authoritative; the CLI sends the\n * caller's request and the server decides. Putting them in a lib module\n * (not the SSH wrapper) so unit tests can exercise the full matrix\n * without spawning subprocesses.\n */\n\nimport { DatabaseSync } from \"node:sqlite\";\nimport {\n countByRole,\n findUserByShortName,\n type Role,\n type UserRow,\n} from \"./serverDb.js\";\n\nexport type SetRoleDenial =\n | \"target_not_found\"\n | \"caller_lacks_authority\"\n | \"last_owner_would_be_lost\"\n | \"invalid_target_role\";\n\nexport type SetRoleResult =\n | { ok: true; old_role: Role; new_role: Role; no_change: boolean }\n | { ok: false; reason: SetRoleDenial };\n\nconst VALID_ROLES: ReadonlySet<Role> = new Set([\"owner\", \"admin\", \"member\"]);\n\n/**\n * Decide whether `caller` may change `target`'s role to `newRole`.\n * Returns null on approval, or the denial reason string.\n *\n * Authority is checked BEFORE the last-owner guard so a non-authoritative\n * caller gets a generic `caller_lacks_authority` rather than a\n * `last_owner_would_be_lost` reason that would leak \"target is the last\n * owner\" downstream. The guard still applies to authoritative callers\n * (e.g. a sole owner attempting to demote themselves).\n */\nfunction checkSetRoleAuthority(\n caller: UserRow,\n target: UserRow,\n newRole: Role,\n ownerCount: number,\n): SetRoleDenial | null {\n // Authority check first.\n let authority_ok = false;\n if (caller.role === \"owner\") {\n // Owners may set any target to any role.\n authority_ok = true;\n } else if (caller.role === \"admin\") {\n // Bootstrap: zero owners exist and admin is promoting THEMSELVES to\n // owner. This is the chicken-and-egg escape — without it, a server\n // seeded only from AUTHORIZED_KEYS (everyone admin, no owner) has\n // no path to a first owner.\n if (\n ownerCount === 0 &&\n newRole === \"owner\" &&\n target.id === caller.id\n ) {\n authority_ok = true;\n } else if (target.role === \"member\" && newRole === \"member\") {\n // Admins may manage members. Promotion to admin/owner is\n // owner-only; touching admins/owners is also owner-only.\n authority_ok = true;\n }\n }\n if (!authority_ok) return \"caller_lacks_authority\";\n\n // Last-owner guard runs after authority. By construction, only owners\n // can reach this branch with a target that is currently owner.\n if (target.role === \"owner\" && newRole !== \"owner\" && ownerCount <= 1) {\n return \"last_owner_would_be_lost\";\n }\n\n return null;\n}\n\n/**\n * Set `target_short_name`'s role to `newRole`, gated by the authority\n * matrix above. Returns a tagged result so callers can map specific\n * denial reasons to specific HTTP statuses / CLI exit codes.\n */\nexport function setUserRole(\n db: DatabaseSync,\n caller: UserRow,\n target_short_name: string,\n newRole: Role,\n): SetRoleResult {\n if (!VALID_ROLES.has(newRole)) {\n return { ok: false, reason: \"invalid_target_role\" };\n }\n const target = findUserByShortName(db, target_short_name);\n if (!target) return { ok: false, reason: \"target_not_found\" };\n\n const ownerCount = countByRole(db, \"owner\");\n const denial = checkSetRoleAuthority(caller, target, newRole, ownerCount);\n if (denial) return { ok: false, reason: denial };\n\n const old_role = target.role;\n if (old_role === newRole) {\n // No-change is still an approved outcome — the caller had authority\n // for the transition, the row simply already holds the requested\n // role. Reported separately so CLI prose can call it out without\n // pretending a change happened.\n return { ok: true, old_role, new_role: newRole, no_change: true };\n }\n\n db.prepare(`UPDATE users SET role = ? WHERE id = ?`).run(newRole, target.id);\n return { ok: true, old_role, new_role: newRole, no_change: false };\n}\n\nexport type RemoveUserDenial =\n | \"target_not_found\"\n | \"caller_lacks_authority\"\n | \"last_owner_would_be_lost\"\n | \"cannot_remove_self\";\n\nexport type RemoveUserResult =\n | { ok: true; removed: UserRow }\n | { ok: false; reason: RemoveUserDenial };\n\nexport function removeUser(\n db: DatabaseSync,\n caller: UserRow,\n target_short_name: string,\n): RemoveUserResult {\n const target = findUserByShortName(db, target_short_name);\n if (!target) return { ok: false, reason: \"target_not_found\" };\n\n // Self-removal is explicitly disallowed. Operators who want to leave\n // a server should have another admin remove their row — prevents the\n // foot-gun of an admin accidentally deleting themselves mid-session\n // and losing access to fix it.\n if (target.id === caller.id) return { ok: false, reason: \"cannot_remove_self\" };\n\n // Authority first (same reason as setUserRole): a non-authoritative\n // caller gets a generic denial rather than a leak that the target is\n // the last owner.\n if (caller.role === \"owner\") {\n // Owners may remove anyone except via the guards above/below.\n } else if (caller.role === \"admin\") {\n // Admins may remove members only.\n if (target.role !== \"member\") {\n return { ok: false, reason: \"caller_lacks_authority\" };\n }\n } else {\n return { ok: false, reason: \"caller_lacks_authority\" };\n }\n\n // Last-owner guard. With authority already enforced, only owners can\n // reach this branch with a target that is currently owner; combined\n // with the self-removal block above, the guard is structurally\n // shadowed today (an owner can never remove \"the last owner\" who is\n // someone else, because if only one owner exists, the caller IS that\n // owner and was caught above). Kept for defense in depth — a future\n // code path that bypasses cannot_remove_self would otherwise zero out\n // ownership.\n const ownerCount = countByRole(db, \"owner\");\n if (target.role === \"owner\" && ownerCount <= 1) {\n return { ok: false, reason: \"last_owner_would_be_lost\" };\n }\n\n db.prepare(`DELETE FROM users WHERE id = ?`).run(target.id);\n return { ok: true, removed: target };\n}\n\nexport type ListUsersDenial = \"caller_lacks_authority\";\n\nexport type ListUsersResult =\n | { ok: true; users: UserRow[] }\n | { ok: false; reason: ListUsersDenial };\n\n/**\n * List all users. Everyone authenticated (member, admin, owner) may\n * call this. The data exposed (short_name, role, ssh pubkey comments)\n * is the same set teammates would see in any other multi-user\n * collaboration tool; keeping the list freely readable lowers the\n * coordination cost (\"who else has access here?\") without revealing\n * anything sensitive.\n */\nexport function listUsersForCaller(\n db: DatabaseSync,\n // Argument kept for API symmetry with setUserRole/removeUser (and so\n // future role-gated read tiers like \"guest\" have an obvious place to\n // land), but every current `Role` value is permitted to list — the\n // exposed columns are coordination-data (who else has access) not\n // secrets. See doc comment above.\n _caller: UserRow,\n): ListUsersResult {\n const rows = db\n .prepare(\n `SELECT id, short_name, ssh_pubkey, ssh_fp, stamp_pubkey, role, source,\n invited_by, created_at, last_seen_at\n FROM users\n ORDER BY\n CASE role WHEN 'owner' THEN 0 WHEN 'admin' THEN 1 ELSE 2 END,\n short_name`,\n )\n .all() as unknown as UserRow[];\n return { ok: true, users: rows };\n}\n","/**\n * Parse the SSH_USER_AUTH file that sshd writes during connection setup\n * when `ExposeAuthInfo yes` is set in sshd_config. Each line is one\n * auth method that successfully authenticated the user; for pubkey auth\n * the line looks like:\n *\n * publickey ssh-ed25519 AAAA... user@host\n *\n * The pubkey blob portion is the same wire format AuthorizedKeysFile\n * lines use, so we hand it through parseSshPubkey to compute the\n * fingerprint that keys the membership DB lookup.\n *\n * This is the load-bearing identity-binding step for SSH-invoked admin\n * commands (stamp-mint-invite, future user-management wrappers): without\n * it, a connected client could claim any role at the wrapper level. With\n * it, the wrapper trusts only sshd's already-completed pubkey auth.\n */\n\nimport { readFileSync } from \"node:fs\";\nimport { parseSshPubkey, type SshPubkey } from \"./sshKeys.js\";\n\n/**\n * Read SSH_USER_AUTH from the process env and return the first publickey\n * entry's parsed pubkey. Returns null when:\n * - SSH_USER_AUTH is unset (not run under sshd with ExposeAuthInfo)\n * - the file is missing or unreadable\n * - no `publickey` line is present (auth via a non-publickey method)\n *\n * Callers should treat null as \"no authenticated identity available\" and\n * refuse to proceed with admin actions — the absence of an identity is\n * never a green-light, only an opt-out-of-the-action signal.\n */\nexport function readAuthenticatedPubkey(): SshPubkey | null {\n const path = process.env[\"SSH_USER_AUTH\"];\n if (!path) return null;\n\n let raw: string;\n try {\n raw = readFileSync(path, \"utf8\");\n } catch {\n return null;\n }\n\n for (const line of raw.split(\"\\n\")) {\n const trimmed = line.trim();\n if (!trimmed.startsWith(\"publickey \")) continue;\n // Strip the leading \"publickey \" token; the rest is a normal pubkey line.\n const pubkeyLine = trimmed.slice(\"publickey \".length).trim();\n try {\n return parseSshPubkey(pubkeyLine);\n } catch {\n // Malformed line — keep looking; sshd may emit multiple successful\n // methods if more than one is configured.\n continue;\n }\n }\n return null;\n}\n","/**\n * Parse OpenSSH-format public keys and compute their SHA256 fingerprints\n * in the exact format sshd emits via the `%f` format specifier passed to\n * AuthorizedKeysCommand. That format is `SHA256:<base64-no-padding>`,\n * distinct from the `sha256:<hex>` form used elsewhere in stamp for stamp\n * signing keys (PEM/SPKI). Both formats exist; this module is the SSH\n * side only.\n *\n * The lookup keyed on this fingerprint is the load-bearing path for\n * sshd-based authentication of users stored in the membership sqlite —\n * any drift between this fingerprint and what sshd computes breaks\n * every connection. Test coverage pins the format to a known OpenSSH\n * fixture so the next regression here is loud.\n */\n\nimport { createHash } from \"node:crypto\";\n\nexport interface SshPubkey {\n /** Key algorithm token, e.g. \"ssh-ed25519\", \"ecdsa-sha2-nistp256\". */\n algorithm: string;\n /** Base64-decoded key blob (the bytes between the algorithm token and\n * the trailing comment in a public-key line). */\n keyBlob: Buffer;\n /** Trailing comment, typically \"user@host\". May be empty. */\n comment: string;\n /** The original single-line representation, trimmed of leading/trailing\n * whitespace. Stored verbatim in the membership DB so the value sshd\n * later prints back via AuthorizedKeysCommand is bit-identical to what\n * the operator submitted. */\n full: string;\n /** OpenSSH-style fingerprint: \"SHA256:<base64-no-padding>\". Matches the\n * `%f` value sshd passes to AuthorizedKeysCommand. */\n fingerprint: string;\n}\n\nconst ALLOWED_ALGOS = new Set([\n \"ssh-ed25519\",\n \"ssh-rsa\",\n \"ecdsa-sha2-nistp256\",\n \"ecdsa-sha2-nistp384\",\n \"ecdsa-sha2-nistp521\",\n]);\n\n/**\n * Parse a single OpenSSH-format public-key line into its components.\n * Rejects empty/blank lines and lines whose algorithm token is not on the\n * conservative allowlist (above) — keeps malformed input out of the DB\n * before we ever try to hand it to sshd.\n */\nexport function parseSshPubkey(line: string): SshPubkey {\n const trimmed = line.trim();\n if (trimmed.length === 0) {\n throw new Error(\"ssh pubkey line is empty\");\n }\n if (trimmed.startsWith(\"#\")) {\n throw new Error(\"ssh pubkey line is a comment\");\n }\n\n const parts = trimmed.split(/\\s+/);\n if (parts.length < 2) {\n throw new Error(\n \"ssh pubkey line must have at least <algorithm> <base64> tokens\",\n );\n }\n\n const [algorithm, b64, ...rest] = parts as [string, string, ...string[]];\n if (!ALLOWED_ALGOS.has(algorithm)) {\n throw new Error(`unsupported ssh pubkey algorithm: ${algorithm}`);\n }\n\n // Buffer.from(string, \"base64\") does NOT throw on invalid input — it\n // silently strips non-base64 characters. So a try/catch around this\n // call is dead code; the real validation is the re-encode comparison\n // below, which catches a paste with a stray quote/character that\n // would otherwise produce a key blob mismatched against sshd's view.\n const keyBlob = Buffer.from(b64, \"base64\");\n if (keyBlob.length === 0) {\n throw new Error(\"ssh pubkey base64 blob is empty\");\n }\n if (keyBlob.toString(\"base64\").replace(/=+$/, \"\") !== b64.replace(/=+$/, \"\")) {\n throw new Error(\"ssh pubkey base64 blob has trailing junk\");\n }\n\n return {\n algorithm,\n keyBlob,\n comment: rest.join(\" \"),\n full: trimmed,\n fingerprint: sshFingerprintFromBlob(keyBlob),\n };\n}\n\n/**\n * SHA256 fingerprint of a raw key blob in OpenSSH wire format. Output is\n * `SHA256:<base64-no-padding>` — the exact form sshd emits in logs and via\n * the `%f` format specifier.\n */\nexport function sshFingerprintFromBlob(keyBlob: Buffer): string {\n const hash = createHash(\"sha256\").update(keyBlob).digest();\n const b64 = hash.toString(\"base64\").replace(/=+$/, \"\");\n return `SHA256:${b64}`;\n}\n\n/**\n * Split a multi-line authorized_keys-style blob into individual valid pubkey\n * lines, dropping blanks and `#` comments. Returns parsed pubkeys and any\n * parse failures alongside their source line numbers — callers decide\n * whether to abort or log-and-continue.\n */\nexport function parseSshPubkeyList(blob: string): {\n pubkeys: SshPubkey[];\n errors: Array<{ lineNumber: number; line: string; error: string }>;\n} {\n const pubkeys: SshPubkey[] = [];\n const errors: Array<{ lineNumber: number; line: string; error: string }> = [];\n const lines = blob.split(\"\\n\");\n for (let i = 0; i < lines.length; i++) {\n const raw = lines[i] ?? \"\";\n const stripped = raw.trim();\n if (stripped.length === 0 || stripped.startsWith(\"#\")) continue;\n try {\n pubkeys.push(parseSshPubkey(stripped));\n } catch (e) {\n errors.push({\n lineNumber: i + 1,\n line: stripped,\n error: (e as Error).message,\n });\n }\n }\n return { pubkeys, errors };\n}\n","/**\n * SSH-invoked user-management dispatcher, reachable as:\n *\n * ssh git@<host> stamp-users list\n * ssh git@<host> stamp-users promote <name> --to <admin|owner>\n * ssh git@<host> stamp-users demote <name> --to <admin|member>\n * ssh git@<host> stamp-users remove <name>\n *\n * Symlinked into /home/git/git-shell-commands/stamp-users on the server.\n * Authenticates the caller by reading SSH_USER_AUTH (requires\n * ExposeAuthInfo yes in sshd_config — already enabled by phase 2) and\n * dispatches to the authority-matrix-aware lib operations in userOps.ts.\n *\n * Exit codes (consumed by the CLI client for specific operator prose):\n *\n * 0 — success\n * 1 — server-side config error (DB unreadable, identity binding\n * failure, etc.)\n * 2 — usage error (missing/bad argv, unknown subcommand)\n * 3 — authority denial (caller's role doesn't permit the action)\n * 4 — target not found\n * 5 — last-owner-would-be-lost guard\n * 6 — cannot remove self\n *\n * stdout = machine-readable payload (a JSON object for `list`, an empty\n * body for write operations). stderr = human-readable prose using the\n * lowercase `note:` / `error:` convention that crosses the SSH boundary\n * unchanged into the operator's terminal.\n */\n\nimport {\n listUsersForCaller,\n removeUser,\n setUserRole,\n type ListUsersDenial,\n type RemoveUserDenial,\n type SetRoleDenial,\n} from \"../lib/userOps.js\";\nimport {\n findUserByShortName,\n findUserBySshFingerprint,\n openServerDb,\n type Role,\n type UserRow,\n} from \"../lib/serverDb.js\";\nimport { readAuthenticatedPubkey } from \"../lib/sshUserAuth.js\";\n\nconst EXIT = {\n OK: 0,\n CONFIG: 1,\n USAGE: 2,\n AUTHORITY: 3,\n NOT_FOUND: 4,\n LAST_OWNER: 5,\n CANNOT_REMOVE_SELF: 6,\n} as const;\n\nfunction fail(message: string, code: number): never {\n process.stderr.write(`error: ${message}\\n`);\n process.exit(code);\n}\n\nfunction usage(): never {\n process.stderr.write(\n \"usage:\\n\" +\n \" stamp-users list\\n\" +\n \" stamp-users promote <short_name> --to <admin|owner>\\n\" +\n \" stamp-users demote <short_name> --to <admin|member>\\n\" +\n \" stamp-users remove <short_name>\\n\" +\n \" stamp-users get-stamp-pubkey <short_name>\\n\",\n );\n process.exit(EXIT.USAGE);\n}\n\ninterface ParsedSetRole {\n subcommand: \"promote\" | \"demote\";\n short_name: string;\n to: Role;\n}\n\ninterface ParsedRemove {\n subcommand: \"remove\";\n short_name: string;\n}\n\ninterface ParsedList {\n subcommand: \"list\";\n}\n\ninterface ParsedGetStampPubkey {\n subcommand: \"get-stamp-pubkey\";\n short_name: string;\n}\n\ntype Parsed =\n | ParsedSetRole\n | ParsedRemove\n | ParsedList\n | ParsedGetStampPubkey;\n\nconst VALID_PROMOTE_TARGETS: ReadonlySet<Role> = new Set([\"admin\", \"owner\"]);\nconst VALID_DEMOTE_TARGETS: ReadonlySet<Role> = new Set([\"admin\", \"member\"]);\n\nfunction parseArgs(argv: string[]): Parsed {\n if (argv.length === 0) usage();\n const [sub, ...rest] = argv as [string, ...string[]];\n if (sub === \"list\") {\n if (rest.length > 0) fail(`'list' takes no arguments (got ${rest.length})`, EXIT.USAGE);\n return { subcommand: \"list\" };\n }\n if (sub === \"promote\" || sub === \"demote\") {\n let short_name = \"\";\n let to: Role | \"\" = \"\";\n for (let i = 0; i < rest.length; i++) {\n const arg = rest[i]!;\n if (arg === \"--to\") {\n const next = rest[i + 1];\n if (!next) fail(`'--to' requires a value`, EXIT.USAGE);\n if (next !== \"admin\" && next !== \"member\" && next !== \"owner\") {\n fail(`--to must be 'admin', 'member', or 'owner' (got ${JSON.stringify(next)})`, EXIT.USAGE);\n }\n to = next;\n i++;\n } else if (arg.startsWith(\"--\")) {\n fail(`unknown flag: ${arg}`, EXIT.USAGE);\n } else if (!short_name) {\n short_name = arg;\n } else {\n fail(`unexpected positional argument: ${arg}`, EXIT.USAGE);\n }\n }\n if (!short_name) fail(`missing <short_name>`, EXIT.USAGE);\n if (!to) fail(`'${sub}' requires --to <role>`, EXIT.USAGE);\n if (sub === \"promote\" && !VALID_PROMOTE_TARGETS.has(to)) {\n fail(`promote --to must be 'admin' or 'owner' (got '${to}')`, EXIT.USAGE);\n }\n if (sub === \"demote\" && !VALID_DEMOTE_TARGETS.has(to)) {\n fail(`demote --to must be 'admin' or 'member' (got '${to}')`, EXIT.USAGE);\n }\n return { subcommand: sub, short_name, to };\n }\n if (sub === \"remove\") {\n if (rest.length === 0) fail(`missing <short_name>`, EXIT.USAGE);\n if (rest.length > 1) fail(`unexpected positional argument: ${rest[1]}`, EXIT.USAGE);\n return { subcommand: \"remove\", short_name: rest[0]! };\n }\n if (sub === \"get-stamp-pubkey\") {\n if (rest.length === 0) fail(`missing <short_name>`, EXIT.USAGE);\n if (rest.length > 1) fail(`unexpected positional argument: ${rest[1]}`, EXIT.USAGE);\n return { subcommand: \"get-stamp-pubkey\", short_name: rest[0]! };\n }\n fail(`unknown subcommand: ${sub}`, EXIT.USAGE);\n}\n\nfunction exitFromSetRoleDenial(reason: SetRoleDenial): number {\n switch (reason) {\n case \"target_not_found\":\n return EXIT.NOT_FOUND;\n case \"caller_lacks_authority\":\n return EXIT.AUTHORITY;\n case \"last_owner_would_be_lost\":\n return EXIT.LAST_OWNER;\n case \"invalid_target_role\":\n return EXIT.USAGE;\n }\n}\n\nfunction exitFromRemoveDenial(reason: RemoveUserDenial): number {\n switch (reason) {\n case \"target_not_found\":\n return EXIT.NOT_FOUND;\n case \"caller_lacks_authority\":\n return EXIT.AUTHORITY;\n case \"last_owner_would_be_lost\":\n return EXIT.LAST_OWNER;\n case \"cannot_remove_self\":\n return EXIT.CANNOT_REMOVE_SELF;\n }\n}\n\nfunction exitFromListDenial(_reason: ListUsersDenial): number {\n return EXIT.AUTHORITY;\n}\n\nfunction resolveCaller(): UserRow {\n const pubkey = readAuthenticatedPubkey();\n if (!pubkey) {\n fail(\n \"could not determine authenticated identity (SSH_USER_AUTH unset or has no publickey entry). \" +\n \"Server may be missing 'ExposeAuthInfo yes' in sshd_config.\",\n EXIT.CONFIG,\n );\n }\n // skipChmod: this wrapper runs as the git user, root-owned file; entrypoint.sh\n // handles boot-time perm tightening. See src/lib/serverDb.ts comment.\n const db = openServerDb({ skipChmod: true });\n try {\n const caller = findUserBySshFingerprint(db, pubkey.fingerprint);\n if (!caller) {\n fail(\n `caller fingerprint ${pubkey.fingerprint} is not in the membership DB. ` +\n `Likely cause: phase-1 env-var sync hasn't run on this server yet.`,\n EXIT.CONFIG,\n );\n }\n return caller;\n } finally {\n db.close();\n }\n}\n\nfunction runList(): void {\n const caller = resolveCaller();\n const db = openServerDb({ skipChmod: true });\n try {\n const result = listUsersForCaller(db, caller);\n if (!result.ok) {\n fail(`listing users failed: ${result.reason}`, exitFromListDenial(result.reason));\n }\n // JSON output for the CLI to format. Includes ssh_pubkey for\n // human-readable comment but excludes any future secret-bearing\n // fields (last_seen_at is operational metadata, included).\n const payload = result.users.map((u) => ({\n id: u.id,\n short_name: u.short_name,\n role: u.role,\n source: u.source,\n ssh_fp: u.ssh_fp,\n has_stamp_pubkey: u.stamp_pubkey !== null,\n invited_by: u.invited_by,\n created_at: u.created_at,\n last_seen_at: u.last_seen_at,\n }));\n process.stdout.write(JSON.stringify({ users: payload }) + \"\\n\");\n } finally {\n db.close();\n }\n}\n\nfunction runSetRole(parsed: ParsedSetRole): void {\n const caller = resolveCaller();\n const db = openServerDb({ skipChmod: true });\n try {\n const result = setUserRole(db, caller, parsed.short_name, parsed.to);\n if (!result.ok) {\n fail(\n `${parsed.subcommand} ${parsed.short_name} --to ${parsed.to}: ${result.reason}`,\n exitFromSetRoleDenial(result.reason),\n );\n }\n if (result.no_change) {\n process.stderr.write(\n `note: ${parsed.short_name} was already ${result.new_role} (no change)\\n`,\n );\n } else {\n process.stderr.write(\n `note: ${parsed.short_name} ${result.old_role} → ${result.new_role}\\n`,\n );\n }\n } finally {\n db.close();\n }\n}\n\nfunction runRemove(parsed: ParsedRemove): void {\n const caller = resolveCaller();\n const db = openServerDb({ skipChmod: true });\n try {\n const result = removeUser(db, caller, parsed.short_name);\n if (!result.ok) {\n fail(\n `remove ${parsed.short_name}: ${result.reason}`,\n exitFromRemoveDenial(result.reason),\n );\n }\n process.stderr.write(\n `note: removed ${result.removed.short_name} (was ${result.removed.role})\\n`,\n );\n } finally {\n db.close();\n }\n}\n\nfunction runGetStampPubkey(parsed: ParsedGetStampPubkey): void {\n // Identity binding still required (so this surface stays consistent\n // with the rest of stamp-users — only authenticated users can read\n // the membership DB) but no role check beyond \"you're enrolled\".\n // The phase-4 trust-grant flow goes through the standard stamp gate\n // anyway, so an enrolled member who fetches a peer's stamp_pubkey\n // can't unilaterally widen anyone's trust.\n resolveCaller();\n const db = openServerDb({ skipChmod: true });\n try {\n const target = findUserByShortName(db, parsed.short_name);\n if (!target) {\n fail(`user ${JSON.stringify(parsed.short_name)} not found`, EXIT.NOT_FOUND);\n }\n if (target.stamp_pubkey === null) {\n fail(\n `user ${JSON.stringify(parsed.short_name)} has no stamp signing pubkey on file ` +\n `— ask them to re-enroll via stamp invites accept with --stamp-pubkey`,\n EXIT.NOT_FOUND,\n );\n }\n // PEM goes to stdout exactly as stored. The receiving CLI pipes\n // this verbatim into the repo's .stamp/trusted-keys/<name>.pub\n // file, so any drift here is observable in the next diff review.\n process.stdout.write(target.stamp_pubkey);\n if (!target.stamp_pubkey.endsWith(\"\\n\")) {\n process.stdout.write(\"\\n\");\n }\n } finally {\n db.close();\n }\n}\n\nfunction main(): void {\n const parsed = parseArgs(process.argv.slice(2));\n switch (parsed.subcommand) {\n case \"list\":\n runList();\n break;\n case \"promote\":\n case \"demote\":\n runSetRole(parsed);\n break;\n case \"remove\":\n runRemove(parsed);\n break;\n case \"get-stamp-pubkey\":\n runGetStampPubkey(parsed);\n break;\n }\n}\n\nmain();\n"],"mappings":"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","names":["import_node_fs","import_node_path","import_node_fs"]}
|