@openthink/stamp 1.3.1 → 1.5.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.
@@ -0,0 +1,247 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ // src/lib/serverDb.ts
5
+ var import_node_fs2 = require("fs");
6
+ var import_node_sqlite = require("node:sqlite");
7
+ var import_node_path2 = require("path");
8
+
9
+ // src/lib/paths.ts
10
+ var import_node_fs = require("fs");
11
+ var import_node_os = require("os");
12
+ var import_node_path = require("path");
13
+ function ensureDir(path, mode = 493) {
14
+ if (!(0, import_node_fs.existsSync)(path)) {
15
+ (0, import_node_fs.mkdirSync)(path, { recursive: true, mode });
16
+ }
17
+ }
18
+
19
+ // src/lib/serverDb.ts
20
+ var DEFAULT_SERVER_DB_PATH = "/srv/git/.stamp-state/users.db";
21
+ function resolveServerDbPath(explicit) {
22
+ if (explicit) return explicit;
23
+ const envPath = process.env["STAMP_SERVER_DB_PATH"];
24
+ if (envPath && envPath.length > 0) return envPath;
25
+ return DEFAULT_SERVER_DB_PATH;
26
+ }
27
+ function openServerDb(opts = {}) {
28
+ const path = resolveServerDbPath(opts.path);
29
+ const readOnly = opts.readOnly ?? false;
30
+ if (!readOnly) {
31
+ const dir = (0, import_node_path2.dirname)(path);
32
+ ensureDir(dir, 488);
33
+ if (!opts.skipChmod) {
34
+ (0, import_node_fs2.chmodSync)(dir, 488);
35
+ }
36
+ }
37
+ const db = new import_node_sqlite.DatabaseSync(path, { readOnly });
38
+ if (!readOnly) {
39
+ db.exec("PRAGMA foreign_keys = ON");
40
+ initSchema(db);
41
+ if (!opts.skipChmod && (0, import_node_fs2.existsSync)(path)) {
42
+ (0, import_node_fs2.chmodSync)(path, 432);
43
+ }
44
+ }
45
+ return db;
46
+ }
47
+ function initSchema(db) {
48
+ db.exec(`
49
+ CREATE TABLE IF NOT EXISTS users (
50
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
51
+ short_name TEXT NOT NULL UNIQUE,
52
+ ssh_pubkey TEXT NOT NULL,
53
+ ssh_fp TEXT NOT NULL UNIQUE,
54
+ stamp_pubkey TEXT,
55
+ role TEXT NOT NULL CHECK (role IN ('owner','admin','member')),
56
+ source TEXT NOT NULL DEFAULT 'invite' CHECK (source IN ('env','bootstrap','invite','manual')),
57
+ invited_by INTEGER REFERENCES users(id),
58
+ created_at INTEGER NOT NULL,
59
+ last_seen_at INTEGER
60
+ );
61
+
62
+ CREATE INDEX IF NOT EXISTS idx_users_ssh_fp ON users(ssh_fp);
63
+
64
+ CREATE TABLE IF NOT EXISTS invites (
65
+ token TEXT PRIMARY KEY,
66
+ role TEXT NOT NULL CHECK (role IN ('admin','member')),
67
+ invited_by INTEGER NOT NULL REFERENCES users(id),
68
+ created_at INTEGER NOT NULL,
69
+ expires_at INTEGER NOT NULL,
70
+ consumed_at INTEGER,
71
+ consumed_by INTEGER REFERENCES users(id)
72
+ );
73
+
74
+ CREATE INDEX IF NOT EXISTS idx_invites_expires ON invites(expires_at);
75
+ `);
76
+ }
77
+ function insertUser(db, input) {
78
+ const stmt = db.prepare(
79
+ `INSERT INTO users (short_name, ssh_pubkey, ssh_fp, stamp_pubkey, role, source, invited_by, created_at)
80
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
81
+ );
82
+ const result = stmt.run(
83
+ input.short_name,
84
+ input.ssh_pubkey,
85
+ input.ssh_fp,
86
+ input.stamp_pubkey ?? null,
87
+ input.role,
88
+ input.source,
89
+ input.invited_by ?? null,
90
+ Math.floor(Date.now() / 1e3)
91
+ );
92
+ return Number(result.lastInsertRowid);
93
+ }
94
+ function upsertUserByFingerprint(db, input) {
95
+ const existing = findUserBySshFingerprint(db, input.ssh_fp);
96
+ if (existing) return { id: existing.id, created: false };
97
+ const id = insertUser(db, input);
98
+ return { id, created: true };
99
+ }
100
+ function findUserBySshFingerprint(db, ssh_fp) {
101
+ const stmt = db.prepare(
102
+ `SELECT id, short_name, ssh_pubkey, ssh_fp, stamp_pubkey, role, source,
103
+ invited_by, created_at, last_seen_at
104
+ FROM users WHERE ssh_fp = ?`
105
+ );
106
+ const row = stmt.get(ssh_fp);
107
+ return row ?? null;
108
+ }
109
+ function findUserByShortName(db, short_name) {
110
+ const stmt = db.prepare(
111
+ `SELECT id, short_name, ssh_pubkey, ssh_fp, stamp_pubkey, role, source,
112
+ invited_by, created_at, last_seen_at
113
+ FROM users WHERE short_name = ?`
114
+ );
115
+ const row = stmt.get(short_name);
116
+ return row ?? null;
117
+ }
118
+ function suggestUniqueShortName(db, desired) {
119
+ if (!findUserByShortName(db, desired)) return desired;
120
+ for (let i = 2; i < 1e4; i++) {
121
+ const candidate = `${desired}-${i}`;
122
+ if (!findUserByShortName(db, candidate)) return candidate;
123
+ }
124
+ throw new Error(
125
+ `could not find a unique short_name for "${desired}" after 10000 attempts`
126
+ );
127
+ }
128
+
129
+ // src/lib/sshKeys.ts
130
+ var import_node_crypto = require("crypto");
131
+ var ALLOWED_ALGOS = /* @__PURE__ */ new Set([
132
+ "ssh-ed25519",
133
+ "ssh-rsa",
134
+ "ecdsa-sha2-nistp256",
135
+ "ecdsa-sha2-nistp384",
136
+ "ecdsa-sha2-nistp521"
137
+ ]);
138
+ function parseSshPubkey(line) {
139
+ const trimmed = line.trim();
140
+ if (trimmed.length === 0) {
141
+ throw new Error("ssh pubkey line is empty");
142
+ }
143
+ if (trimmed.startsWith("#")) {
144
+ throw new Error("ssh pubkey line is a comment");
145
+ }
146
+ const parts = trimmed.split(/\s+/);
147
+ if (parts.length < 2) {
148
+ throw new Error(
149
+ "ssh pubkey line must have at least <algorithm> <base64> tokens"
150
+ );
151
+ }
152
+ const [algorithm, b64, ...rest] = parts;
153
+ if (!ALLOWED_ALGOS.has(algorithm)) {
154
+ throw new Error(`unsupported ssh pubkey algorithm: ${algorithm}`);
155
+ }
156
+ const keyBlob = Buffer.from(b64, "base64");
157
+ if (keyBlob.length === 0) {
158
+ throw new Error("ssh pubkey base64 blob is empty");
159
+ }
160
+ if (keyBlob.toString("base64").replace(/=+$/, "") !== b64.replace(/=+$/, "")) {
161
+ throw new Error("ssh pubkey base64 blob has trailing junk");
162
+ }
163
+ return {
164
+ algorithm,
165
+ keyBlob,
166
+ comment: rest.join(" "),
167
+ full: trimmed,
168
+ fingerprint: sshFingerprintFromBlob(keyBlob)
169
+ };
170
+ }
171
+ function sshFingerprintFromBlob(keyBlob) {
172
+ const hash = (0, import_node_crypto.createHash)("sha256").update(keyBlob).digest();
173
+ const b64 = hash.toString("base64").replace(/=+$/, "");
174
+ return `SHA256:${b64}`;
175
+ }
176
+ function parseSshPubkeyList(blob) {
177
+ const pubkeys = [];
178
+ const errors = [];
179
+ const lines = blob.split("\n");
180
+ for (let i = 0; i < lines.length; i++) {
181
+ const raw = lines[i] ?? "";
182
+ const stripped = raw.trim();
183
+ if (stripped.length === 0 || stripped.startsWith("#")) continue;
184
+ try {
185
+ pubkeys.push(parseSshPubkey(stripped));
186
+ } catch (e) {
187
+ errors.push({
188
+ lineNumber: i + 1,
189
+ line: stripped,
190
+ error: e.message
191
+ });
192
+ }
193
+ }
194
+ return { pubkeys, errors };
195
+ }
196
+
197
+ // src/server/seed-users.ts
198
+ var AUTHORIZED_KEYS = process.env["AUTHORIZED_KEYS"] ?? "";
199
+ function deriveShortName(comment, index) {
200
+ const trimmed = comment.trim();
201
+ if (trimmed.length === 0) return `env-key-${index + 1}`;
202
+ const slug = trimmed.replace(/[^A-Za-z0-9._-]/g, "-").replace(/^-+|-+$/g, "");
203
+ if (slug.length === 0) return `env-key-${index + 1}`;
204
+ return slug;
205
+ }
206
+ function main() {
207
+ if (AUTHORIZED_KEYS.trim().length === 0) {
208
+ console.log("stamp-seed-users: AUTHORIZED_KEYS env var unset or empty; nothing to sync");
209
+ return;
210
+ }
211
+ const { pubkeys, errors } = parseSshPubkeyList(AUTHORIZED_KEYS);
212
+ for (const err of errors) {
213
+ console.error(
214
+ `stamp-seed-users: ignoring malformed AUTHORIZED_KEYS line ${err.lineNumber}: ${err.error}`
215
+ );
216
+ }
217
+ if (pubkeys.length === 0) {
218
+ console.log("stamp-seed-users: AUTHORIZED_KEYS parsed to 0 valid pubkeys; nothing to sync");
219
+ return;
220
+ }
221
+ const db = openServerDb();
222
+ let imported = 0;
223
+ let skipped = 0;
224
+ try {
225
+ for (let i = 0; i < pubkeys.length; i++) {
226
+ const pk = pubkeys[i];
227
+ const desired = deriveShortName(pk.comment, i);
228
+ const short_name = suggestUniqueShortName(db, desired);
229
+ const result = upsertUserByFingerprint(db, {
230
+ short_name,
231
+ ssh_pubkey: pk.full,
232
+ ssh_fp: pk.fingerprint,
233
+ role: "admin",
234
+ source: "env"
235
+ });
236
+ if (result.created) imported++;
237
+ else skipped++;
238
+ }
239
+ } finally {
240
+ db.close();
241
+ }
242
+ console.log(
243
+ `stamp-seed-users: imported=${imported} already-present=${skipped} from AUTHORIZED_KEYS (${pubkeys.length} valid lines)`
244
+ );
245
+ }
246
+ main();
247
+ //# sourceMappingURL=seed-users.cjs.map
@@ -0,0 +1 @@
1
+ {"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, 0o750);\n if (!opts.skipChmod) {\n // ensureDir no-ops on an existing directory, so this explicit\n // chmod is what tightens perms on a redeploy where the dir was\n // created at a looser mode by an earlier image version.\n chmodSync(dir, 0o750);\n }\n }\n\n const db = new DatabaseSync(path, { readOnly });\n\n if (!readOnly) {\n db.exec(\"PRAGMA foreign_keys = ON\");\n initSchema(db);\n if (!opts.skipChmod && existsSync(path)) {\n // root:git 0660. The HTTP server (git user) writes new user rows\n // on invite-accept; the AuthorizedKeysCommand resolver also runs\n // as git but opens readOnly:true so the write bit is dormant on\n // its path. Chown is the operator's responsibility (entrypoint.sh\n // sets root:git after each boot); we only set the mode bits.\n //\n // Callers running as the git user (mint-invite, http-server) must\n // pass skipChmod:true — only the file owner can chmod on Linux,\n // and entrypoint.sh has already tightened perms by the time those\n // callers run.\n chmodSync(path, 0o660);\n }\n }\n\n return db;\n}\n\nfunction initSchema(db: DatabaseSync): void {\n db.exec(`\n CREATE TABLE IF NOT EXISTS users (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n short_name TEXT NOT NULL UNIQUE,\n ssh_pubkey TEXT NOT NULL,\n ssh_fp TEXT NOT NULL UNIQUE,\n stamp_pubkey TEXT,\n role TEXT NOT NULL CHECK (role IN ('owner','admin','member')),\n source TEXT NOT NULL DEFAULT 'invite' CHECK (source IN ('env','bootstrap','invite','manual')),\n invited_by INTEGER REFERENCES users(id),\n created_at INTEGER NOT NULL,\n last_seen_at INTEGER\n );\n\n CREATE INDEX IF NOT EXISTS idx_users_ssh_fp ON users(ssh_fp);\n\n CREATE TABLE IF NOT EXISTS invites (\n token TEXT PRIMARY KEY,\n role TEXT NOT NULL CHECK (role IN ('admin','member')),\n invited_by INTEGER NOT NULL REFERENCES users(id),\n created_at INTEGER NOT NULL,\n expires_at INTEGER NOT NULL,\n consumed_at INTEGER,\n consumed_by INTEGER REFERENCES users(id)\n );\n\n CREATE INDEX IF NOT EXISTS idx_invites_expires ON invites(expires_at);\n `);\n}\n\nexport interface InsertUserInput {\n short_name: string;\n ssh_pubkey: string;\n ssh_fp: string;\n stamp_pubkey?: string | null;\n role: Role;\n source: UserSource;\n invited_by?: number | null;\n}\n\n/** Insert a user. Throws if short_name or ssh_fp collide. */\nexport function insertUser(db: DatabaseSync, input: InsertUserInput): number {\n const stmt = db.prepare(\n `INSERT INTO users (short_name, ssh_pubkey, ssh_fp, stamp_pubkey, role, source, invited_by, created_at)\n VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,\n );\n const result = stmt.run(\n input.short_name,\n input.ssh_pubkey,\n input.ssh_fp,\n input.stamp_pubkey ?? null,\n input.role,\n input.source,\n input.invited_by ?? null,\n Math.floor(Date.now() / 1000),\n );\n return Number(result.lastInsertRowid);\n}\n\n/**\n * Idempotent insert keyed on ssh_fp. Returns the row id (newly-inserted or\n * pre-existing) and a `created` flag. Does NOT mutate role/short_name of an\n * existing row — the env-sync path runs on every boot, and we don't want\n * a manual admin demotion in the DB to be silently re-promoted by an\n * env-var entry that's still hanging around.\n *\n * If the caller's proposed short_name collides with an existing row that\n * has a DIFFERENT fingerprint, this throws. The seed-users entrypoint\n * handles that by appending a numeric suffix.\n */\nexport function upsertUserByFingerprint(\n db: DatabaseSync,\n input: InsertUserInput,\n): { id: number; created: boolean } {\n const existing = findUserBySshFingerprint(db, input.ssh_fp);\n if (existing) return { id: existing.id, created: false };\n const id = insertUser(db, input);\n return { id, created: true };\n}\n\nexport function findUserBySshFingerprint(\n db: DatabaseSync,\n ssh_fp: string,\n): UserRow | null {\n const stmt = db.prepare(\n `SELECT id, short_name, ssh_pubkey, ssh_fp, stamp_pubkey, role, source,\n invited_by, created_at, last_seen_at\n FROM users WHERE ssh_fp = ?`,\n );\n const row = stmt.get(ssh_fp) as UserRow | undefined;\n return row ?? null;\n}\n\nexport function findUserByShortName(\n db: DatabaseSync,\n short_name: string,\n): UserRow | null {\n const stmt = db.prepare(\n `SELECT id, short_name, ssh_pubkey, ssh_fp, stamp_pubkey, role, source,\n invited_by, created_at, last_seen_at\n FROM users WHERE short_name = ?`,\n );\n const row = stmt.get(short_name) as UserRow | undefined;\n return row ?? null;\n}\n\nexport function listUsers(db: DatabaseSync): UserRow[] {\n const stmt = db.prepare(\n `SELECT id, short_name, ssh_pubkey, ssh_fp, stamp_pubkey, role, source,\n invited_by, created_at, last_seen_at\n FROM users\n ORDER BY id`,\n );\n return stmt.all() as unknown as UserRow[];\n}\n\nexport function countByRole(db: DatabaseSync, role: Role): number {\n const stmt = db.prepare(`SELECT COUNT(*) AS n FROM users WHERE role = ?`);\n const row = stmt.get(role) as { n: number };\n return row.n;\n}\n\n/**\n * Generate a short_name that doesn't collide with any existing row. If\n * `desired` is free, returns it; otherwise appends `-2`, `-3`, ... until\n * a free slot is found. Used by the env-sync path where the proposed\n * short_name is derived from the SSH key's comment (often \"user@host\"),\n * which can collide if two keys share the same comment.\n */\nexport function suggestUniqueShortName(\n db: DatabaseSync,\n desired: string,\n): string {\n if (!findUserByShortName(db, desired)) return desired;\n for (let i = 2; i < 10000; i++) {\n const candidate = `${desired}-${i}`;\n if (!findUserByShortName(db, candidate)) return candidate;\n }\n throw new Error(\n `could not find a unique short_name for \"${desired}\" after 10000 attempts`,\n );\n}\n","import { existsSync, mkdirSync, readFileSync, statSync } from \"node:fs\";\nimport { homedir } from \"node:os\";\nimport { dirname, isAbsolute, join, resolve } from \"node:path\";\n\nexport function findRepoRoot(startFrom: string = process.cwd()): string {\n let current = resolve(startFrom);\n while (true) {\n if (existsSync(join(current, \".git\"))) return current;\n const parent = dirname(current);\n if (parent === current) {\n throw new Error(\n `not inside a git repository (searched up from ${startFrom})`,\n );\n }\n current = parent;\n }\n}\n\nexport function stampConfigDir(repoRoot: string): string {\n return join(repoRoot, \".stamp\");\n}\n\nexport function stampReviewersDir(repoRoot: string): string {\n return join(repoRoot, \".stamp\", \"reviewers\");\n}\n\nexport function stampTrustedKeysDir(repoRoot: string): string {\n return join(repoRoot, \".stamp\", \"trusted-keys\");\n}\n\nexport function stampConfigFile(repoRoot: string): string {\n return join(repoRoot, \".stamp\", \"config.yml\");\n}\n\nexport function stampStateDbPath(repoRoot: string): string {\n return join(gitCommonDir(repoRoot), \"stamp\", \"state.db\");\n}\n\n/**\n * Marker file that records \"we have shown the LLM data-flow notice in this\n * repo at least once.\" Lives next to state.db under the git common dir so\n * it's per-repo (not per-worktree, not committed).\n */\nexport function stampLlmNoticeMarkerPath(repoRoot: string): string {\n return join(gitCommonDir(repoRoot), \"stamp\", \"llm-notice-shown\");\n}\n\n/**\n * Resolve the git common directory for `repoRoot`. For a normal checkout this\n * is `<repoRoot>/.git`; for a worktree, `<repoRoot>/.git` is a *file* of the\n * form `gitdir: <path>` and the real common dir lives at `<gitdir>/commondir`\n * (a path relative to gitdir, typically `../..`). Mirrors `git rev-parse\n * --git-common-dir` without spawning git.\n *\n * State that should be shared across every worktree of one repository (review\n * verdicts, the per-machine sqlite db) lives under this common dir, so callers\n * resolve their paths through here rather than hard-coding `<repoRoot>/.git`.\n */\nexport function gitCommonDir(repoRoot: string): string {\n const dotGit = join(repoRoot, \".git\");\n const st = statSync(dotGit);\n if (st.isDirectory()) return dotGit;\n\n // Worktree (or submodule): `.git` is a file. Parse the `gitdir:` line, then\n // follow the `commondir` pointer from there. Submodules have no `commondir`,\n // so the gitdir itself is the writable common dir — fall through to that.\n const contents = readFileSync(dotGit, \"utf8\");\n const match = contents.match(/^gitdir:\\s*(.+)$/m);\n if (!match || !match[1]) {\n throw new Error(\n `expected '.git' at ${repoRoot} to be a directory or a 'gitdir:' pointer file, got: ${contents.slice(0, 120)}`,\n );\n }\n const gitdirRaw = match[1].trim();\n const gitdir = isAbsolute(gitdirRaw) ? gitdirRaw : resolve(repoRoot, gitdirRaw);\n\n const commondirPath = join(gitdir, \"commondir\");\n if (!existsSync(commondirPath)) return gitdir;\n const commondirRaw = readFileSync(commondirPath, \"utf8\").trim();\n return isAbsolute(commondirRaw) ? commondirRaw : resolve(gitdir, commondirRaw);\n}\n\nexport function userKeysDir(): string {\n return join(homedir(), \".stamp\", \"keys\");\n}\n\n/**\n * Per-user stamp-server config. Holds {host, port, user, repo_root_prefix}\n * so commands like `stamp provision` can reach the operator's stamp server\n * without making the agent guess at SSH endpoints.\n */\nexport function userServerConfigPath(): string {\n return join(homedir(), \".stamp\", \"server.yml\");\n}\n\n/**\n * Per-user stamp config. Today holds reviewer-model selections; structured\n * as a top-level object so future per-user knobs (telemetry sinks, default\n * timeouts, etc.) can land alongside without renaming the file. Lives\n * separately from per-repo `.stamp/config.yml` because cost/speed is\n * operator infrastructure rather than committed review policy — different\n * operators on the same repo are free to pick different models without\n * a merge-conflict over preference, and this file is intentionally\n * EXCLUDED from the v3 reviewer attestation hash chain.\n */\nexport function userConfigPath(): string {\n return join(homedir(), \".stamp\", \"config.yml\");\n}\n\nexport function ensureDir(path: string, mode = 0o755): void {\n if (!existsSync(path)) {\n mkdirSync(path, { recursive: true, mode });\n }\n}\n\nexport function isFile(path: string): boolean {\n try {\n return statSync(path).isFile();\n } catch {\n return false;\n }\n}\n","/**\n * Parse OpenSSH-format public keys and compute their SHA256 fingerprints\n * in the exact format sshd emits via the `%f` format specifier passed to\n * AuthorizedKeysCommand. That format is `SHA256:<base64-no-padding>`,\n * distinct from the `sha256:<hex>` form used elsewhere in stamp for stamp\n * signing keys (PEM/SPKI). Both formats exist; this module is the SSH\n * side only.\n *\n * The lookup keyed on this fingerprint is the load-bearing path for\n * sshd-based authentication of users stored in the membership sqlite —\n * any drift between this fingerprint and what sshd computes breaks\n * every connection. Test coverage pins the format to a known OpenSSH\n * fixture so the next regression here is loud.\n */\n\nimport { createHash } from \"node:crypto\";\n\nexport interface SshPubkey {\n /** Key algorithm token, e.g. \"ssh-ed25519\", \"ecdsa-sha2-nistp256\". */\n algorithm: string;\n /** Base64-decoded key blob (the bytes between the algorithm token and\n * the trailing comment in a public-key line). */\n keyBlob: Buffer;\n /** Trailing comment, typically \"user@host\". May be empty. */\n comment: string;\n /** The original single-line representation, trimmed of leading/trailing\n * whitespace. Stored verbatim in the membership DB so the value sshd\n * later prints back via AuthorizedKeysCommand is bit-identical to what\n * the operator submitted. */\n full: string;\n /** OpenSSH-style fingerprint: \"SHA256:<base64-no-padding>\". Matches the\n * `%f` value sshd passes to AuthorizedKeysCommand. */\n fingerprint: string;\n}\n\nconst ALLOWED_ALGOS = new Set([\n \"ssh-ed25519\",\n \"ssh-rsa\",\n \"ecdsa-sha2-nistp256\",\n \"ecdsa-sha2-nistp384\",\n \"ecdsa-sha2-nistp521\",\n]);\n\n/**\n * Parse a single OpenSSH-format public-key line into its components.\n * Rejects empty/blank lines and lines whose algorithm token is not on the\n * conservative allowlist (above) — keeps malformed input out of the DB\n * before we ever try to hand it to sshd.\n */\nexport function parseSshPubkey(line: string): SshPubkey {\n const trimmed = line.trim();\n if (trimmed.length === 0) {\n throw new Error(\"ssh pubkey line is empty\");\n }\n if (trimmed.startsWith(\"#\")) {\n throw new Error(\"ssh pubkey line is a comment\");\n }\n\n const parts = trimmed.split(/\\s+/);\n if (parts.length < 2) {\n throw new Error(\n \"ssh pubkey line must have at least <algorithm> <base64> tokens\",\n );\n }\n\n const [algorithm, b64, ...rest] = parts as [string, string, ...string[]];\n if (!ALLOWED_ALGOS.has(algorithm)) {\n throw new Error(`unsupported ssh pubkey algorithm: ${algorithm}`);\n }\n\n // Buffer.from(string, \"base64\") does NOT throw on invalid input — it\n // silently strips non-base64 characters. So a try/catch around this\n // call is dead code; the real validation is the re-encode comparison\n // below, which catches a paste with a stray quote/character that\n // would otherwise produce a key blob mismatched against sshd's view.\n const keyBlob = Buffer.from(b64, \"base64\");\n if (keyBlob.length === 0) {\n throw new Error(\"ssh pubkey base64 blob is empty\");\n }\n if (keyBlob.toString(\"base64\").replace(/=+$/, \"\") !== b64.replace(/=+$/, \"\")) {\n throw new Error(\"ssh pubkey base64 blob has trailing junk\");\n }\n\n return {\n algorithm,\n keyBlob,\n comment: rest.join(\" \"),\n full: trimmed,\n fingerprint: sshFingerprintFromBlob(keyBlob),\n };\n}\n\n/**\n * SHA256 fingerprint of a raw key blob in OpenSSH wire format. Output is\n * `SHA256:<base64-no-padding>` — the exact form sshd emits in logs and via\n * the `%f` format specifier.\n */\nexport function sshFingerprintFromBlob(keyBlob: Buffer): string {\n const hash = createHash(\"sha256\").update(keyBlob).digest();\n const b64 = hash.toString(\"base64\").replace(/=+$/, \"\");\n return `SHA256:${b64}`;\n}\n\n/**\n * Split a multi-line authorized_keys-style blob into individual valid pubkey\n * lines, dropping blanks and `#` comments. Returns parsed pubkeys and any\n * parse failures alongside their source line numbers — callers decide\n * whether to abort or log-and-continue.\n */\nexport function parseSshPubkeyList(blob: string): {\n pubkeys: SshPubkey[];\n errors: Array<{ lineNumber: number; line: string; error: string }>;\n} {\n const pubkeys: SshPubkey[] = [];\n const errors: Array<{ lineNumber: number; line: string; error: string }> = [];\n const lines = blob.split(\"\\n\");\n for (let i = 0; i < lines.length; i++) {\n const raw = lines[i] ?? \"\";\n const stripped = raw.trim();\n if (stripped.length === 0 || stripped.startsWith(\"#\")) continue;\n try {\n pubkeys.push(parseSshPubkey(stripped));\n } catch (e) {\n errors.push({\n lineNumber: i + 1,\n line: stripped,\n error: (e as Error).message,\n });\n }\n }\n return { pubkeys, errors };\n}\n","/**\n * 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"]}