@openthink/stamp 2.1.0 → 2.2.0
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 +5499 -3926
- package/dist/index.js.map +1 -1
- package/dist/server/prompts-cache-bootstrap.cjs +276 -0
- package/dist/server/prompts-cache-bootstrap.cjs.map +1 -0
- package/dist/server/stamp-review.cjs +43 -14
- package/dist/server/stamp-review.cjs.map +1 -1
- package/dist/server/start-http-server.cjs +471 -2
- package/dist/server/start-http-server.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, 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":"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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/prompts-cache.ts","../../src/server/promptFetch.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 * POST /webhook/prompts (AGT-374, Phase B)\n * headers: X-Hub-Signature-256, X-GitHub-Delivery, X-GitHub-Event\n * body: github push-event JSON (we don't introspect it — the HMAC over\n * the raw bytes is the only thing this endpoint trusts)\n * 202: {ok:true} — refresh scheduled (or coalesced with a recent one)\n * 401: {ok:false, error:\"invalid_signature\"} — HMAC mismatch\n * 503: {ok:false, error:\"webhook_secret_unconfigured\"}\n *\n * GET /healthz\n * 200: {ok:true} — for orchestrator probes\n *\n * Hard cap on request body at 16 KiB for invite-accept, 64 KiB for the\n * prompts webhook (github push deliveries can exceed 16 KiB on busy repos).\n * Sshd-style fail-open does NOT apply here: this surface is the only path\n * that mutates the users table from a non-root context, so we surface real\n * status codes and refuse the request on any malformed input.\n */\n\nimport {\n createHmac,\n timingSafeEqual,\n type BinaryLike,\n} from \"node:crypto\";\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\";\nimport {\n cloneOrFetchPromptsCache,\n type CloneOrFetchOpts,\n type RefreshResult,\n} from \"./prompts-cache.js\";\n\nconst DEFAULT_PORT = 8080;\nconst MAX_BODY_BYTES = 16 * 1024;\n// Github push-event deliveries on large repos (many tags, big release pushes,\n// hundreds of commits in one push) routinely run 20-40 KiB. 64 KiB is a\n// comfortable headroom that still bounds the worst-case allocation an\n// unauthenticated request can force us into BEFORE we've HMAC-validated.\nconst WEBHOOK_MAX_BODY_BYTES = 64 * 1024;\n// 5-second coalescing window — see `scheduleWebhookRefresh` below.\nconst WEBHOOK_COALESCE_WINDOW_MS = 5_000;\n// Default cache location matches the project README + AGT-375's entrypoint\n// volume layout. Overridable via STAMP_PROMPTS_CACHE_ROOT.\nconst DEFAULT_PROMPTS_CACHE_ROOT = \"/srv/git/.prompts-cache\";\n// Default deploy-key location matches AGT-375's entrypoint provisioning.\n// Resolved lazily inside `buildRefreshOpts` so a missing key path doesn't\n// crash import.\nconst DEFAULT_DEPLOY_KEY_PATH = \"/srv/git/.ssh-client-keys/prompts_repo_key\";\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(\n req: IncomingMessage,\n maxBytes: number = MAX_BODY_BYTES,\n): 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 > maxBytes) {\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\n// ─── POST /webhook/prompts (AGT-374, Phase B) ─────────────────────────\n\n/**\n * Delivery-ID shape used for log redaction. Github sends a UUID4; we accept\n * any reasonable opaque token and reject obvious garbage (CRLF, null bytes,\n * absurd length) before logging it — the value goes straight into a stdout\n * line where a malicious sender could otherwise inject a fake log entry.\n */\nconst DELIVERY_ID_RE = /^[A-Za-z0-9._:-]{1,200}$/;\n\n/**\n * Coalescing state for `POST /webhook/prompts`.\n *\n * Github fires a `push` event on every push to the prompts repo, including\n * fast-follow CI / squash-merge / release-tag bursts. We don't need a fetch\n * per delivery — one fetch per ~5 s is plenty and matches the operator's\n * mental model of \"the cache is eventually consistent with the prompts repo\n * within seconds of a push.\"\n *\n * - `lastKickoffAt` — monotonic ms (from `Date.now()`) at which the most\n * recent refresh was scheduled. A new delivery within\n * WINDOW_MS of this timestamp does NOT kick off a\n * fresh refresh.\n * - `pendingTrailingRefresh` — set to true when a delivery arrives inside\n * the window. When the active refresh settles, we\n * schedule one more refresh to capture the trailing\n * edge of the burst — otherwise the very last push of\n * a 5-deliveries-in-1s burst could be lost between the\n * fetch we already started and the throttle window.\n * - `inflight` — the promise of the currently-running refresh, if\n * any. Settling this promise drains the pending\n * trailing flag.\n *\n * NOTE: AGT-372's `prompts-cache` module already coalesces concurrent calls\n * inside a single process via its own `inflightRefreshes` map. This route-\n * level throttle layers on TOP of that to avoid even ENTERING the cache\n * module on every delivery — saves the lock-file acquire/release + the git\n * fetch even when it would have been a no-op.\n */\ninterface WebhookCoalesceState {\n lastKickoffAt: number;\n pendingTrailingRefresh: boolean;\n inflight: Promise<RefreshResult> | null;\n}\n\nconst webhookState: WebhookCoalesceState = {\n lastKickoffAt: 0,\n pendingTrailingRefresh: false,\n inflight: null,\n};\n\n/**\n * Dependency-injection seam for tests: the production implementation is\n * `cloneOrFetchPromptsCache` from the prompts-cache module; tests swap in\n * a counter-bumping stub so they can assert on coalescing without spinning\n * up a real git fixture for every webhook test.\n *\n * Internal — exported only via `__setRefreshFnForTests` below.\n */\nlet refreshFn: (opts: CloneOrFetchOpts) => Promise<RefreshResult> =\n cloneOrFetchPromptsCache;\n\n/**\n * Test-only override of the refresh function. Call with `null` to restore the\n * production binding. Mirrors the env-var-override seam invite-accept uses\n * for `STAMP_SERVER_DB_PATH` — DI without a constructor argument keeps the\n * `startServer(port)` shape stable for the bin script.\n */\nexport function __setRefreshFnForTests(\n fn: ((opts: CloneOrFetchOpts) => Promise<RefreshResult>) | null,\n): void {\n refreshFn = fn ?? cloneOrFetchPromptsCache;\n}\n\n/**\n * Test-only reset of the coalescing state. Two tests fired back-to-back\n * would otherwise see each other's `lastKickoffAt` and skip a refresh that\n * the second test expects to run. Production code never calls this — the\n * server boots with a fresh state object and the throttle works from there.\n */\nexport function __resetWebhookStateForTests(): void {\n webhookState.lastKickoffAt = 0;\n webhookState.pendingTrailingRefresh = false;\n webhookState.inflight = null;\n}\n\n/**\n * Build the `CloneOrFetchOpts` block for a webhook-triggered refresh from\n * env vars. Returns null if `STAMP_PROMPTS_REPO_URL` is unset — that's a\n * misconfiguration (we got a webhook delivery for a repo we don't have a\n * URL for) but we treat it the same way as a missing secret: 503 with a\n * clear operator-actionable error rather than a silent no-op. The webhook\n * receiver can't usefully proceed without knowing what to fetch.\n *\n * `STAMP_PROMPTS_REPO_REF` defaults to `main`. `STAMP_PROMPTS_CACHE_ROOT`\n * defaults to `/srv/git/.prompts-cache` (the path AGT-375's entrypoint\n * provisions on the persistent volume). The deploy-key path defaults to the\n * AGT-375 location; passing it for an HTTPS URL is harmless — the cache\n * module ignores it when the URL isn't SSH-shaped.\n */\nfunction buildRefreshOpts(): CloneOrFetchOpts | null {\n const url = process.env[\"STAMP_PROMPTS_REPO_URL\"];\n if (!url) return null;\n const ref = process.env[\"STAMP_PROMPTS_REPO_REF\"] || \"main\";\n const cacheRoot =\n process.env[\"STAMP_PROMPTS_CACHE_ROOT\"] || DEFAULT_PROMPTS_CACHE_ROOT;\n const deployKeyPath =\n process.env[\"STAMP_PROMPTS_DEPLOY_KEY_PATH\"] || DEFAULT_DEPLOY_KEY_PATH;\n return { url, ref, cacheRoot, deployKeyPath };\n}\n\n/**\n * Validate `X-Hub-Signature-256` against the raw request body. Returns true\n * iff the header parses as `sha256=<64-hex-chars>` AND the decoded digest\n * matches the HMAC-SHA256 of `body` under `secret`.\n *\n * Implementation notes:\n *\n * - `timingSafeEqual` requires equal-length buffers; we length-check\n * BEFORE handing them off so a malformed signature can't crash the\n * handler.\n * - The HMAC computation uses the raw body bytes — NOT the parsed JSON\n * string. Github computes the HMAC over the wire payload; any\n * re-stringification would change the bytes and break the check.\n * - The supplied signature is never logged. The caller logs ONLY the\n * delivery ID + remote address on rejection (per AC bullet 3).\n */\nfunction verifyWebhookSignature(\n body: BinaryLike,\n signatureHeader: string | undefined,\n secret: string,\n): boolean {\n if (!signatureHeader || !signatureHeader.startsWith(\"sha256=\")) {\n return false;\n }\n const providedHex = signatureHeader.slice(\"sha256=\".length);\n // Hex-decode is strict: an odd-length or non-hex character produces a\n // shorter buffer than expected, which the length check below catches.\n // (Node's Buffer.from with 'hex' silently truncates at the first invalid\n // character — we therefore reject anything that isn't exactly 64 hex\n // characters upstream of the decode, so the length check is belt-and-\n // suspenders rather than the primary defense.)\n if (!/^[0-9a-fA-F]{64}$/.test(providedHex)) {\n return false;\n }\n const provided = Buffer.from(providedHex, \"hex\");\n const expected = createHmac(\"sha256\", secret).update(body).digest();\n if (provided.length !== expected.length) {\n return false;\n }\n return timingSafeEqual(provided, expected);\n}\n\n/**\n * Schedule (or coalesce) a prompts-cache refresh. Called from the webhook\n * handler AFTER the response has been sent — github expects a fast 202 and\n * will retry on a timeout. The refresh runs in the background.\n *\n * Throttle rules:\n *\n * - If the previous refresh kickoff was >= WINDOW_MS ago, kick off\n * immediately. Update `lastKickoffAt`.\n * - If the previous refresh kickoff was < WINDOW_MS ago AND a refresh is\n * currently in flight, set `pendingTrailingRefresh = true`. The\n * in-flight refresh's `.finally` will fire one more refresh after it\n * settles — captures the trailing-edge delivery of a burst.\n * - If the previous refresh kickoff was < WINDOW_MS ago AND nothing is\n * in flight (the previous one already settled), just no-op — the cache\n * is already at HEAD as of <WINDOW_MS ago, the next push will refresh\n * it again. Setting `pendingTrailingRefresh` here would create a\n * debounce-without-leading-edge pattern that's surprising.\n *\n * Errors thrown by `refreshFn` are caught and logged but never propagated\n * — the response has already been sent, and an unhandled rejection from a\n * setImmediate callback would crash the listener.\n */\nfunction scheduleWebhookRefresh(opts: CloneOrFetchOpts, deliveryId: string): void {\n const now = Date.now();\n const elapsed = now - webhookState.lastKickoffAt;\n\n if (elapsed >= WEBHOOK_COALESCE_WINDOW_MS) {\n kickoffRefresh(opts, deliveryId);\n return;\n }\n\n // Inside the throttle window.\n if (webhookState.inflight) {\n // A refresh is still running — mark a trailing-edge re-fire so the\n // burst's last delivery isn't lost.\n if (!webhookState.pendingTrailingRefresh) {\n logLine(\n \"info\",\n `webhook/prompts coalesced delivery=${deliveryId} (refresh in flight, trailing scheduled)`,\n );\n }\n webhookState.pendingTrailingRefresh = true;\n return;\n }\n\n // Previous refresh has settled within the window; the cache is fresh\n // enough. No-op.\n logLine(\n \"info\",\n `webhook/prompts coalesced delivery=${deliveryId} (refresh ${Math.round(elapsed)}ms ago, within ${WEBHOOK_COALESCE_WINDOW_MS}ms window)`,\n );\n}\n\nfunction kickoffRefresh(opts: CloneOrFetchOpts, deliveryId: string): void {\n webhookState.lastKickoffAt = Date.now();\n const promise = refreshFn(opts);\n webhookState.inflight = promise;\n logLine(\"info\", `webhook/prompts refresh start delivery=${deliveryId}`);\n\n promise\n .then((result) => {\n logLine(\n \"info\",\n `webhook/prompts refresh ok delivery=${deliveryId} sha=${result.commitSha} at=${result.refreshedAt}`,\n );\n })\n .catch((err: unknown) => {\n // The cache module throws on operator-actionable errors (bad URL,\n // missing deploy key, git failure). We log the message and keep\n // running — the operator can inspect the log, fix the misconfig,\n // and the next webhook delivery will retry.\n const msg = err instanceof Error ? err.message : String(err);\n logLine(\n \"error\",\n `webhook/prompts refresh failed delivery=${deliveryId}: ${msg}`,\n );\n })\n .finally(() => {\n if (webhookState.inflight === promise) {\n webhookState.inflight = null;\n }\n if (webhookState.pendingTrailingRefresh) {\n webhookState.pendingTrailingRefresh = false;\n // Drain the trailing flag with a fresh kickoff, tagged so logs\n // make the burst-collapse pattern visible.\n kickoffRefresh(opts, `${deliveryId}+trailing`);\n }\n });\n}\n\nasync function handleWebhookPrompts(\n req: IncomingMessage,\n res: ServerResponse,\n): Promise<void> {\n // Sanitize the delivery ID before it touches any log line. A missing\n // header is fine (we log `none`); a malformed value is replaced with\n // `malformed` so we still get a record of the request without injecting\n // arbitrary bytes into our log stream.\n const rawDelivery = req.headers[\"x-github-delivery\"];\n const deliveryId =\n typeof rawDelivery === \"string\" && DELIVERY_ID_RE.test(rawDelivery)\n ? rawDelivery\n : rawDelivery\n ? \"malformed\"\n : \"none\";\n\n // Remote address is informational only — used for ops to spot a misconfig\n // (e.g. someone pointed an external scanner at our /webhook/prompts URL).\n // Behind a reverse proxy this will be the proxy IP; that's fine, the\n // proxy's access log carries the original.\n const remoteAddr = req.socket.remoteAddress ?? \"unknown\";\n\n // 503: missing secret. Done BEFORE reading the body — an unconfigured\n // server shouldn't allocate up to 64 KiB for every webhook delivery.\n const secret = process.env[\"STAMP_PROMPTS_WEBHOOK_SECRET\"];\n if (!secret) {\n logLine(\n \"error\",\n `webhook/prompts delivery=${deliveryId} rejected: STAMP_PROMPTS_WEBHOOK_SECRET not configured`,\n );\n sendJson(res, 503, {\n ok: false,\n error: \"webhook_secret_unconfigured\",\n detail:\n \"STAMP_PROMPTS_WEBHOOK_SECRET env var must be set on stamp-server to accept prompt-repo webhooks\",\n });\n return;\n }\n\n // Read the raw bytes — the HMAC is over the wire payload, NOT the parsed\n // JSON, so we must compute the digest on the buffer before any parsing.\n let read: ReadBodyResult;\n try {\n read = await readBody(req, WEBHOOK_MAX_BODY_BYTES);\n } catch (e) {\n logLine(\"warn\", `webhook/prompts read body failed delivery=${deliveryId}: ${(e as Error).message}`);\n sendJson(res, 400, { ok: false, error: \"body_read_failed\" });\n return;\n }\n if (read.tooLarge) {\n // Payload over the cap: don't even attempt HMAC. An attacker who\n // doesn't know the secret can't fabricate a valid signature on a\n // body we never read past 64 KiB; an oversized body from a real\n // sender is an operator problem (someone hooked us into the wrong\n // event) that should surface as 413.\n logLine(\n \"warn\",\n `webhook/prompts delivery=${deliveryId} from=${remoteAddr} body too large (> ${WEBHOOK_MAX_BODY_BYTES} bytes)`,\n );\n sendJson(res, 413, { ok: false, error: \"body_too_large\" });\n return;\n }\n\n // 401: signature mismatch. We do NOT log the supplied signature value —\n // it could be either an honest header from a misconfigured webhook OR an\n // attacker's probe; either way it has no diagnostic value and writing it\n // to logs is just a foot-gun.\n const sigHeader = req.headers[\"x-hub-signature-256\"];\n const sigValue = Array.isArray(sigHeader) ? sigHeader[0] : sigHeader;\n if (!verifyWebhookSignature(read.buf, sigValue, secret)) {\n logLine(\n \"warn\",\n `webhook/prompts delivery=${deliveryId} from=${remoteAddr} rejected: invalid signature`,\n );\n sendJson(res, 401, { ok: false, error: \"invalid_signature\" });\n return;\n }\n\n // 202: signature valid. Schedule the refresh in the background and\n // respond fast — github retries on 5xx OR on response timeout, so we\n // want this round-trip to complete before the refresh's git fetch\n // could possibly stall.\n const opts = buildRefreshOpts();\n if (!opts) {\n // The HMAC validated against our configured secret, so this is our\n // operator's misconfiguration — webhook is wired but no repo URL is\n // set. Returning 503 (not 202) is louder; github will retry, and the\n // operator will see repeated failures in the webhook delivery log\n // until they set STAMP_PROMPTS_REPO_URL. 503 also matches the missing-\n // secret case for operator-actionable misconfig.\n logLine(\n \"error\",\n `webhook/prompts delivery=${deliveryId} rejected: STAMP_PROMPTS_REPO_URL not configured`,\n );\n sendJson(res, 503, {\n ok: false,\n error: \"prompts_repo_url_unconfigured\",\n detail:\n \"STAMP_PROMPTS_REPO_URL env var must be set on stamp-server to accept prompt-repo webhooks\",\n });\n return;\n }\n\n // Send 202 immediately, THEN schedule. setImmediate so the response\n // flushes before the (potentially long) git fetch begins; even though\n // the cache module is non-blocking, the lock-acquire + execFileSync\n // chain inside it can hold the event loop for a few ms on a cold cache.\n sendJson(res, 202, { ok: true });\n logLine(\n \"info\",\n `webhook/prompts delivery=${deliveryId} accepted (signature valid)`,\n );\n\n setImmediate(() => {\n try {\n scheduleWebhookRefresh(opts, deliveryId);\n } catch (e) {\n // scheduleWebhookRefresh's own .catch handles refreshFn rejections.\n // This try/catch covers synchronous throws from the scheduling\n // bookkeeping itself — should be impossible but a crashed listener\n // here would take out invite/accept too.\n logLine(\n \"error\",\n `webhook/prompts schedule error delivery=${deliveryId}: ${(e as Error).message}`,\n );\n }\n });\n}\n\n// ─── Periodic-poll backstop (AGT-376, Phase B) ───────────────────────\n\n/**\n * Default poll interval in seconds. One hour matches the project README's\n * \"optional periodic-poll fallback (every ~hour) as backstop\" line and is\n * the default named in the AGT-376 acceptance criteria. The whole point of\n * the backstop is to recover from a missed webhook within a bounded window\n * without adding meaningful load — github webhook deliveries cover the\n * common case sub-second; the poll only matters when a delivery was\n * missed/delayed.\n *\n * Operators tune via `STAMP_PROMPTS_POLL_INTERVAL_SEC`. Set to `0` to\n * disable polling entirely (e.g. in test deploys where the webhook is\n * the only source and an hourly fetch would clutter logs). Negative or\n * non-integer values fall back to the default.\n */\nexport const DEFAULT_PROMPTS_POLL_INTERVAL_SEC = 3600;\n\n/**\n * Floor on the poll interval. Five seconds matches the webhook-route\n * coalescing window — a poll that fires more often than the webhook can\n * coalesce its own bursts would be pointless overhead. Tests set values\n * below this (via the test-only override) to keep fake-timer runs fast,\n * but production values are clamped.\n */\nconst MIN_PROMPTS_POLL_INTERVAL_SEC = 5;\n\n/**\n * Poll-worker state, module-scoped so it survives across the request\n * handlers without leaking into the request itself. Two pieces:\n *\n * - `handle` — the `setInterval` timer handle, or null when polling is\n * disabled / not yet started. Cleared on `stopPromptsPollWorker`.\n * - `inflight` — set to true while a poll-triggered refresh is awaiting\n * the cache module. Skips the next tick if a previous\n * refresh is still running (defensive — the cache module\n * has its own in-process coalescing, but firing two\n * parallel ticks is wasted work and produces confusing\n * log lines).\n */\ninterface PollWorkerState {\n handle: ReturnType<typeof setInterval> | null;\n inflight: boolean;\n /**\n * Bumped on every poll-triggered refresh attempt (successful or not).\n * Test-only — production code doesn't read this. Lets the test assert\n * \"advancing fake time by N intervals fired the refresh fn N times\"\n * without spelunking the cache module's internal counters.\n */\n tickCount: number;\n}\n\nconst pollState: PollWorkerState = {\n handle: null,\n inflight: false,\n tickCount: 0,\n};\n\n/**\n * Resolve the poll interval from env. Returns 0 when polling should be\n * disabled (env value `\"0\"`, exactly) and a positive integer otherwise.\n * Defensive parsing: a typo'd value falls back to the default rather\n * than silently disabling — only the literal `\"0\"` disables.\n *\n * Exported so operator-docs / diagnostic tools can mirror the same\n * resolution shape without re-parsing the env var.\n */\nexport function resolvePromptsPollIntervalSec(): number {\n const raw = process.env[\"STAMP_PROMPTS_POLL_INTERVAL_SEC\"];\n if (raw === undefined || raw === \"\") {\n return DEFAULT_PROMPTS_POLL_INTERVAL_SEC;\n }\n // Exact \"0\" is the documented opt-out signal.\n if (raw === \"0\") return 0;\n // Require an explicit integer shape. `Number(\" \")` and `Number(\"\\n\")`\n // both return 0, which would otherwise sneak through as \"disable\n // polling\" — silent disable on whitespace is exactly the failure mode\n // the AC explicitly avoids (\"setting interval to 0 prevents fetches\").\n // Operators get the disable behavior ONLY by writing the literal \"0\".\n if (!/^-?\\d+$/.test(raw)) {\n logLine(\n \"warn\",\n `STAMP_PROMPTS_POLL_INTERVAL_SEC=${JSON.stringify(raw)} is not a non-negative integer; falling back to default ${DEFAULT_PROMPTS_POLL_INTERVAL_SEC}s`,\n );\n return DEFAULT_PROMPTS_POLL_INTERVAL_SEC;\n }\n const n = Number(raw);\n // n <= 0 (NOT n < 0): the literal \"0\" exited above, so any other\n // input that parses to a non-positive integer (\"00\", \"000\", \"-5\",\n // \"-0\", etc.) is operator-error territory. Fall back to the default\n // rather than silently disabling — the standards reviewer flagged\n // \"00\" silently disabling polling as an invariant violation; this\n // is the one-character fix that closes it. `!Number.isInteger(n)`\n // is unreachable here (the regex above already rejected non-\n // integers) but kept as belt-and-suspenders in case a future\n // refactor loosens the regex.\n if (!Number.isInteger(n) || n <= 0) {\n logLine(\n \"warn\",\n `STAMP_PROMPTS_POLL_INTERVAL_SEC=${JSON.stringify(raw)} is not a positive integer (and not the literal '0' opt-out); falling back to default ${DEFAULT_PROMPTS_POLL_INTERVAL_SEC}s`,\n );\n return DEFAULT_PROMPTS_POLL_INTERVAL_SEC;\n }\n // Clamp tiny non-zero values to the floor. Operators who want polling\n // disabled use 0 explicitly; values like 1 or 2 are almost certainly\n // a mistake (the cache module's lock file would still serialize them,\n // but the resulting log noise has no diagnostic value).\n if (n > 0 && n < MIN_PROMPTS_POLL_INTERVAL_SEC) {\n logLine(\n \"warn\",\n `STAMP_PROMPTS_POLL_INTERVAL_SEC=${n} is below the floor of ${MIN_PROMPTS_POLL_INTERVAL_SEC}s; clamping to floor`,\n );\n return MIN_PROMPTS_POLL_INTERVAL_SEC;\n }\n return n;\n}\n\n/**\n * Run one poll tick: invoke the shared `refreshFn` (same DI seam the\n * webhook route uses; production = `cloneOrFetchPromptsCache`). Errors are\n * caught and logged — the next tick will retry, so a transient network\n * failure doesn't crash the worker.\n *\n * `inflight` guards against the case where a prior tick's refresh hasn't\n * settled yet (e.g. operator set a 10s interval and the first git fetch\n * over a slow link took 12s). The cache module would coalesce internally,\n * but skipping at this layer avoids the second log line entirely.\n *\n * Exported (`__runPollTickForTests`) so tests can fire a tick without\n * having to drive `setInterval` directly — fake timers + an async tick\n * handler is a known-finicky combination, and a direct invocation keeps\n * each test's intent crisp.\n */\nasync function runPollTick(opts: CloneOrFetchOpts): Promise<void> {\n if (pollState.inflight) {\n logLine(\n \"info\",\n \"prompts-poll: skipping tick — previous refresh still in flight\",\n );\n return;\n }\n pollState.inflight = true;\n pollState.tickCount += 1;\n try {\n const result = await refreshFn(opts);\n logLine(\n \"info\",\n `prompts-poll: refresh ok sha=${result.commitSha} at=${result.refreshedAt}`,\n );\n } catch (err) {\n const msg = err instanceof Error ? err.message : String(err);\n logLine(\"error\", `prompts-poll: refresh failed: ${msg}`);\n } finally {\n pollState.inflight = false;\n }\n}\n\n/**\n * Start the periodic-poll backstop. Idempotent — calling twice without an\n * intervening stop is a no-op (logs a warning so the duplicate call is\n * visible during refactoring but doesn't double-arm the interval).\n *\n * Gating logic per AGT-376 ACs:\n *\n * 1. `STAMP_PROMPTS_REPO_URL` unset → no-op. The whole feature is opt-in;\n * Phase A deployments (bundled prompts at `/etc/stamp/reviewers/`)\n * don't need a polling worker.\n * 2. `STAMP_PROMPTS_POLL_INTERVAL_SEC=0` → no-op. Explicit operator\n * opt-out — typical when the webhook is the only source and an\n * hourly fetch would clutter logs.\n * 3. Otherwise → arm `setInterval` against `runPollTick`. The handle is\n * `unref()`'d so an idle daemon (no traffic, no webhooks) can still\n * exit cleanly under SIGTERM; the listener's `close` callback calls\n * `stopPromptsPollWorker` for symmetric shutdown.\n *\n * The poll worker shares the `refreshFn` DI seam with the webhook route,\n * so tests that override `refreshFn` via `__setRefreshFnForTests` see\n * both surfaces drive the same stub. Production callers get\n * `cloneOrFetchPromptsCache` which has its own lock + coalescing — a poll\n * tick that races a webhook-triggered refresh collapses to a single\n * fetch without further coordination at this layer.\n */\nexport function startPromptsPollWorker(): void {\n if (pollState.handle !== null) {\n logLine(\"warn\", \"prompts-poll: already started — ignoring duplicate start\");\n return;\n }\n\n // AC bullet 4: only run when STAMP_PROMPTS_REPO_URL is set.\n const opts = buildRefreshOpts();\n if (!opts) {\n // Phase A deployment — Phase B feature isn't engaged. Don't even log;\n // the bootstrap binary's silent no-op shape (AGT-375) is the\n // precedent here.\n return;\n }\n\n // AC bullet 2: STAMP_PROMPTS_POLL_INTERVAL_SEC=0 disables.\n const intervalSec = resolvePromptsPollIntervalSec();\n if (intervalSec === 0) {\n logLine(\n \"info\",\n \"prompts-poll: disabled (STAMP_PROMPTS_POLL_INTERVAL_SEC=0)\",\n );\n return;\n }\n\n const intervalMs = intervalSec * 1000;\n const handle = setInterval(() => {\n // Don't propagate the rejection — runPollTick already catches and\n // logs. The void cast keeps eslint/tsc happy about the unhandled\n // promise from an async callback.\n void runPollTick(opts);\n }, intervalMs);\n // unref so the daemon can still exit cleanly when nothing else is\n // keeping the event loop alive (CI, ephemeral test harnesses).\n handle.unref();\n pollState.handle = handle;\n\n logLine(\n \"info\",\n `prompts-poll: started (interval=${intervalSec}s, url=${opts.url}, ref=${opts.ref}, cacheRoot=${opts.cacheRoot})`,\n );\n}\n\n/**\n * Stop the periodic-poll backstop. Safe to call when not started (no-op).\n * Production callers invoke from `server.close()` for clean shutdown;\n * tests call between cases to reset state.\n */\nexport function stopPromptsPollWorker(): void {\n if (pollState.handle === null) return;\n clearInterval(pollState.handle);\n pollState.handle = null;\n pollState.inflight = false;\n}\n\n/**\n * Test-only reset of the poll-worker state. Tests fire back-to-back\n * intervals from one test case to the next, and a stale `tickCount` /\n * `inflight` flag would otherwise leak across cases. Production code\n * never calls this — the daemon starts with a fresh state object.\n */\nexport function __resetPollStateForTests(): void {\n if (pollState.handle !== null) {\n clearInterval(pollState.handle);\n }\n pollState.handle = null;\n pollState.inflight = false;\n pollState.tickCount = 0;\n}\n\n/**\n * Test-only direct fire of one poll tick. Fake timers + an async\n * `setInterval` callback is a known-finicky combination — driving the\n * tick directly keeps each test's intent crisp. Returns the same promise\n * the interval callback would have awaited.\n *\n * Returns `null` when polling would be a no-op (URL unset). Tests that\n * exercise the disabled paths assert on `pollState.tickCount` staying\n * at 0 across `tick()` advances rather than calling this.\n */\nexport async function __runPollTickForTests(): Promise<void | null> {\n const opts = buildRefreshOpts();\n if (!opts) return null;\n await runPollTick(opts);\n return;\n}\n\n/**\n * Test-only introspection of the poll worker's state. Lets tests assert\n * \"the interval was armed\" / \"tickCount bumped after N ticks\" without\n * mutating module-private state. Returns a snapshot, not the live\n * object, so callers can't accidentally mutate it.\n */\nexport function __getPollStateForTests(): {\n armed: boolean;\n inflight: boolean;\n tickCount: number;\n} {\n return {\n armed: pollState.handle !== null,\n inflight: pollState.inflight,\n tickCount: pollState.tickCount,\n };\n}\n\n// ─── Server lifecycle ─────────────────────────────────────────────────\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 if (req.method === \"POST\" && url === \"/webhook/prompts\") {\n void handleWebhookPrompts(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 // Arm the periodic-poll backstop AFTER the listen-success callback\n // — keeps the boot ordering predictable (listener up, then the\n // worker that depends on the same env-var fan-out). The worker is\n // a no-op when STAMP_PROMPTS_REPO_URL is unset, so Phase A\n // deployments see no behavior change.\n startPromptsPollWorker();\n });\n // Symmetric shutdown: when the server closes (operator-driven or test\n // cleanup), stop the poll worker so the interval doesn't leak into\n // the next listener instance. `unref` already lets the process exit;\n // this is belt-and-suspenders for test reuse + future graceful-\n // shutdown supervisors.\n server.once(\"close\", () => {\n stopPromptsPollWorker();\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 * Server-side prompts-cache module (AGT-372 — Phase B foundation).\n *\n * Maintains a local git clone of an external \"reviewer prompts\" repo and\n * resolves `(reviewer, org?, repo?)` → on-disk path. Sits underneath\n * `promptFetch.ts`: `cloneOrFetchPromptsCache` populates the cache directory,\n * `getPromptPath` answers per-request \"which file do I read for this reviewer\n * on this repo?\", and `defaultPromptCacheResolver` (Phase A, untouched in this\n * ticket) reads the bytes.\n *\n * Phase A's resolver mapped one (reviewer) → one path. Phase B layers a\n * per-repo override on top: `<cacheRoot>/<org>/<repo>/<reviewer>.md` if it\n * exists, else fall through to `<cacheRoot>/<reviewer>.md`. The lookup is the\n * ONLY filesystem I/O in `getPromptPath` (`existsSync`); the actual read still\n * happens in `fetchCanonicalPrompt` via `readFileSync`, where ENOENT surfaces\n * as `no_such_file`.\n *\n * --- Why a local clone, not a HiveDB-style direct-write ---\n *\n * Phase A had HiveDB writing prompt bytes directly into `STAMP_PROMPTS_DIR`.\n * Phase B replaces that channel with \"operator pushes to a github prompts\n * repo, github webhook fires, stamp-server pulls from origin.\" The cache is\n * a real git working tree (not a bare clone) so `getPromptPath` can answer\n * with a normal filesystem path, and `fetchCanonicalPrompt` keeps its\n * `readFileSync` shape unchanged.\n *\n * --- Atomic refresh contract ---\n *\n * Both \"clone the cache for the first time\" and \"rebuild from scratch after\n * a fetch failure\" go through the atomic pattern:\n *\n * 1. Remove any stale `<cacheRoot>.tmp` from a previous failed attempt.\n * 2. `git clone <url> <cacheRoot>.tmp` (with deploy key wired via\n * GIT_SSH_COMMAND if `deployKeyPath` is set).\n * 3. `git -C <cacheRoot>.tmp checkout <ref>` then `git -C <cacheRoot>.tmp\n * rev-parse HEAD` — proves the ref resolved AND the tree is checked\n * out, before we commit the swap.\n * 4. If `<cacheRoot>` already exists, rename it to `<cacheRoot>.old`,\n * then rename `.tmp` → `<cacheRoot>`, then `rm -rf <cacheRoot>.old`.\n * POSIX guarantees rename is atomic within the same filesystem; the\n * brief window where neither name exists is bounded by two syscalls.\n *\n * A failure at step 2 or 3 leaves `<cacheRoot>` untouched — the existing\n * (last-known-good) cache stays consistent. The `.tmp` debris is cleaned up\n * at the START of the NEXT call rather than on failure, so debugging a\n * broken fetch can inspect the partial state.\n *\n * The faster in-place path (`git fetch && git reset --hard FETCH_HEAD`\n * inside an already-populated `<cacheRoot>`) is also supported and is the\n * common case for webhook-driven refreshes — but if that path fails for\n * any reason, we fall back to the atomic rebuild rather than leaving a\n * half-fetched tree in place.\n *\n * --- Concurrency model ---\n *\n * Two layers:\n *\n * 1. In-process: a `Map<cacheRoot, Promise<RefreshResult>>` coalesces\n * concurrent calls from the same Node process. The second caller awaits\n * the in-flight promise — no parallel `git fetch`, no parallel rename.\n * 2. Cross-process: a sibling lock file at `<cacheRoot>.refresh.lock`\n * (NOT inside the clone — so the atomic rename doesn't move it) acquired\n * via `O_CREAT | O_EXCL`. Stale-lock stealing after 5 minutes (mtime)\n * handles SIGKILL'd processes that didn't release.\n *\n * Webhook bursts (github sometimes fires multiple deliveries within a few\n * hundred ms on a merge) collapse to one fetch; the second caller sees the\n * first's result.\n *\n * --- Deploy-key + known-hosts wiring ---\n *\n * SSH urls (`git@github.com:owner/repo.git`) need a private key on disk\n * (provisioned by the operator into the stamp-server volume — same posture\n * as `stamp-ensure-repo-key`) and a pinned known-hosts file so\n * `StrictHostKeyChecking=yes` doesn't prompt. The known-hosts file ships in\n * the image at `server/github-known-hosts`; we resolve it relative to this\n * module via `import.meta.url` so dev runs and packaged builds both find it.\n * Tests can override via the `GIT_SSH_KNOWN_HOSTS` env var to point at a\n * fixture.\n *\n * HTTPS urls bypass the key entirely — git's TLS does the host\n * verification. `deployKeyPath` is ignored in that case (but a non-existent\n * key path with an HTTPS url is NOT an error — operators may set both env\n * vars and toggle the url without re-deploying).\n */\n\nimport {\n closeSync,\n existsSync,\n mkdirSync,\n openSync,\n renameSync,\n rmSync,\n statSync,\n} from \"node:fs\";\nimport { execFileSync } from \"node:child_process\";\nimport { fileURLToPath } from \"node:url\";\nimport { dirname, resolve as pathResolve } from \"node:path\";\n\nimport { REVIEWER_NAME_RE } from \"./promptFetch.js\";\n\n// ─── Public types ─────────────────────────────────────────────────────\n\n/**\n * Options for `cloneOrFetchPromptsCache`.\n *\n * - `url` — github URL of the prompts repo. HTTPS or SSH.\n * - `ref` — branch or tag to track (e.g. `\"main\"`).\n * - `cacheRoot` — absolute directory path where the cache lives. The\n * parent dir must exist and be writable; the cache dir\n * itself is created/replaced by this function.\n * - `deployKeyPath` — optional path to a private SSH key for SSH URLs.\n * Ignored for HTTPS URLs. The corresponding pubkey\n * must already be registered as a deploy key on the\n * prompts repo.\n */\nexport interface CloneOrFetchOpts {\n url: string;\n ref: string;\n cacheRoot: string;\n deployKeyPath?: string;\n}\n\n/**\n * Returned by `cloneOrFetchPromptsCache`. `commitSha` is the SHA-1 hex of\n * `HEAD` after the refresh — the operator can compare this against the\n * prompts repo's current `<ref>` SHA to detect missed/delayed webhook\n * deliveries. `refreshedAt` is wall-clock ISO-8601 set at completion.\n */\nexport interface RefreshResult {\n commitSha: string;\n refreshedAt: string;\n}\n\n// ─── Constants / shape validation ─────────────────────────────────────\n\n/**\n * Github org/repo slug shape. Stricter than GitHub itself (which allows a\n * leading dot in obscure cases) but matches every name we'd realistically\n * see in a stamp-reviewers-style repo. Reuses the same character class as\n * `REVIEWER_NAME_RE` deliberately — these names get interpolated into a\n * filesystem path, and one canonical \"safe slug\" definition is easier to\n * reason about than two regexes that almost agree.\n */\nconst ORG_REPO_SLUG_RE = /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,99}$/;\n\n/**\n * Stale-lock threshold. Five minutes is comfortably longer than any healthy\n * clone (sub-second to a few seconds even on a slow link for the typical\n * stamp-reviewers repo) and short enough that a SIGKILL'd stamp-server\n * recovers without operator intervention by the next webhook.\n */\nconst LOCK_STALE_MS = 5 * 60 * 1000;\n\n// ─── Resolve the bundled github known-hosts file ──────────────────────\n\n/**\n * Path to `server/github-known-hosts` resolved relative to this module.\n * Used in `GIT_SSH_COMMAND` for SSH clones so `StrictHostKeyChecking=yes`\n * has something to verify against without falling back to the user's\n * `~/.ssh/known_hosts`. Tests override via `GIT_SSH_KNOWN_HOSTS`.\n *\n * Deferred to a function (rather than a module-level constant) so the\n * `import.meta.url` lookup doesn't run at module load. AGT-375's\n * boot-time bootstrap binary consumes this module from a CJS bundle\n * (tsup `format: \"cjs\"`), where `import.meta.url` is undefined and an\n * eager `fileURLToPath(undefined)` would crash the bundle's entry. The\n * lookup is only ever needed inside `buildGitEnv` for SSH URLs, so\n * deferring is both correct and free; ESM consumers see the identical\n * result the first call computes.\n */\nfunction defaultKnownHostsPath(): string {\n return pathResolve(\n dirname(fileURLToPath(import.meta.url)),\n \"..\",\n \"..\",\n \"server\",\n \"github-known-hosts\",\n );\n}\n\n// ─── In-process coalescing map ────────────────────────────────────────\n\n/**\n * Keyed by `cacheRoot` (the absolute path). Holds the in-flight refresh\n * promise so a second concurrent caller awaits the first's result instead\n * of starting a parallel git operation. Cleared when the promise settles\n * (success or failure) — a failed fetch shouldn't poison subsequent\n * attempts.\n *\n * Map (not Set or WeakMap) because the key is a string and we need to look\n * up by value; Map.delete on settle is the cleanup path.\n */\nconst inflightRefreshes = new Map<string, Promise<RefreshResult>>();\n\n// ─── Public API ───────────────────────────────────────────────────────\n\n/**\n * Clone (first run) or fetch+checkout (subsequent runs) the prompts repo\n * into `cacheRoot`. Idempotent: an already-populated cache for the same\n * `ref` resolves to `{ commitSha, refreshedAt }` after a fast no-op fetch.\n *\n * Coalesces concurrent callers via an in-process map AND a file-level lock\n * — see module header for the layered concurrency model. Atomic refresh:\n * mid-fetch failures leave `<cacheRoot>` intact.\n *\n * Throws (rather than returning a typed error) on any condition the operator\n * needs to fix synchronously: unreadable known-hosts file, unwritable parent\n * dir, missing deploy key, git not in PATH, git clone/fetch failure that the\n * atomic rebuild also failed to recover from. The caller (webhook route /\n * entrypoint / periodic poll) decides how to surface these.\n */\nexport async function cloneOrFetchPromptsCache(\n opts: CloneOrFetchOpts,\n): Promise<RefreshResult> {\n validateOpts(opts);\n const cacheRoot = pathResolve(opts.cacheRoot);\n\n // In-process coalescing: if another caller in this process is already\n // refreshing the same cacheRoot, return that promise. The lock file\n // below catches cross-process races; this catches the (much more common)\n // single-process webhook-burst case.\n const existing = inflightRefreshes.get(cacheRoot);\n if (existing) return existing;\n\n const promise = (async (): Promise<RefreshResult> => {\n const lockPath = `${cacheRoot}.refresh.lock`;\n acquireLock(lockPath);\n try {\n return await refreshInternal({ ...opts, cacheRoot });\n } finally {\n releaseLock(lockPath);\n }\n })();\n\n inflightRefreshes.set(cacheRoot, promise);\n // Clear the map slot when the promise settles, success or failure, so\n // a subsequent caller after the failure gets to retry rather than\n // re-awaiting a rejected promise.\n promise.finally(() => {\n if (inflightRefreshes.get(cacheRoot) === promise) {\n inflightRefreshes.delete(cacheRoot);\n }\n }).catch(() => {\n // Swallow: the rejection is already surfaced via the returned `promise`.\n // This .catch attaches a no-op handler to the `.finally`'d branch so\n // node doesn't log an unhandled rejection warning from the bookkeeping\n // chain. The real handler is whoever awaited the returned `promise`.\n });\n\n return promise;\n}\n\n/**\n * Resolve the on-disk path for a `(reviewer, org?, repo?)` triple.\n *\n * - If `org` AND `repo` are both supplied AND\n * `<cacheRoot>/<org>/<repo>/<reviewer>.md` exists on disk,\n * return that path (per-repo override).\n * - Otherwise return `<cacheRoot>/<reviewer>.md` (default fallback).\n *\n * The fallback path is returned EVEN IF IT ALSO DOESN'T EXIST — existence\n * of the default path is `fetchCanonicalPrompt`'s job to check (it surfaces\n * a clean `no_such_file` error). This function only decides which path to\n * try; the read decides whether the prompt is there.\n *\n * All three inputs are validated against the same regex Phase A uses for\n * reviewer names (re-exported as `REVIEWER_NAME_RE` from `promptFetch.ts`).\n * Org/repo names are validated against a slightly broader slug regex that\n * accepts the dot character (github allows `my.org`).\n *\n * Throws on invalid input — by the time we reach the resolver, the SSH\n * verb has already validated the inputs; a violation here is a caller bug,\n * not an attempted injection.\n */\nexport function getPromptPath(\n cacheRoot: string,\n reviewer: string,\n org?: string,\n repo?: string,\n): string {\n if (!cacheRoot || typeof cacheRoot !== \"string\") {\n throw new Error(\"getPromptPath: cacheRoot must be a non-empty string\");\n }\n if (!REVIEWER_NAME_RE.test(reviewer)) {\n throw new Error(\n `getPromptPath: invalid reviewer name '${reviewer}' (must match ${REVIEWER_NAME_RE.source})`,\n );\n }\n const normalized = cacheRoot.endsWith(\"/\") ? cacheRoot.slice(0, -1) : cacheRoot;\n\n // Per-repo override path. Only consult if BOTH org and repo are present\n // and both pass slug validation. If either is malformed, we fall through\n // to the default path rather than throwing — a malformed (org,repo) tuple\n // from upstream is more likely to be \"this verb call didn't carry repo\n // context\" than \"an attacker forged a slug.\" The default-path read will\n // still error cleanly if the prompt isn't there.\n if (org && repo && ORG_REPO_SLUG_RE.test(org) && ORG_REPO_SLUG_RE.test(repo)) {\n const overridePath = `${normalized}/${org}/${repo}/${reviewer}.md`;\n if (existsSync(overridePath)) {\n return overridePath;\n }\n }\n return `${normalized}/${reviewer}.md`;\n}\n\n// ─── Internals ────────────────────────────────────────────────────────\n\nfunction validateOpts(opts: CloneOrFetchOpts): void {\n if (!opts || typeof opts !== \"object\") {\n throw new Error(\"cloneOrFetchPromptsCache: opts must be an object\");\n }\n if (!opts.url || typeof opts.url !== \"string\") {\n throw new Error(\"cloneOrFetchPromptsCache: url is required\");\n }\n if (!opts.ref || typeof opts.ref !== \"string\") {\n throw new Error(\"cloneOrFetchPromptsCache: ref is required\");\n }\n if (!opts.cacheRoot || typeof opts.cacheRoot !== \"string\") {\n throw new Error(\"cloneOrFetchPromptsCache: cacheRoot is required\");\n }\n // Disallow shell metacharacters in `ref` — even though we use execFileSync\n // (no shell), a refspec like `;rm -rf /` would be passed to git verbatim\n // and git's own parser might do something unexpected on certain refspecs.\n // Branch/tag names allow a wide character set; this is the conservative\n // intersection.\n if (!/^[a-zA-Z0-9][a-zA-Z0-9._/-]{0,200}$/.test(opts.ref)) {\n throw new Error(\n `cloneOrFetchPromptsCache: ref ${JSON.stringify(opts.ref)} contains characters not allowed in a git refspec`,\n );\n }\n}\n\n/**\n * Acquire `<cacheRoot>.refresh.lock` via `O_CREAT | O_EXCL`. If the file\n * exists and is stale (older than `LOCK_STALE_MS`), we steal it. Throws\n * with a clear message if a fresh lock is held — caller should treat that\n * as \"another process is already refreshing\" and retry / coalesce upstream.\n *\n * Lives at the PARENT of `cacheRoot` (sibling, not child) so the atomic\n * rename of `<cacheRoot>.tmp` → `<cacheRoot>` doesn't displace it.\n */\nfunction acquireLock(lockPath: string): void {\n // Make sure the parent directory exists before we try to create the lock\n // file. First-run on a fresh server has neither cacheRoot nor its\n // sibling lock present.\n mkdirSync(dirname(lockPath), { recursive: true });\n\n // Fast path — try exclusive create.\n try {\n const fd = openSync(lockPath, \"wx\");\n closeSync(fd);\n return;\n } catch (err) {\n const e = err as { code?: string };\n if (e.code !== \"EEXIST\") {\n throw new Error(\n `prompts-cache: could not create lock file ${lockPath}: ${(err as Error).message}`,\n );\n }\n }\n\n // EEXIST — check if it's stale.\n let lockStat;\n try {\n lockStat = statSync(lockPath);\n } catch (err) {\n // The file disappeared between openSync and statSync — extremely\n // unlikely race, retry the create once.\n try {\n const fd = openSync(lockPath, \"wx\");\n closeSync(fd);\n return;\n } catch (err2) {\n throw new Error(\n `prompts-cache: lock-file race on ${lockPath}: ${(err2 as Error).message}`,\n );\n }\n }\n\n const age = Date.now() - lockStat.mtimeMs;\n if (age > LOCK_STALE_MS) {\n // Steal the lock — previous holder is dead. `rmSync` then re-create.\n rmSync(lockPath, { force: true });\n const fd = openSync(lockPath, \"wx\");\n closeSync(fd);\n return;\n }\n\n throw new Error(\n `prompts-cache: refresh already in progress (lock ${lockPath} held, ${Math.round(age / 1000)}s old)`,\n );\n}\n\nfunction releaseLock(lockPath: string): void {\n try {\n rmSync(lockPath, { force: true });\n } catch {\n // Best-effort: if the lock is gone for some reason, we don't fail the\n // refresh over it. The next acquire will create afresh.\n }\n}\n\n/**\n * The actual refresh, called under the lock. Two paths:\n *\n * - cacheRoot exists & looks like a git checkout → try in-place fetch\n * (`git fetch` + `git reset --hard FETCH_HEAD`). Cheap, no rename.\n * - cacheRoot missing, or in-place path failed → atomic rebuild via\n * `<cacheRoot>.tmp` → renameSync.\n */\nasync function refreshInternal(\n opts: CloneOrFetchOpts & { cacheRoot: string },\n): Promise<RefreshResult> {\n const { url, ref, cacheRoot, deployKeyPath } = opts;\n const env = buildGitEnv(deployKeyPath);\n const tmpPath = `${cacheRoot}.tmp`;\n\n // Clean any debris from a prior failed attempt up front. We deliberately\n // do NOT clean on failure (see module header) so a broken state can be\n // inspected by the operator before the next refresh wipes it.\n if (existsSync(tmpPath)) {\n rmSync(tmpPath, { recursive: true, force: true });\n }\n\n const cacheIsCheckout = existsSync(cacheRoot) && existsSync(`${cacheRoot}/.git`);\n\n if (cacheIsCheckout) {\n // Force the in-place fetch to use the URL the caller passed, not\n // whatever the .git/config remembers from the original clone. If the\n // operator rotates `STAMP_PROMPTS_REPO_URL` (e.g. HTTPS → SSH after\n // adding a deploy key) the next refresh must honor the new url\n // synchronously rather than continuing to fetch the old one. Also\n // means: if the caller passes a bogus url, the in-place fetch fails\n // here and we fall through to the atomic-rebuild path (which will\n // also fail against the same bogus url, throwing — exactly the\n // behavior the \"mid-fetch failure\" tests expect).\n try {\n runGit(cacheRoot, [\"remote\", \"set-url\", \"origin\", url], env);\n runGit(cacheRoot, [\"fetch\", \"--prune\", \"origin\", ref], env);\n runGit(cacheRoot, [\"checkout\", ref], env);\n runGit(cacheRoot, [\"reset\", \"--hard\", `origin/${ref}`], env);\n const commitSha = runGit(cacheRoot, [\"rev-parse\", \"HEAD\"], env).trim();\n return { commitSha, refreshedAt: new Date().toISOString() };\n } catch (err) {\n // In-place fetch failed (corrupted tree, remote moved, ref renamed,\n // network blip mid-fetch). Don't trust the partial state — fall\n // through to the atomic rebuild. The existing cacheRoot stays in\n // place until step 4 below.\n const reason = err instanceof Error ? err.message : String(err);\n process.stderr.write(\n `prompts-cache: in-place fetch failed (${reason}), falling back to atomic rebuild\\n`,\n );\n }\n }\n\n // Atomic rebuild: clone to .tmp, verify, swap.\n mkdirSync(dirname(cacheRoot), { recursive: true });\n runGit(dirname(cacheRoot), [\"clone\", \"--quiet\", \"--branch\", ref, url, tmpPath], env);\n\n // Belt-and-suspenders: confirm the ref resolved on disk before we commit\n // to the swap. `git clone --branch` would have errored if `ref` didn't\n // exist, but rev-parse catches \"the clone succeeded but the working tree\n // is somehow empty\" — paranoid, cheap.\n const commitSha = runGit(tmpPath, [\"rev-parse\", \"HEAD\"], env).trim();\n if (!/^[0-9a-f]{40}$/.test(commitSha)) {\n throw new Error(\n `prompts-cache: rev-parse HEAD in ${tmpPath} returned non-SHA ${JSON.stringify(commitSha)}`,\n );\n }\n\n // Atomic swap. POSIX rename(2) is atomic within a filesystem; we verified\n // both paths share a parent dir, so they're on the same filesystem by\n // construction.\n if (existsSync(cacheRoot)) {\n const oldPath = `${cacheRoot}.old`;\n if (existsSync(oldPath)) {\n rmSync(oldPath, { recursive: true, force: true });\n }\n renameSync(cacheRoot, oldPath);\n try {\n renameSync(tmpPath, cacheRoot);\n } catch (err) {\n // Restore the old cacheRoot if the second rename somehow fails.\n // Should be impossible on a healthy filesystem — both renames are\n // within the same dir — but if it does happen, the old cache is\n // still recoverable.\n try {\n renameSync(oldPath, cacheRoot);\n } catch {\n // We've now lost the cache. Surface the original error.\n }\n throw err;\n }\n rmSync(oldPath, { recursive: true, force: true });\n } else {\n renameSync(tmpPath, cacheRoot);\n }\n\n return { commitSha, refreshedAt: new Date().toISOString() };\n}\n\n/**\n * Build the env block we pass to git: inherits the parent env, then\n * overlays `GIT_SSH_COMMAND` if we have a deploy key. The known-hosts file\n * resolution lives here so a missing file produces a clear error before\n * git is invoked.\n */\nfunction buildGitEnv(deployKeyPath: string | undefined): NodeJS.ProcessEnv {\n const env = { ...process.env };\n if (!deployKeyPath) return env;\n\n if (!existsSync(deployKeyPath)) {\n throw new Error(\n `prompts-cache: deployKeyPath ${deployKeyPath} does not exist — operator must provision the private SSH key`,\n );\n }\n\n const knownHostsPath = process.env[\"GIT_SSH_KNOWN_HOSTS\"] || defaultKnownHostsPath();\n if (!existsSync(knownHostsPath)) {\n throw new Error(\n `prompts-cache: known-hosts file ${knownHostsPath} does not exist — image build is missing server/github-known-hosts`,\n );\n }\n\n env[\"GIT_SSH_COMMAND\"] = [\n \"ssh\",\n \"-i\",\n quoteForSshCommand(deployKeyPath),\n \"-o\",\n \"StrictHostKeyChecking=yes\",\n \"-o\",\n `UserKnownHostsFile=${quoteForSshCommand(knownHostsPath)}`,\n \"-o\",\n \"IdentitiesOnly=yes\",\n ].join(\" \");\n return env;\n}\n\n/**\n * `GIT_SSH_COMMAND` is parsed by /bin/sh, so paths containing spaces or\n * shell metacharacters would break. The stamp-server image's paths\n * (`/srv/git/...`) never contain such characters, but the dev sandbox or\n * a test fixture might (e.g. `/var/folders/.../T/`). Single-quote and\n * escape any embedded single quotes — same posture as `shell-quote` but\n * inlined to avoid a dependency for one call site.\n */\nfunction quoteForSshCommand(s: string): string {\n return `'${s.replace(/'/g, \"'\\\\''\")}'`;\n}\n\n/**\n * Run `git` in `cwd` with the given args, return stdout. Throws on\n * non-zero exit. stderr is included in the thrown error's message so\n * the caller can log it. Uses `execFileSync` (no shell) — the args\n * array is the only injection surface, and we validate `ref` upstream.\n */\nfunction runGit(cwd: string, args: string[], env: NodeJS.ProcessEnv): string {\n try {\n return execFileSync(\"git\", args, {\n cwd,\n env,\n encoding: \"utf8\",\n stdio: [\"ignore\", \"pipe\", \"pipe\"],\n });\n } catch (err) {\n const e = err as { stderr?: Buffer | string; message?: string };\n const stderr = typeof e.stderr === \"string\" ? e.stderr : e.stderr?.toString(\"utf8\") ?? \"\";\n throw new Error(\n `git ${args.join(\" \")} (cwd=${cwd}) failed: ${e.message ?? String(err)}${stderr ? `\\nstderr: ${stderr.trim()}` : \"\"}`,\n );\n }\n}\n\n","/**\n * Server-side canonical reviewer-prompt fetch — the load-bearing security\n * step of server-attested reviews (stamp 2.x).\n *\n * The trust property of server-attested reviews collapses unless the SERVER\n * (not the client) controls which prompt bytes get fed to the LLM. If the\n * client could supply the prompt the substitution attack returns: an\n * operator passes a permissive prompt to the LLM, but embeds the canonical\n * prompt's hash in the attestation, and the verifier sees a perfectly\n * consistent claim about a real LLM call that bears no relationship to what\n * the model actually read.\n *\n * This module is the choke point. AGT-370 moved the prompt source from\n * a server-side bare git clone to a server-side filesystem cache\n * populated out-of-band (HiveDB writes reviewer prompts directly into\n * `STAMP_PROMPTS_DIR`; see the parallel HiveDB reconfig ticket). The\n * fetch reads `${cacheRoot}/<reviewer>.md` synchronously via\n * `fs.readFileSync` — never via `git show`, never with a caller-\n * controlled fallback, never from anywhere outside the resolver-returned\n * path.\n *\n * The AGT-330 SSH-verb handler (and the future HTTP handler) calls this\n * exactly once per review request and pipes the returned bytes directly\n * into the Anthropic system message; the resulting `prompt_sha256` lives\n * inside the `ApprovalV4` body that the server then signs.\n *\n * --- Why no fallback parameter ---\n *\n * The module's surface deliberately offers no way to pass a substitute\n * prompt, an override path, an extra search root, or a \"use this if the\n * fetch fails\" fallback. Adding any such knob — even one gated behind a\n * dev-only flag — would re-open the substitution attack. The whole point\n * of moving the fetch server-side is that the (reviewer_name) tuple is\n * the ONLY input that determines what the server reads. Anything else,\n * by construction, is forbidden.\n *\n * If the fetch fails for any reason (no such file, filesystem error),\n * this module returns a typed error and the verb handler maps it to a\n * clean SSH response. Falling back to a different prompt is forbidden.\n *\n * --- Routing via injected resolver ---\n *\n * The default Phase-1 resolver maps `(reviewer)` → `${cacheRoot}/<reviewer>.md`.\n * Multi-tenant SaaS deployments inject a custom resolver that translates\n * `(reviewer)` into a tenant-aware path (e.g. `<state>/<tenant-id>/<reviewer>.md`);\n * the rest of this module — file read, error mapping, hashing — stays\n * identical. The resolver is a synchronous pure function returning a\n * string path: no I/O (no `existsSync`, no network) — keeping it pure\n * means the handler can pre-compute the path for logging before we hit\n * the fetch, and test injection becomes trivial. Existence checks\n * happen via the file read itself (an absent file surfaces as\n * `no_such_file`).\n *\n * --- Hash convention ---\n *\n * `FetchedPrompt.sha256` is BARE HEX (no `sha256:` prefix), matching\n * `ApprovalV4.prompt_sha256` in `src/lib/attestationV4.ts`. This is the\n * opposite convention from `src/lib/trustedKeysManifest.ts`, which uses\n * `sha256:<hex>` for KEY fingerprints — different field, different\n * convention, do not conflate. The caller folds this value directly into\n * the approval body before canonical serialization + signing.\n */\n\nimport { readFileSync } from \"node:fs\";\nimport { createHash } from \"node:crypto\";\n\n// Circular-by-name import (prompts-cache.ts re-exports REVIEWER_NAME_RE\n// from this module): safe because `getPromptPath` is only invoked\n// inside the resolver closure at request time, never at module-load\n// time. ESM resolves the binding lazily — both modules' top-level\n// initialization completes before any closure body runs. See AGT-373\n// for the design rationale (single source of truth for path layout\n// lives in prompts-cache.ts).\nimport { getPromptPath } from \"./prompts-cache.js\";\n\n/**\n * Resolves a `(reviewer, org?, repo?)` tuple to the absolute path of\n * the prompt file on this server's filesystem.\n *\n * AGT-373 (Phase B) widened the signature from `(reviewer) => path` to\n * `(reviewer, org?, repo?) => path` so per-repo prompt overrides\n * (`<cacheRoot>/<org>/<repo>/<reviewer>.md`) can take precedence over\n * the cache-root-level default (`<cacheRoot>/<reviewer>.md`) when both\n * exist. `org` and `repo` are optional so existing Phase A callsites\n * that don't carry repo context still compile — a single-arg call\n * gets the fallback path.\n *\n * Synchronous — but NOT strictly pure under AGT-373: the default\n * resolver calls `existsSync` once to decide between the override and\n * the fallback path. Multi-tenant resolvers MAY remain pure if their\n * tenancy model doesn't need a stat. No thrown errors for \"not\n * found\" — an absent path surfaces naturally as `no_such_file` from\n * the read in `fetchCanonicalPrompt`.\n *\n * The default `defaultPromptCacheResolver` delegates to `getPromptPath`\n * from `prompts-cache.ts`, which validates the reviewer name against\n * `REVIEWER_NAME_RE` (the same regex `src/commands/reviewers.ts`\n * enforces) and the org/repo slugs against a slightly broader shape.\n * Multi-tenant resolvers do whatever path layout their tenancy model\n * requires; they MUST validate any input that gets interpolated into\n * a filesystem path.\n */\nexport type PromptResolver = (\n reviewer: string,\n org?: string,\n repo?: string,\n) => string;\n\n/**\n * The successful fetch result. `bytes` is the raw `.md` file content as\n * read from disk — no normalization, no trimming, no line-ending fixes.\n * Whatever is on disk is what the LLM sees and what the hash binds.\n * `sha256` is bare hex (see file-header doc).\n */\nexport interface FetchedPrompt {\n kind: \"ok\";\n bytes: Buffer;\n /** Hex sha256 of `bytes`. Bare — no `sha256:` prefix. Matches the\n * `ApprovalV4.prompt_sha256` convention in `src/lib/attestationV4.ts`. */\n sha256: string;\n}\n\n/**\n * Typed failure mode. Each `kind` maps to a stable client-facing error\n * category; the verb handler translates these to SSH responses.\n *\n * `detail` is server-side diagnostic surface — log it, do NOT surface it\n * verbatim to the caller (filesystem error messages can leak server\n * filesystem layout). The verb handler should respond with a generic\n * \"<kind>: not available\" message and rely on operator-visible logs for\n * the detail.\n *\n * Categories:\n * - `no_such_file` — resolver returned a path; the file does\n * not exist there. The reviewer isn't\n * provisioned in the prompt cache, or the\n * reviewer name is misspelled. AGT-370:\n * this is the dominant failure now that\n * prompts live in a filesystem cache\n * rather than a bare git repo.\n * - `invalid_input` — `reviewer_name` failed shape validation.\n * Caller bug or attempted injection.\n * - `io_error` — filesystem read failed for a reason\n * other than ENOENT (permission denied,\n * I/O error, file too large, etc.).\n * Operator-actionable.\n */\nexport interface PromptFetchError {\n kind: \"no_such_file\" | \"invalid_input\" | \"io_error\";\n detail: string;\n}\n\nexport type PromptFetchResult = FetchedPrompt | PromptFetchError;\n\n// ─── Limits ─────────────────────────────────────────────────────────\n\n/** Hard cap on a single fetched prompt. Reviewer prompts are normally a\n * few KB; a megabyte is already huge. 1 MB gives plenty of headroom for\n * rich prompts without leaving DoS surface open. */\nconst MAX_PROMPT_BYTES = 1024 * 1024;\n\n// ─── Input validation ───────────────────────────────────────────────\n\n/** Reviewer-name shape; mirrors `VALID_REVIEWER_NAME` in\n * `src/commands/reviewers.ts` so a name that round-trips through the\n * client `reviewers add` UI also round-trips through the server fetch.\n *\n * Exported (AGT-372) so the Phase B `prompts-cache` module can reuse the\n * same canonical source rather than redefining it. The regex is the one\n * thing both modules MUST agree on — a divergence would let a name\n * validate at one layer and reject at the other, opening a confused-deputy\n * path. Keep the export; do not inline a copy. */\nexport const REVIEWER_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/;\n\n// ─── Public surface ─────────────────────────────────────────────────\n\n/**\n * Build the default single-tenant resolver. `cacheRoot` is the\n * directory holding the reviewer prompt files (e.g. `/etc/stamp/reviewers`\n * for Phase A, or `/srv/git/.prompts-cache` once Phase B's\n * `STAMP_PROMPTS_REPO_URL` is set).\n *\n * `cacheRoot` is taken at resolver-construction time, not at each call,\n * so the verb handler can build the resolver once at startup from the\n * env-resolved cache root and inject it into every request.\n *\n * AGT-373 (Phase B) widened the returned resolver from\n * `(reviewer) => path` to `(reviewer, org?, repo?) => path` and\n * delegates the path-construction logic to `getPromptPath` from\n * `prompts-cache.ts`. The override-vs-default decision (`<cacheRoot>/<org>/<repo>/<reviewer>.md`\n * when that file exists, else `<cacheRoot>/<reviewer>.md`) lives in\n * `getPromptPath` so the resolver and the prompts-cache module agree\n * on layout by construction. Phase A callsites that omit org/repo get\n * the fallback path — they keep compiling, they keep working.\n *\n * AGT-370: replaces the old `defaultRepoResolver` (which mapped `(org,\n * repo)` to a bare-repo path on disk and then used `git show` to read\n * the prompt at `base_sha`). The bare-repo source forced stamp-server\n * to maintain a clone of every reviewed repo, blocking private/internal\n * repos whose code must never leave its git host. The filesystem-cache\n * source decouples the prompt provisioning channel from the review\n * channel — HiveDB (Phase A) / a github prompts repo via webhook\n * (Phase B, AGT-374) writes canonical prompts into the cache\n * out-of-band.\n */\nexport function defaultPromptCacheResolver(cacheRoot: string): PromptResolver {\n if (!cacheRoot || typeof cacheRoot !== \"string\") {\n throw new Error(\"defaultPromptCacheResolver: cacheRoot must be a non-empty string\");\n }\n // The closure captures `cacheRoot` and forwards every call to\n // `getPromptPath`, which owns the trailing-slash normalization, the\n // reviewer-name regex check, the override-vs-fallback decision, and\n // the org/repo slug validation. One source of truth for \"which file\n // do I read for this triple?\" lives in `prompts-cache.ts`; this\n // function only wires the resolver's cacheRoot in.\n return (reviewer: string, org?: string, repo?: string): string =>\n getPromptPath(cacheRoot, reviewer, org, repo);\n}\n\n/**\n * Fetch the canonical reviewer prompt for `reviewerName` from the\n * server's local filesystem cache via the injected `promptResolver`.\n * Returns a discriminated-union result: callers branch on `result.kind`\n * — `\"ok\"` carries the bytes + hash, any other value is a\n * `PromptFetchError`.\n *\n * The flow:\n * 1. Validate `reviewerName` shape.\n * 2. Resolve the prompt path via the injected resolver.\n * 3. `fs.readFileSync(path)` — captures the bytes; ENOENT maps to\n * `no_such_file`, any other error maps to `io_error`.\n * 4. Hash the bytes with SHA-256 (bare hex).\n * 5. Return `{ kind: \"ok\", bytes, sha256 }`.\n *\n * Buffered output is bounded by `MAX_PROMPT_BYTES`. Reviewer prompts in\n * the wild are kilobytes; the cap defends against a future runaway\n * provisioning script that drops a multi-megabyte file at the prompt\n * path.\n *\n * AGT-370 + AGT-373 note: `base_sha` is NOT a prompt-resolution input\n * (server is manifest-blind and base-sha-blind for prompts). It still\n * flows over the SSH wire to populate `ApprovalV4.base_sha`. `org` and\n * `repo` ARE now resolution inputs (AGT-373 Phase B) so the default\n * resolver can pick a `<cacheRoot>/<org>/<repo>/<reviewer>.md`\n * override when one exists. Both are optional — Phase A callsites\n * that omit them keep working, falling back to `<cacheRoot>/<reviewer>.md`.\n */\nexport async function fetchCanonicalPrompt(\n promptResolver: PromptResolver,\n reviewerName: string,\n org?: string,\n repo?: string,\n): Promise<PromptFetchResult> {\n if (!REVIEWER_NAME_RE.test(reviewerName)) {\n return {\n kind: \"invalid_input\",\n detail: `reviewerName must match ${REVIEWER_NAME_RE.source} (got ${JSON.stringify(reviewerName)})`,\n };\n }\n\n let promptPath: string;\n try {\n // Forward org/repo to the resolver. The default resolver\n // (defaultPromptCacheResolver → getPromptPath) consults the\n // override path `<cacheRoot>/<org>/<repo>/<reviewer>.md` if both\n // are present and the file exists, else falls back to\n // `<cacheRoot>/<reviewer>.md`. Custom (multi-tenant) resolvers\n // may ignore the extra args entirely — the wider signature is\n // covariant so older one-arg resolvers stay assignable.\n promptPath = promptResolver(reviewerName, org, repo);\n } catch (err) {\n return {\n kind: \"invalid_input\",\n detail: `resolver rejected (reviewer=${JSON.stringify(reviewerName)}): ${\n err instanceof Error ? err.message : String(err)\n }`,\n };\n }\n\n let bytes: Buffer;\n try {\n bytes = readFileSync(promptPath);\n } catch (err) {\n const e = err as { code?: string; message?: string };\n if (e.code === \"ENOENT\") {\n return {\n kind: \"no_such_file\",\n detail: `prompt cache miss: ${promptPath} does not exist (reviewer=${JSON.stringify(reviewerName)})`,\n };\n }\n return {\n kind: \"io_error\",\n detail: `readFileSync(${promptPath}) failed: ${e.message ?? String(err)}`,\n };\n }\n if (bytes.length > MAX_PROMPT_BYTES) {\n return {\n kind: \"io_error\",\n detail: `prompt file ${promptPath} is ${bytes.length} bytes — exceeds cap (${MAX_PROMPT_BYTES} bytes). Operator: shrink the prompt or raise the cap.`,\n };\n }\n\n const sha256 = createHash(\"sha256\").update(bytes).digest(\"hex\");\n return { kind: \"ok\", bytes, sha256 };\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_crypto","import_node_fs","import_node_path","import_node_crypto","import_node_fs","import_node_path","import_node_fs","import_node_crypto","pathResolve","commitSha","resolve","port"]}
|