{"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 *\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 * 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 { parseSshPubkeyList } from \"../lib/sshKeys.js\";\n\nconst AUTHORIZED_KEYS = process.env[\"AUTHORIZED_KEYS\"] ?? \"\";\n\nfunction deriveShortName(comment: string, index: number): string {\n  // SSH key comments are often \"user@host\" — extract a usable slug. Fall\n  // back to \"env-key-N\" for keys with empty/junk comments.\n  const trimmed = comment.trim();\n  if (trimmed.length === 0) return `env-key-${index + 1}`;\n  // Replace any char that isn't [A-Za-z0-9._-] with a dash. Keeps short_name\n  // friendly in CLI output without losing identifying info.\n  const slug = trimmed.replace(/[^A-Za-z0-9._-]/g, \"-\").replace(/^-+|-+$/g, \"\");\n  if (slug.length === 0) return `env-key-${index + 1}`;\n  return slug;\n}\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      const desired = deriveShortName(pk.comment, i);\n      const short_name = suggestUniqueShortName(db, desired);\n      const result = upsertUserByFingerprint(db, {\n        short_name,\n        ssh_pubkey: pk.full,\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"]}