{"version":3,"sources":["../../src/lib/serverDb.ts","../../src/lib/paths.ts","../../src/lib/sshKeys.ts","../../src/server/seed-users.ts"],"sourcesContent":["/**\n * Membership sqlite for the stamp server.\n *\n * Lives on the persistent volume at /srv/git/.stamp-state/users.db. Holds:\n *   - users: SSH pubkey → role (owner/admin/member), optional stamp signing\n *     pubkey, source provenance (env / bootstrap / invite / manual)\n *   - invites: single-use, time-bounded tokens an admin mints to onboard\n *     a teammate (phase 2)\n *   - peer_review_patches: one row per broadcast PR (AGT-427)\n *   - peer_review_events: append-only event log for peer-agentic reviews\n *     (AGT-427)\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  // Each SSH verb is its own process with its own connection to the same DB\n  // file. busy_timeout makes a concurrent BEGIN IMMEDIATE (e.g. the AGT-420\n  // rate-limit transaction) WAIT for the write lock instead of failing fast\n  // with SQLITE_BUSY — the held region is microseconds, so writers serialize\n  // cleanly under contention rather than erroring out.\n  db.exec(\"PRAGMA busy_timeout = 5000\");\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    -- AGT-420: per-(caller, action) token-bucket rate-limit state. Lazy\n    -- refill (computed on read), so no cron is needed. Brand-new table;\n    -- additive, never ALTERed here.\n    CREATE TABLE IF NOT EXISTS rate_limits (\n      subject_id  INTEGER NOT NULL,\n      action      TEXT NOT NULL,\n      tokens      REAL NOT NULL,\n      updated_at  INTEGER NOT NULL,\n      PRIMARY KEY (subject_id, action)\n    );\n\n    -- AGT-420: server-side verdict cache, symmetric to the client's local\n    -- reviews cache (lib/db.ts findCachedVerdict). Keyed on the same triple\n    -- (reviewer, diff_sha256, prompt_sha256); one row per key (upserted).\n    CREATE TABLE IF NOT EXISTS server_verdicts (\n      reviewer      TEXT NOT NULL,\n      diff_sha256   TEXT NOT NULL,\n      prompt_sha256 TEXT NOT NULL,\n      verdict       TEXT NOT NULL,\n      prose         TEXT NOT NULL,\n      created_at    INTEGER NOT NULL,\n      PRIMARY KEY (reviewer, diff_sha256, prompt_sha256)\n    );\n\n    -- AGT-427: peer-agentic review patches. One row per broadcast PR.\n    -- seat_N_holder is the fingerprint of the agent that claimed the seat;\n    -- seat_N_claimed_at is the unix-second timestamp of the last heartbeat\n    -- (or original claim), used by the seat-TTL sweep (future ticket).\n    CREATE TABLE IF NOT EXISTS peer_review_patches (\n      patch_id          TEXT PRIMARY KEY,\n      requested_by_fp   TEXT NOT NULL,\n      base_sha          TEXT NOT NULL,\n      head_sha          TEXT NOT NULL,\n      repo              TEXT NOT NULL,\n      broadcast_at      INTEGER NOT NULL,\n      seat_1_holder     TEXT,\n      seat_2_holder     TEXT,\n      seat_1_claimed_at INTEGER,\n      seat_2_claimed_at INTEGER\n    );\n\n    CREATE INDEX IF NOT EXISTS idx_pr_patches_repo\n      ON peer_review_patches(repo);\n\n    -- AGT-427: append-only event log for peer-agentic review lifecycle.\n    -- payload is the full event JSON for future audit/replay.\n    -- NOTE: no row-level TTL sweep is scoped to this ticket; the\n    -- event log is unbounded in this release. A future ticket adds a sweep.\n    CREATE TABLE IF NOT EXISTS peer_review_events (\n      id          INTEGER PRIMARY KEY AUTOINCREMENT,\n      patch_id    TEXT NOT NULL,\n      event_type  TEXT NOT NULL,\n      actor_fp    TEXT NOT NULL,\n      occurred_at INTEGER NOT NULL,\n      payload     TEXT NOT NULL\n    );\n\n    CREATE INDEX IF NOT EXISTS idx_pr_events_patch_id\n      ON peer_review_events(patch_id);\n  `);\n\n  // AGT-431: additive boot-safe column — pr_url on peer_review_patches.\n  // SQLite lacks ADD COLUMN IF NOT EXISTS, so we guard with PRAGMA table_info.\n  // Pre-existing rows keep NULL pr_url; new pr-opened broadcasts populate it.\n  const cols = db.prepare(\"PRAGMA table_info(peer_review_patches)\").all() as Array<{ name: string }>;\n  const hasPrUrl = cols.some((c) => c.name === \"pr_url\");\n  if (!hasPrUrl) {\n    db.exec(\"ALTER TABLE peer_review_patches ADD COLUMN pr_url TEXT\");\n  }\n}\n\n// ─── Peer-review patch helpers (AGT-427) ────────────────────────────\n\nexport interface PeerReviewPatchRow {\n  patch_id: string;\n  requested_by_fp: string;\n  base_sha: string;\n  head_sha: string;\n  repo: string;\n  broadcast_at: number;\n  /** PR URL stored at broadcast time (AGT-431). NULL for rows predating AGT-431. */\n  pr_url: string | null;\n  seat_1_holder: string | null;\n  seat_2_holder: string | null;\n  seat_1_claimed_at: number | null;\n  seat_2_claimed_at: number | null;\n}\n\nexport interface InsertPatchInput {\n  patch_id: string;\n  requested_by_fp: string;\n  base_sha: string;\n  head_sha: string;\n  repo: string;\n  /** PR URL to persist alongside the patch row (AGT-431). */\n  pr_url?: string | null;\n  broadcast_at?: number;\n}\n\n/** Insert a new peer-review patch row. Throws on duplicate patch_id. */\nexport function insertPatch(\n  db: DatabaseSync,\n  input: InsertPatchInput,\n  now: number = Date.now(),\n): void {\n  db.prepare(\n    `INSERT INTO peer_review_patches\n       (patch_id, requested_by_fp, base_sha, head_sha, repo, broadcast_at, pr_url)\n     VALUES (?, ?, ?, ?, ?, ?, ?)`,\n  ).run(\n    input.patch_id,\n    input.requested_by_fp,\n    input.base_sha,\n    input.head_sha,\n    input.repo,\n    input.broadcast_at ?? Math.floor(now / 1000),\n    input.pr_url ?? null,\n  );\n}\n\n/** Fetch a patch row by patch_id. Returns null when not found. */\nexport function findPatch(\n  db: DatabaseSync,\n  patch_id: string,\n): PeerReviewPatchRow | null {\n  const row = db\n    .prepare(\n      `SELECT patch_id, requested_by_fp, base_sha, head_sha, repo,\n              broadcast_at, pr_url, seat_1_holder, seat_2_holder,\n              seat_1_claimed_at, seat_2_claimed_at\n       FROM peer_review_patches WHERE patch_id = ?`,\n    )\n    .get(patch_id) as PeerReviewPatchRow | undefined;\n  return row\n    ? {\n        patch_id: row.patch_id,\n        requested_by_fp: row.requested_by_fp,\n        base_sha: row.base_sha,\n        head_sha: row.head_sha,\n        repo: row.repo,\n        broadcast_at: row.broadcast_at,\n        pr_url: row.pr_url ?? null,\n        seat_1_holder: row.seat_1_holder ?? null,\n        seat_2_holder: row.seat_2_holder ?? null,\n        seat_1_claimed_at: row.seat_1_claimed_at ?? null,\n        seat_2_claimed_at: row.seat_2_claimed_at ?? null,\n      }\n    : null;\n}\n\n// ─── Peer-review event query helpers (peer-events poll worker) ───────\n\n/**\n * Shape of a raw row returned by `findPeerReviewEventsAfter`.\n * `payload` is the raw JSON string; callers parse it.\n */\nexport interface PeerReviewEventRow {\n  id: number;\n  patch_id: string;\n  event_type: string;\n  actor_fp: string;\n  occurred_at: number;\n  payload: string;\n  /** `repo` field extracted from the peer_review_patches join. */\n  repo: string;\n}\n\n/**\n * Fetch up to `limit` peer_review_events rows with `id > afterId`, ordered\n * by id ascending. The `repo` field is joined from peer_review_patches so the\n * poll worker can filter by org without re-querying. Returns an empty array\n * when there are no new events. Safe to call from a read-only DB connection.\n */\nexport function findPeerReviewEventsAfter(\n  db: DatabaseSync,\n  afterId: number,\n  limit: number = 500,\n): PeerReviewEventRow[] {\n  const rows = db.prepare(`\n    SELECT e.id, e.patch_id, e.event_type, e.actor_fp, e.occurred_at, e.payload,\n           p.repo\n    FROM peer_review_events e\n    JOIN peer_review_patches p ON p.patch_id = e.patch_id\n    WHERE e.id > ?\n    ORDER BY e.id ASC\n    LIMIT ?\n  `).all(afterId, limit) as unknown as PeerReviewEventRow[];\n  // node:sqlite returns null-prototype rows; rebuild as plain objects.\n  return rows.map((r) => ({\n    id: r.id,\n    patch_id: r.patch_id,\n    event_type: r.event_type,\n    actor_fp: r.actor_fp,\n    occurred_at: r.occurred_at,\n    payload: r.payload,\n    repo: r.repo,\n  }));\n}\n\n/**\n * Return the current MAX(id) from peer_review_events, or 0 when the table\n * is empty. Used by the poll worker to initialize its cursor so it does not\n * replay history that existed before the worker started.\n */\nexport function maxPeerReviewEventId(db: DatabaseSync): number {\n  const row = db.prepare(\n    `SELECT COALESCE(MAX(id), 0) AS max_id FROM peer_review_events`,\n  ).get() as { max_id: number };\n  return row.max_id;\n}\n\n/** Append a row to the peer_review_events table. */\nexport function appendEvent(\n  db: DatabaseSync,\n  patch_id: string,\n  event_type: string,\n  actor_fp: string,\n  payload: object,\n  now: number = Date.now(),\n): void {\n  db.prepare(\n    `INSERT INTO peer_review_events (patch_id, event_type, actor_fp, occurred_at, payload)\n     VALUES (?, ?, ?, ?, ?)`,\n  ).run(\n    patch_id,\n    event_type,\n    actor_fp,\n    Math.floor(now / 1000),\n    JSON.stringify(payload),\n  );\n}\n\nexport type ClaimSeatError =\n  | { ok: false; error: \"patch_not_found\" }\n  | { ok: false; error: \"author_cannot_claim_own_pr\" }\n  | { ok: false; error: \"already_holds_other_seat\" }\n  | { ok: false; error: \"seats_full\" };\n\nexport type ClaimSeatResult =\n  | ClaimSeatError\n  | { ok: true; seat: 1 | 2 };\n\n/**\n * Atomically claim a seat on a peer-review patch (AGT-427).\n *\n * Runs entirely inside a BEGIN IMMEDIATE transaction (the write lock is\n * acquired at the SELECT) so concurrent callers from different SSH-verb\n * processes serialize rather than racing past the seat-count check.\n * Caller must NOT already be inside a transaction.\n *\n * Rejection conditions (in evaluation order):\n *   1. patch not found → patch_not_found\n *   2. claimant fp === requested_by_fp → author_cannot_claim_own_pr\n *   3. claimant fp already holds the OTHER seat → already_holds_other_seat\n *   4. both seats occupied → seats_full\n *   5. success → returns { ok: true, seat: 1 | 2 }\n */\nexport function claimSeatTx(\n  db: DatabaseSync,\n  patch_id: string,\n  claimant_fp: string,\n  now: number = Date.now(),\n): ClaimSeatResult {\n  db.exec(\"BEGIN IMMEDIATE\");\n  try {\n    const row = db\n      .prepare(\n        `SELECT requested_by_fp, seat_1_holder, seat_2_holder\n         FROM peer_review_patches WHERE patch_id = ?`,\n      )\n      .get(patch_id) as\n      | { requested_by_fp: string; seat_1_holder: string | null; seat_2_holder: string | null }\n      | undefined;\n\n    if (!row) {\n      db.exec(\"ROLLBACK\");\n      return { ok: false, error: \"patch_not_found\" };\n    }\n\n    if (claimant_fp === row.requested_by_fp) {\n      db.exec(\"ROLLBACK\");\n      return { ok: false, error: \"author_cannot_claim_own_pr\" };\n    }\n\n    const s1 = row.seat_1_holder ?? null;\n    const s2 = row.seat_2_holder ?? null;\n\n    // Self-collision: already holds the other seat\n    if ((s1 === claimant_fp) || (s2 === claimant_fp)) {\n      db.exec(\"ROLLBACK\");\n      return { ok: false, error: \"already_holds_other_seat\" };\n    }\n\n    const nowSec = Math.floor(now / 1000);\n\n    if (!s1) {\n      db.prepare(\n        `UPDATE peer_review_patches\n         SET seat_1_holder = ?, seat_1_claimed_at = ?\n         WHERE patch_id = ?`,\n      ).run(claimant_fp, nowSec, patch_id);\n      db.exec(\"COMMIT\");\n      return { ok: true, seat: 1 };\n    }\n\n    if (!s2) {\n      db.prepare(\n        `UPDATE peer_review_patches\n         SET seat_2_holder = ?, seat_2_claimed_at = ?\n         WHERE patch_id = ?`,\n      ).run(claimant_fp, nowSec, patch_id);\n      db.exec(\"COMMIT\");\n      return { ok: true, seat: 2 };\n    }\n\n    db.exec(\"ROLLBACK\");\n    return { ok: false, error: \"seats_full\" };\n  } catch (err) {\n    try { db.exec(\"ROLLBACK\"); } catch { /* already rolled back */ }\n    throw err;\n  }\n}\n\n/** Release a held seat (clear the column). Returns true when a seat was\n *  cleared; false when the claimant holds no seat on this patch. */\nexport function releaseSeat(\n  db: DatabaseSync,\n  patch_id: string,\n  claimant_fp: string,\n): boolean {\n  // No transaction needed: each SSH-verb process is the sole writer for this\n  // patch when it reaches release (seats serialize on claim, not release).\n  const row = db\n    .prepare(\n      `SELECT seat_1_holder, seat_2_holder FROM peer_review_patches WHERE patch_id = ?`,\n    )\n    .get(patch_id) as\n    | { seat_1_holder: string | null; seat_2_holder: string | null }\n    | undefined;\n\n  if (!row) return false;\n\n  if (row.seat_1_holder === claimant_fp) {\n    db.prepare(\n      `UPDATE peer_review_patches SET seat_1_holder = NULL, seat_1_claimed_at = NULL WHERE patch_id = ?`,\n    ).run(patch_id);\n    return true;\n  }\n\n  if (row.seat_2_holder === claimant_fp) {\n    db.prepare(\n      `UPDATE peer_review_patches SET seat_2_holder = NULL, seat_2_claimed_at = NULL WHERE patch_id = ?`,\n    ).run(patch_id);\n    return true;\n  }\n\n  return false;\n}\n\n/** Refresh the seat_N_claimed_at timestamp for a heartbeat. Returns the\n *  seat number (1 or 2) that was refreshed; null when the claimant holds\n *  no seat on this patch (404 case). */\nexport function touchHeartbeat(\n  db: DatabaseSync,\n  patch_id: string,\n  claimant_fp: string,\n  now: number = Date.now(),\n): 1 | 2 | null {\n  const row = db\n    .prepare(\n      `SELECT seat_1_holder, seat_2_holder FROM peer_review_patches WHERE patch_id = ?`,\n    )\n    .get(patch_id) as\n    | { seat_1_holder: string | null; seat_2_holder: string | null }\n    | undefined;\n\n  if (!row) return null;\n\n  const nowSec = Math.floor(now / 1000);\n\n  if (row.seat_1_holder === claimant_fp) {\n    db.prepare(\n      `UPDATE peer_review_patches SET seat_1_claimed_at = ? WHERE patch_id = ?`,\n    ).run(nowSec, patch_id);\n    return 1;\n  }\n\n  if (row.seat_2_holder === claimant_fp) {\n    db.prepare(\n      `UPDATE peer_review_patches SET seat_2_claimed_at = ? WHERE patch_id = ?`,\n    ).run(nowSec, patch_id);\n    return 2;\n  }\n\n  return null;\n}\n\n/**\n * Sweep expired seats from `peer_review_patches` (AGT-454, G3).\n *\n * Clears `seat_N_holder` / `seat_N_claimed_at` for any seat whose last\n * heartbeat timestamp is older than `ttlSeconds` seconds. This bounds\n * seat-squat attacks where a member claims both seats and never releases\n * them — the TTL ensures they are freed automatically.\n *\n * Safe to run at any cadence; skips seats that are already NULL.\n * Returns the number of individual seat columns cleared (0–2× patches swept).\n *\n * `now` is injectable for deterministic tests.\n */\nexport function sweepExpiredSeats(\n  db: DatabaseSync,\n  ttlSeconds: number,\n  now: number = Date.now(),\n): number {\n  const cutoffSec = Math.floor(now / 1000) - ttlSeconds;\n  let cleared = 0;\n\n  // Clear seat_1_holder on rows where seat 1 is held but the heartbeat expired.\n  const r1 = db.prepare(\n    `UPDATE peer_review_patches\n     SET seat_1_holder = NULL, seat_1_claimed_at = NULL\n     WHERE seat_1_holder IS NOT NULL\n       AND seat_1_claimed_at IS NOT NULL\n       AND seat_1_claimed_at < ?`,\n  ).run(cutoffSec) as { changes: number };\n  cleared += r1.changes;\n\n  // Clear seat_2_holder on rows where seat 2 is held but the heartbeat expired.\n  const r2 = db.prepare(\n    `UPDATE peer_review_patches\n     SET seat_2_holder = NULL, seat_2_claimed_at = NULL\n     WHERE seat_2_holder IS NOT NULL\n       AND seat_2_claimed_at IS NOT NULL\n       AND seat_2_claimed_at < ?`,\n  ).run(cutoffSec) as { changes: number };\n  cleared += r2.changes;\n\n  return cleared;\n}\n\n/**\n * Token-bucket rate limit keyed on `(subjectId, action)` (AGT-420). Lazy\n * refill: tokens accrue at `capPerHour/3600` per second since the last\n * touch, capped at `capPerHour`. Consumes one token and returns true when\n * allowed, false when the bucket is empty. Always persists the recomputed\n * tokens + timestamp so the accounting is correct across calls. `now` is\n * injectable for deterministic tests.\n */\nexport function checkAndConsumeToken(\n  db: DatabaseSync,\n  subjectId: number,\n  action: string,\n  capPerHour: number,\n  now: number = Date.now(),\n): boolean {\n  const nowSec = Math.floor(now / 1000);\n  // The read-modify-write MUST be atomic: each SSH verb is its own process\n  // with its own connection, so without a transaction two concurrent callers\n  // can both SELECT tokens=1, both decide allowed, and both write 0 — letting\n  // a flood exceed the cap by ~1 per racing pair (the exact spend-amplification\n  // this bounds). BEGIN IMMEDIATE takes the write lock at the SELECT, so\n  // concurrent callers serialize (busy_timeout makes them wait, not error).\n  // Caller must NOT already be inside a transaction. AGT-420 security review.\n  db.exec(\"BEGIN IMMEDIATE\");\n  try {\n    const row = db\n      .prepare(\n        `SELECT tokens, updated_at FROM rate_limits WHERE subject_id = ? AND action = ?`,\n      )\n      .get(subjectId, action) as\n      | { tokens: number; updated_at: number }\n      | undefined;\n\n    let tokens: number;\n    if (!row) {\n      tokens = capPerHour; // first call for this subject/action: full bucket\n    } else {\n      const elapsed = Math.max(0, nowSec - row.updated_at);\n      tokens = Math.min(capPerHour, row.tokens + elapsed * (capPerHour / 3600));\n    }\n\n    const allowed = tokens >= 1;\n    if (allowed) tokens -= 1;\n\n    db.prepare(\n      `INSERT INTO rate_limits (subject_id, action, tokens, updated_at)\n         VALUES (?, ?, ?, ?)\n       ON CONFLICT(subject_id, action)\n         DO UPDATE SET tokens = excluded.tokens, updated_at = excluded.updated_at`,\n    ).run(subjectId, action, tokens, nowSec);\n\n    db.exec(\"COMMIT\");\n    return allowed;\n  } catch (err) {\n    try {\n      db.exec(\"ROLLBACK\");\n    } catch {\n      // already rolled back / no active txn — nothing to do\n    }\n    throw err;\n  }\n}\n\nexport interface CachedServerVerdict {\n  verdict: string;\n  prose: string;\n}\n\n/** Look up a cached server verdict for an identical (reviewer, diff, prompt)\n * triple (AGT-420). Mirrors lib/db.ts findCachedVerdict. */\nexport function findCachedServerVerdict(\n  db: DatabaseSync,\n  reviewer: string,\n  diff_sha256: string,\n  prompt_sha256: string,\n): CachedServerVerdict | null {\n  const row = db\n    .prepare(\n      `SELECT verdict, prose FROM server_verdicts\n        WHERE reviewer = ? AND diff_sha256 = ? AND prompt_sha256 = ?`,\n    )\n    .get(reviewer, diff_sha256, prompt_sha256) as\n    | CachedServerVerdict\n    | undefined;\n  // Reconstruct as a plain object — node:sqlite `.get()` returns a\n  // null-prototype row, which trips deepStrictEqual and is awkward for\n  // callers. Return a clean { verdict, prose } or null.\n  return row ? { verdict: row.verdict, prose: row.prose } : null;\n}\n\n/** Persist a server verdict for future cache hits (AGT-420). Upsert: the\n * latest successful evaluation for a triple wins. Callers MUST NOT record\n * an API-error-path verdict — only a real model response. */\nexport function recordServerVerdict(\n  db: DatabaseSync,\n  reviewer: string,\n  diff_sha256: string,\n  prompt_sha256: string,\n  verdict: string,\n  prose: string,\n): void {\n  db.prepare(\n    `INSERT INTO server_verdicts (reviewer, diff_sha256, prompt_sha256, verdict, prose, created_at)\n       VALUES (?, ?, ?, ?, ?, ?)\n     ON CONFLICT(reviewer, diff_sha256, prompt_sha256)\n       DO UPDATE SET verdict = excluded.verdict, prose = excluded.prose, created_at = excluded.created_at`,\n  ).run(\n    reviewer,\n    diff_sha256,\n    prompt_sha256,\n    verdict,\n    prose,\n    Math.floor(Date.now() / 1000),\n  );\n}\n\n/** Defensive positive-int env reader: bad/absent value → default, never\n * throws (a typo must not crash the boot/request path — AGT-420). */\nfunction positiveIntEnv(name: string, def: number): number {\n  const raw = process.env[name];\n  if (!raw) return def;\n  const n = Number(raw);\n  return Number.isInteger(n) && n > 0 ? n : def;\n}\n\n/** Per-hour review cap by role (AGT-420). `member` (broadly granted by\n * single-use invite — the spend-amplification blast radius) uses\n * MAX_REVIEWS_PER_HOUR (default 30); admin/owner get 5× headroom so the\n * operator isn't throttled while a compromised elevated key stays bounded. */\nexport function resolveReviewRateCap(role: Role): number {\n  const base = positiveIntEnv(\"MAX_REVIEWS_PER_HOUR\", 30);\n  return role === \"member\" ? base : base * 5;\n}\n\n/** Per-admin invite cap (AGT-420). MAX_INVITES_PER_HOUR, default 10. */\nexport function resolveInviteRateCap(): number {\n  return positiveIntEnv(\"MAX_INVITES_PER_HOUR\", 10);\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\n/**\n * Look up an enrolled user by their stamp (Ed25519) public key in SPKI PEM\n * form. Used by the SSE `/peer/events` authorization step: a key that signed a\n * valid challenge is only authorized if it belongs to an enrolled user.\n *\n * PEM whitespace is normalized on both sides of the compare so that a key sent\n * with CRLF line endings, a trailing newline, or no trailing newline still\n * matches the canonical form stored at enrollment time. The base64 body is\n * what carries the identity; surrounding whitespace is not significant.\n */\nexport function findUserByStampPubkey(\n  db: DatabaseSync,\n  stamp_pubkey_pem: string,\n): UserRow | null {\n  const target = normalizePemWhitespace(stamp_pubkey_pem);\n  if (!target) return 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 stamp_pubkey IS NOT NULL`,\n  );\n  const rows = stmt.all() as unknown as UserRow[];\n  for (const row of rows) {\n    if (row.stamp_pubkey && normalizePemWhitespace(row.stamp_pubkey) === target) {\n      return row;\n    }\n  }\n  return null;\n}\n\n/** Collapse PEM whitespace (CRLF→LF, strip blank lines, trim) for comparison. */\nfunction normalizePemWhitespace(pem: string): string {\n  return pem\n    .replace(/\\r\\n/g, \"\\n\")\n    .split(\"\\n\")\n    .map((line) => line.trim())\n    .filter((line) => line.length > 0)\n    .join(\"\\n\");\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 * Stamp `users.last_seen_at` (unix seconds) for a user on every\n * authenticated invocation (AGT-422). The column already existed in the\n * schema but was never written; this is the writer. `now` is injectable for\n * tests. Call from the writable verb paths (stamp-review / mint-invite /\n * users-cli) — NOT the read-only per-handshake AuthorizedKeysCommand\n * resolver, which fires far more often and opens the DB read-only.\n */\nexport function touchLastSeen(\n  db: DatabaseSync,\n  userId: number,\n  now: number = Date.now(),\n): void {\n  db.prepare(`UPDATE users SET last_seen_at = ? WHERE id = ?`).run(\n    Math.floor(now / 1000),\n    userId,\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\n/**\n * Directory that holds verifying keys for reviewer-manifest signature\n * verification (`.stamp/verifying-keys/<fingerprint>.pub`). This is a\n * SEPARATE trust plane from `.stamp/trusted-keys/` (merge-signing trust)\n * — the two directories carry distinct authorities (Option B, AGT-113).\n */\nexport function stampVerifyingKeysDir(repoRoot: string): string {\n  return join(repoRoot, \".stamp\", \"verifying-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/** Root of the user's stamp home directory (`~/.stamp`). */\nexport function userStampDir(): string {\n  return join(homedir(), \".stamp\");\n}\n\n/** Path to `~/.stamp/peer-watch.md` (operator triage rules). */\nexport function peerWatchPath(): string {\n  return join(homedir(), \".stamp\", \"peer-watch.md\");\n}\n\n/** Path to `~/.stamp/peer-watch.log` (triage triplet log). `STAMP_PEER_WATCH_LOG` overrides — set in `tests/setup-env.ts` so tests never write to the user's real log (#48). */\nexport function peerWatchLogPath(): string {\n  return process.env.STAMP_PEER_WATCH_LOG ?? join(homedir(), \".stamp\", \"peer-watch.log\");\n}\n\n/** Directory containing named peer-review prompt files (`~/.stamp/personal/peers/`). */\nexport function peersDir(): string {\n  return join(homedir(), \".stamp\", \"personal\", \"peers\");\n}\n\n/** Directory for saved peer-review drafts (`~/.stamp/drafts/`). */\nexport function draftsDir(): string {\n  return join(homedir(), \".stamp\", \"drafts\");\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 * The comment-stripped wire form: `<algorithm> <base64(keyBlob)>` (AGT-422).\n * An OpenSSH pubkey line's trailing comment is decorative — conventionally\n * `firstname.lastname@laptop`, i.e. PII — and is NOT used by sshd for key\n * matching (sshd decodes and compares the blob). Persisting this form drops\n * the PII without affecting auth or the fingerprint. The re-encoded base64\n * is canonical (parseSshPubkey already round-trip-validates it), so the\n * decoded blob — and thus the sshd match — is byte-identical.\n */\nexport function sshPubkeyBody(pk: SshPubkey): string {\n  return `${pk.algorithm} ${pk.keyBlob.toString(\"base64\")}`;\n}\n\n/**\n * Content-addressed, PII-free default short_name: `user-<8 hex of the\n * keyBlob's sha256>` (AGT-422). Deterministic + collision-resistant;\n * replaces the comment-slug default so real names / hostnames don't land in\n * a table every enrolled user can read via `stamp users list`. A human name\n * is set only when an operator explicitly runs `stamp users set-name`.\n */\nexport function contentAddressedShortName(pk: SshPubkey): string {\n  return `user-${createHash(\"sha256\").update(pk.keyBlob).digest(\"hex\").slice(0, 8)}`;\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 * Boot-time entrypoint: synchronize SSH pubkeys from the legacy\n * AUTHORIZED_KEYS env var into the membership sqlite.\n *\n * Runs once per container boot, as root, from /entrypoint.sh — BEFORE\n * sshd is exec'd. Idempotent: keys already in the DB are not re-inserted,\n * and no role/short_name on existing rows is mutated. Operators who add\n * a new line to AUTHORIZED_KEYS on a redeploy will see that key picked up\n * here; operators who REMOVE a line are unaffected by this script (the\n * already-imported row stays — phase 3 surfaces a remove command).\n *\n * Why every env-imported key becomes admin (not member): under the legacy\n * model, anyone in AUTHORIZED_KEYS had full server access — push, pull,\n * merge. Importing them as members would silently downgrade their merge\n * rights. Importing them as admins preserves the existing trust posture\n * exactly. The operator can demote anyone they want via phase 3's\n * `stamp server users demote` once that ships.\n *\n * The OPERATOR_PUB_KEY env var is the operator's stamp SIGNING pubkey (PEM)\n * — distinct from their SSH pubkey. It is NOT seeded into the users table\n * here; it continues to flow through its existing path (/etc/stamp/operator.pub\n * is written by entrypoint.sh and consumed by new-stamp-repo as the seed\n * trusted signer). Phase 3 will surface a self-promote escape hatch so the\n * first operator can elevate themselves from admin to owner once they're\n * authenticated.\n */\n\nimport { openServerDb, suggestUniqueShortName, upsertUserByFingerprint } from \"../lib/serverDb.js\";\nimport {\n  contentAddressedShortName,\n  parseSshPubkeyList,\n  sshPubkeyBody,\n} from \"../lib/sshKeys.js\";\n\nconst AUTHORIZED_KEYS = process.env[\"AUTHORIZED_KEYS\"] ?? \"\";\n\nfunction main(): void {\n  if (AUTHORIZED_KEYS.trim().length === 0) {\n    console.log(\"stamp-seed-users: AUTHORIZED_KEYS env var unset or empty; nothing to sync\");\n    return;\n  }\n\n  const { pubkeys, errors } = parseSshPubkeyList(AUTHORIZED_KEYS);\n\n  for (const err of errors) {\n    console.error(\n      `stamp-seed-users: ignoring malformed AUTHORIZED_KEYS line ${err.lineNumber}: ${err.error}`,\n    );\n  }\n\n  if (pubkeys.length === 0) {\n    console.log(\"stamp-seed-users: AUTHORIZED_KEYS parsed to 0 valid pubkeys; nothing to sync\");\n    return;\n  }\n\n  const db = openServerDb();\n\n  let imported = 0;\n  let skipped = 0;\n  try {\n    for (let i = 0; i < pubkeys.length; i++) {\n      const pk = pubkeys[i]!;\n      // AGT-422: default to a content-addressed, PII-free short_name and\n      // persist the comment-stripped key body (the comment is decorative\n      // PII; sshd matches on the blob, not the comment). A human name comes\n      // only from an explicit `stamp users set-name`.\n      const short_name = suggestUniqueShortName(db, contentAddressedShortName(pk));\n      const result = upsertUserByFingerprint(db, {\n        short_name,\n        ssh_pubkey: sshPubkeyBody(pk),\n        ssh_fp: pk.fingerprint,\n        role: \"admin\",\n        source: \"env\",\n      });\n      if (result.created) imported++;\n      else skipped++;\n    }\n  } finally {\n    db.close();\n  }\n\n  console.log(\n    `stamp-seed-users: imported=${imported} already-present=${skipped} ` +\n      `from AUTHORIZED_KEYS (${pubkeys.length} valid lines)`,\n  );\n}\n\nmain();\n"],"mappings":"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","names":["import_node_fs","import_node_path"]}