@openthink/stamp 1.4.0 → 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.
- package/dist/index.js +911 -134
- package/dist/index.js.map +1 -1
- package/dist/server/authorized-keys.cjs +131 -0
- package/dist/server/authorized-keys.cjs.map +1 -0
- package/dist/server/mint-invite.cjs +306 -0
- package/dist/server/mint-invite.cjs.map +1 -0
- package/dist/server/seed-users.cjs +247 -0
- package/dist/server/seed-users.cjs.map +1 -0
- package/dist/server/start-http-server.cjs +400 -0
- package/dist/server/start-http-server.cjs.map +1 -0
- package/dist/server/users-cli.cjs +473 -0
- package/dist/server/users-cli.cjs.map +1 -0
- package/package.json +2 -2
|
@@ -0,0 +1,131 @@
|
|
|
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 findUserBySshFingerprint(db, ssh_fp) {
|
|
78
|
+
const stmt = db.prepare(
|
|
79
|
+
`SELECT id, short_name, ssh_pubkey, ssh_fp, stamp_pubkey, role, source,
|
|
80
|
+
invited_by, created_at, last_seen_at
|
|
81
|
+
FROM users WHERE ssh_fp = ?`
|
|
82
|
+
);
|
|
83
|
+
const row = stmt.get(ssh_fp);
|
|
84
|
+
return row ?? null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// src/server/authorized-keys.ts
|
|
88
|
+
function main() {
|
|
89
|
+
const argv = process.argv.slice(2);
|
|
90
|
+
if (argv.length < 2) {
|
|
91
|
+
console.error(
|
|
92
|
+
"stamp-authorized-keys: expected 2 args (username, fingerprint); got " + argv.length
|
|
93
|
+
);
|
|
94
|
+
process.exit(0);
|
|
95
|
+
}
|
|
96
|
+
const username = argv[0];
|
|
97
|
+
const fingerprint = argv[1];
|
|
98
|
+
if (username !== "git") {
|
|
99
|
+
process.exit(0);
|
|
100
|
+
}
|
|
101
|
+
if (!fingerprint.startsWith("SHA256:") || fingerprint.length < 10) {
|
|
102
|
+
process.exit(0);
|
|
103
|
+
}
|
|
104
|
+
let db;
|
|
105
|
+
try {
|
|
106
|
+
db = openServerDb({ readOnly: true });
|
|
107
|
+
} catch (e) {
|
|
108
|
+
console.error(
|
|
109
|
+
`stamp-authorized-keys: could not open membership DB: ${e.message}`
|
|
110
|
+
);
|
|
111
|
+
process.exit(0);
|
|
112
|
+
}
|
|
113
|
+
try {
|
|
114
|
+
const user = findUserBySshFingerprint(db, fingerprint);
|
|
115
|
+
if (user) {
|
|
116
|
+
process.stdout.write(user.ssh_pubkey + "\n");
|
|
117
|
+
}
|
|
118
|
+
} catch (e) {
|
|
119
|
+
console.error(
|
|
120
|
+
`stamp-authorized-keys: lookup failed for ${fingerprint}: ${e.message}`
|
|
121
|
+
);
|
|
122
|
+
} finally {
|
|
123
|
+
try {
|
|
124
|
+
db.close();
|
|
125
|
+
} catch {
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
process.exit(0);
|
|
129
|
+
}
|
|
130
|
+
main();
|
|
131
|
+
//# sourceMappingURL=authorized-keys.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/lib/serverDb.ts","../../src/lib/paths.ts","../../src/server/authorized-keys.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 * sshd AuthorizedKeysCommand resolver.\n *\n * Invoked by sshd at connection time as:\n * /usr/local/sbin/stamp-authorized-keys <username> <fingerprint>\n *\n * where <fingerprint> is sshd's %f format specifier — \"SHA256:<base64>\".\n * Looks the fingerprint up in the membership sqlite (read-only) and, if\n * matched, prints the matching authorized_keys line to stdout. Exits 0\n * regardless of whether a match was found — sshd treats empty stdout as\n * \"no key matched, try the next auth method (AuthorizedKeysFile)\", which\n * is exactly the behavior we want during the env-var → sqlite transition.\n *\n * Constraints sshd enforces on this script:\n * - Must be owned by root, no group/other write bits (sshd validates).\n * - Must produce its output quickly — every SSH handshake invokes us.\n * - Runs as AuthorizedKeysCommandUser (we use `git`), which has READ-ONLY\n * access to the DB via the root:git 0640 mode bits. Opening the DB\n * read-only here means even a future bug that tries to mutate state\n * fails at open time instead of corrupting authz.\n *\n * Failure handling: ANY error (DB open failure, malformed arguments, etc.)\n * is logged to stderr and the script exits 0 with empty stdout. That hands\n * the auth attempt off to sshd's AuthorizedKeysFile fallback rather than\n * locking everyone out if the DB is briefly unavailable (rebuild window,\n * volume not yet mounted, etc.). The legacy /home/git/.ssh/authorized_keys\n * path remains populated by entrypoint.sh during the transition, so a\n * DB-side failure degrades to \"legacy AUTHORIZED_KEYS env var keys still\n * work\" rather than \"nobody can SSH in.\"\n */\n\nimport { findUserBySshFingerprint, openServerDb } from \"../lib/serverDb.js\";\n\nfunction main(): void {\n // argv: [node, script, username, fingerprint]\n const argv = process.argv.slice(2);\n if (argv.length < 2) {\n console.error(\n \"stamp-authorized-keys: expected 2 args (username, fingerprint); got \" + argv.length,\n );\n process.exit(0);\n }\n\n const username = argv[0]!;\n const fingerprint = argv[1]!;\n\n // Only the git user is a valid SSH target on this server. Any other\n // username is a misconfiguration or a probe; emit nothing.\n if (username !== \"git\") {\n process.exit(0);\n }\n\n // Fingerprint sanity check before we hit the DB. sshd's %f format is\n // strict; anything that doesn't match is either a probe or a future\n // sshd version we don't know how to handle yet — fail open to the\n // AuthorizedKeysFile fallback.\n if (!fingerprint.startsWith(\"SHA256:\") || fingerprint.length < 10) {\n process.exit(0);\n }\n\n let db;\n try {\n db = openServerDb({ readOnly: true });\n } catch (e) {\n console.error(\n `stamp-authorized-keys: could not open membership DB: ${(e as Error).message}`,\n );\n process.exit(0);\n }\n\n try {\n const user = findUserBySshFingerprint(db, fingerprint);\n if (user) {\n // Print the stored authorized_keys line verbatim. sshd parses this\n // exactly as it would a line from AuthorizedKeysFile — algorithm,\n // base64, optional comment.\n process.stdout.write(user.ssh_pubkey + \"\\n\");\n }\n } catch (e) {\n console.error(\n `stamp-authorized-keys: lookup failed for ${fingerprint}: ${(e as Error).message}`,\n );\n } finally {\n try {\n db.close();\n } catch {\n // ignore — the process is exiting anyway\n }\n }\n\n process.exit(0);\n}\n\nmain();\n"],"mappings":";;;;AAsBA,IAAAA,kBAAsC;AACtC,yBAA6B;AAC7B,IAAAC,oBAAwB;;;ACxBxB,qBAA8D;AAC9D,qBAAwB;AACxB,uBAAmD;AA2G5C,SAAS,UAAU,MAAc,OAAO,KAAa;AAC1D,MAAI,KAAC,2BAAW,IAAI,GAAG;AACrB,kCAAU,MAAM,EAAE,WAAW,MAAM,KAAK,CAAC;AAAA,EAC3C;AACF;;;AD1DO,IAAM,yBAAyB;AAS/B,SAAS,oBAAoB,UAA2B;AAC7D,MAAI,SAAU,QAAO;AACrB,QAAM,UAAU,QAAQ,IAAI,sBAAsB;AAClD,MAAI,WAAW,QAAQ,SAAS,EAAG,QAAO;AAC1C,SAAO;AACT;AAcO,SAAS,aAAa,OAAyB,CAAC,GAAiB;AACtE,QAAM,OAAO,oBAAoB,KAAK,IAAI;AAC1C,QAAM,WAAW,KAAK,YAAY;AAElC,MAAI,CAAC,UAAU;AACb,UAAM,UAAM,2BAAQ,IAAI;AACxB,cAAU,KAAK,GAAK;AACpB,QAAI,CAAC,KAAK,WAAW;AAInB,qCAAU,KAAK,GAAK;AAAA,IACtB;AAAA,EACF;AAEA,QAAM,KAAK,IAAI,gCAAa,MAAM,EAAE,SAAS,CAAC;AAE9C,MAAI,CAAC,UAAU;AACb,OAAG,KAAK,0BAA0B;AAClC,eAAW,EAAE;AACb,QAAI,CAAC,KAAK,iBAAa,4BAAW,IAAI,GAAG;AAWvC,qCAAU,MAAM,GAAK;AAAA,IACvB;AAAA,EACF;AAEA,SAAO;AACT;AAEA,SAAS,WAAW,IAAwB;AAC1C,KAAG,KAAK;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,GA2BP;AACH;AAoDO,SAAS,yBACd,IACA,QACgB;AAChB,QAAM,OAAO,GAAG;AAAA,IACd;AAAA;AAAA;AAAA,EAGF;AACA,QAAM,MAAM,KAAK,IAAI,MAAM;AAC3B,SAAO,OAAO;AAChB;;;AEpLA,SAAS,OAAa;AAEpB,QAAM,OAAO,QAAQ,KAAK,MAAM,CAAC;AACjC,MAAI,KAAK,SAAS,GAAG;AACnB,YAAQ;AAAA,MACN,yEAAyE,KAAK;AAAA,IAChF;AACA,YAAQ,KAAK,CAAC;AAAA,EAChB;AAEA,QAAM,WAAW,KAAK,CAAC;AACvB,QAAM,cAAc,KAAK,CAAC;AAI1B,MAAI,aAAa,OAAO;AACtB,YAAQ,KAAK,CAAC;AAAA,EAChB;AAMA,MAAI,CAAC,YAAY,WAAW,SAAS,KAAK,YAAY,SAAS,IAAI;AACjE,YAAQ,KAAK,CAAC;AAAA,EAChB;AAEA,MAAI;AACJ,MAAI;AACF,SAAK,aAAa,EAAE,UAAU,KAAK,CAAC;AAAA,EACtC,SAAS,GAAG;AACV,YAAQ;AAAA,MACN,wDAAyD,EAAY,OAAO;AAAA,IAC9E;AACA,YAAQ,KAAK,CAAC;AAAA,EAChB;AAEA,MAAI;AACF,UAAM,OAAO,yBAAyB,IAAI,WAAW;AACrD,QAAI,MAAM;AAIR,cAAQ,OAAO,MAAM,KAAK,aAAa,IAAI;AAAA,IAC7C;AAAA,EACF,SAAS,GAAG;AACV,YAAQ;AAAA,MACN,4CAA4C,WAAW,KAAM,EAAY,OAAO;AAAA,IAClF;AAAA,EACF,UAAE;AACA,QAAI;AACF,SAAG,MAAM;AAAA,IACX,QAAQ;AAAA,IAER;AAAA,EACF;AAEA,UAAQ,KAAK,CAAC;AAChB;AAEA,KAAK;","names":["import_node_fs","import_node_path"]}
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
// src/lib/invites.ts
|
|
5
|
+
var import_node_crypto = require("crypto");
|
|
6
|
+
var INVITE_TTL_SECONDS = 15 * 60;
|
|
7
|
+
var TOKEN_BYTES = 32;
|
|
8
|
+
function generateInviteToken() {
|
|
9
|
+
return (0, import_node_crypto.randomBytes)(TOKEN_BYTES).toString("base64url");
|
|
10
|
+
}
|
|
11
|
+
function mintInvite(db, input) {
|
|
12
|
+
const token = generateInviteToken();
|
|
13
|
+
const now = input.now ?? Math.floor(Date.now() / 1e3);
|
|
14
|
+
const ttl = input.ttl_seconds ?? INVITE_TTL_SECONDS;
|
|
15
|
+
const expires_at = now + ttl;
|
|
16
|
+
const stmt = db.prepare(
|
|
17
|
+
`INSERT INTO invites (token, role, invited_by, created_at, expires_at)
|
|
18
|
+
VALUES (?, ?, ?, ?, ?)`
|
|
19
|
+
);
|
|
20
|
+
stmt.run(token, input.role, input.invited_by, now, expires_at);
|
|
21
|
+
return { token, expires_at };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// src/lib/serverDb.ts
|
|
25
|
+
var import_node_fs2 = require("fs");
|
|
26
|
+
var import_node_sqlite = require("node:sqlite");
|
|
27
|
+
var import_node_path2 = require("path");
|
|
28
|
+
|
|
29
|
+
// src/lib/paths.ts
|
|
30
|
+
var import_node_fs = require("fs");
|
|
31
|
+
var import_node_os = require("os");
|
|
32
|
+
var import_node_path = require("path");
|
|
33
|
+
function ensureDir(path, mode = 493) {
|
|
34
|
+
if (!(0, import_node_fs.existsSync)(path)) {
|
|
35
|
+
(0, import_node_fs.mkdirSync)(path, { recursive: true, mode });
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// src/lib/serverDb.ts
|
|
40
|
+
var DEFAULT_SERVER_DB_PATH = "/srv/git/.stamp-state/users.db";
|
|
41
|
+
function resolveServerDbPath(explicit) {
|
|
42
|
+
if (explicit) return explicit;
|
|
43
|
+
const envPath = process.env["STAMP_SERVER_DB_PATH"];
|
|
44
|
+
if (envPath && envPath.length > 0) return envPath;
|
|
45
|
+
return DEFAULT_SERVER_DB_PATH;
|
|
46
|
+
}
|
|
47
|
+
function openServerDb(opts = {}) {
|
|
48
|
+
const path = resolveServerDbPath(opts.path);
|
|
49
|
+
const readOnly = opts.readOnly ?? false;
|
|
50
|
+
if (!readOnly) {
|
|
51
|
+
const dir = (0, import_node_path2.dirname)(path);
|
|
52
|
+
ensureDir(dir, 488);
|
|
53
|
+
if (!opts.skipChmod) {
|
|
54
|
+
(0, import_node_fs2.chmodSync)(dir, 488);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
const db = new import_node_sqlite.DatabaseSync(path, { readOnly });
|
|
58
|
+
if (!readOnly) {
|
|
59
|
+
db.exec("PRAGMA foreign_keys = ON");
|
|
60
|
+
initSchema(db);
|
|
61
|
+
if (!opts.skipChmod && (0, import_node_fs2.existsSync)(path)) {
|
|
62
|
+
(0, import_node_fs2.chmodSync)(path, 432);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return db;
|
|
66
|
+
}
|
|
67
|
+
function initSchema(db) {
|
|
68
|
+
db.exec(`
|
|
69
|
+
CREATE TABLE IF NOT EXISTS users (
|
|
70
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
71
|
+
short_name TEXT NOT NULL UNIQUE,
|
|
72
|
+
ssh_pubkey TEXT NOT NULL,
|
|
73
|
+
ssh_fp TEXT NOT NULL UNIQUE,
|
|
74
|
+
stamp_pubkey TEXT,
|
|
75
|
+
role TEXT NOT NULL CHECK (role IN ('owner','admin','member')),
|
|
76
|
+
source TEXT NOT NULL DEFAULT 'invite' CHECK (source IN ('env','bootstrap','invite','manual')),
|
|
77
|
+
invited_by INTEGER REFERENCES users(id),
|
|
78
|
+
created_at INTEGER NOT NULL,
|
|
79
|
+
last_seen_at INTEGER
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
CREATE INDEX IF NOT EXISTS idx_users_ssh_fp ON users(ssh_fp);
|
|
83
|
+
|
|
84
|
+
CREATE TABLE IF NOT EXISTS invites (
|
|
85
|
+
token TEXT PRIMARY KEY,
|
|
86
|
+
role TEXT NOT NULL CHECK (role IN ('admin','member')),
|
|
87
|
+
invited_by INTEGER NOT NULL REFERENCES users(id),
|
|
88
|
+
created_at INTEGER NOT NULL,
|
|
89
|
+
expires_at INTEGER NOT NULL,
|
|
90
|
+
consumed_at INTEGER,
|
|
91
|
+
consumed_by INTEGER REFERENCES users(id)
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
CREATE INDEX IF NOT EXISTS idx_invites_expires ON invites(expires_at);
|
|
95
|
+
`);
|
|
96
|
+
}
|
|
97
|
+
function findUserBySshFingerprint(db, ssh_fp) {
|
|
98
|
+
const stmt = db.prepare(
|
|
99
|
+
`SELECT id, short_name, ssh_pubkey, ssh_fp, stamp_pubkey, role, source,
|
|
100
|
+
invited_by, created_at, last_seen_at
|
|
101
|
+
FROM users WHERE ssh_fp = ?`
|
|
102
|
+
);
|
|
103
|
+
const row = stmt.get(ssh_fp);
|
|
104
|
+
return row ?? null;
|
|
105
|
+
}
|
|
106
|
+
function findUserByShortName(db, short_name) {
|
|
107
|
+
const stmt = db.prepare(
|
|
108
|
+
`SELECT id, short_name, ssh_pubkey, ssh_fp, stamp_pubkey, role, source,
|
|
109
|
+
invited_by, created_at, last_seen_at
|
|
110
|
+
FROM users WHERE short_name = ?`
|
|
111
|
+
);
|
|
112
|
+
const row = stmt.get(short_name);
|
|
113
|
+
return row ?? null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// src/lib/sshUserAuth.ts
|
|
117
|
+
var import_node_fs3 = require("fs");
|
|
118
|
+
|
|
119
|
+
// src/lib/sshKeys.ts
|
|
120
|
+
var import_node_crypto2 = require("crypto");
|
|
121
|
+
var ALLOWED_ALGOS = /* @__PURE__ */ new Set([
|
|
122
|
+
"ssh-ed25519",
|
|
123
|
+
"ssh-rsa",
|
|
124
|
+
"ecdsa-sha2-nistp256",
|
|
125
|
+
"ecdsa-sha2-nistp384",
|
|
126
|
+
"ecdsa-sha2-nistp521"
|
|
127
|
+
]);
|
|
128
|
+
function parseSshPubkey(line) {
|
|
129
|
+
const trimmed = line.trim();
|
|
130
|
+
if (trimmed.length === 0) {
|
|
131
|
+
throw new Error("ssh pubkey line is empty");
|
|
132
|
+
}
|
|
133
|
+
if (trimmed.startsWith("#")) {
|
|
134
|
+
throw new Error("ssh pubkey line is a comment");
|
|
135
|
+
}
|
|
136
|
+
const parts = trimmed.split(/\s+/);
|
|
137
|
+
if (parts.length < 2) {
|
|
138
|
+
throw new Error(
|
|
139
|
+
"ssh pubkey line must have at least <algorithm> <base64> tokens"
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
const [algorithm, b64, ...rest] = parts;
|
|
143
|
+
if (!ALLOWED_ALGOS.has(algorithm)) {
|
|
144
|
+
throw new Error(`unsupported ssh pubkey algorithm: ${algorithm}`);
|
|
145
|
+
}
|
|
146
|
+
const keyBlob = Buffer.from(b64, "base64");
|
|
147
|
+
if (keyBlob.length === 0) {
|
|
148
|
+
throw new Error("ssh pubkey base64 blob is empty");
|
|
149
|
+
}
|
|
150
|
+
if (keyBlob.toString("base64").replace(/=+$/, "") !== b64.replace(/=+$/, "")) {
|
|
151
|
+
throw new Error("ssh pubkey base64 blob has trailing junk");
|
|
152
|
+
}
|
|
153
|
+
return {
|
|
154
|
+
algorithm,
|
|
155
|
+
keyBlob,
|
|
156
|
+
comment: rest.join(" "),
|
|
157
|
+
full: trimmed,
|
|
158
|
+
fingerprint: sshFingerprintFromBlob(keyBlob)
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
function sshFingerprintFromBlob(keyBlob) {
|
|
162
|
+
const hash = (0, import_node_crypto2.createHash)("sha256").update(keyBlob).digest();
|
|
163
|
+
const b64 = hash.toString("base64").replace(/=+$/, "");
|
|
164
|
+
return `SHA256:${b64}`;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// src/lib/sshUserAuth.ts
|
|
168
|
+
function readAuthenticatedPubkey() {
|
|
169
|
+
const path = process.env["SSH_USER_AUTH"];
|
|
170
|
+
if (!path) return null;
|
|
171
|
+
let raw;
|
|
172
|
+
try {
|
|
173
|
+
raw = (0, import_node_fs3.readFileSync)(path, "utf8");
|
|
174
|
+
} catch {
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
for (const line of raw.split("\n")) {
|
|
178
|
+
const trimmed = line.trim();
|
|
179
|
+
if (!trimmed.startsWith("publickey ")) continue;
|
|
180
|
+
const pubkeyLine = trimmed.slice("publickey ".length).trim();
|
|
181
|
+
try {
|
|
182
|
+
return parseSshPubkey(pubkeyLine);
|
|
183
|
+
} catch {
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// src/server/mint-invite.ts
|
|
191
|
+
var SHORT_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,62}$/;
|
|
192
|
+
function fail(message, exitCode) {
|
|
193
|
+
console.error(`error: ${message}`);
|
|
194
|
+
process.exit(exitCode);
|
|
195
|
+
}
|
|
196
|
+
function parseArgs(argv) {
|
|
197
|
+
if (argv.length < 1) {
|
|
198
|
+
fail("usage: stamp-mint-invite <short_name> [--role admin|member]", 2);
|
|
199
|
+
}
|
|
200
|
+
let short_name = "";
|
|
201
|
+
let role = "member";
|
|
202
|
+
for (let i = 0; i < argv.length; i++) {
|
|
203
|
+
const arg = argv[i];
|
|
204
|
+
if (arg === "--role") {
|
|
205
|
+
const next = argv[i + 1];
|
|
206
|
+
if (next !== "admin" && next !== "member") {
|
|
207
|
+
fail(`--role expects 'admin' or 'member' (got ${JSON.stringify(next)})`, 2);
|
|
208
|
+
}
|
|
209
|
+
role = next;
|
|
210
|
+
i++;
|
|
211
|
+
} else if (arg.startsWith("--")) {
|
|
212
|
+
fail(`unknown flag: ${arg}`, 2);
|
|
213
|
+
} else if (!short_name) {
|
|
214
|
+
short_name = arg;
|
|
215
|
+
} else {
|
|
216
|
+
fail(`unexpected positional argument: ${arg}`, 2);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
if (!short_name) {
|
|
220
|
+
fail("missing required <short_name> argument", 2);
|
|
221
|
+
}
|
|
222
|
+
if (!SHORT_NAME_RE.test(short_name)) {
|
|
223
|
+
fail(
|
|
224
|
+
`<short_name> ${JSON.stringify(short_name)} has an invalid shape (allowed: alphanumerics + . _ -, must start with alnum, max 63 chars)`,
|
|
225
|
+
2
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
return { short_name, role };
|
|
229
|
+
}
|
|
230
|
+
function publicHostFromUrl(url) {
|
|
231
|
+
try {
|
|
232
|
+
const u = new URL(url);
|
|
233
|
+
const host = u.host;
|
|
234
|
+
if (!host) throw new Error("URL has no host");
|
|
235
|
+
return { host, insecure: u.protocol === "http:" };
|
|
236
|
+
} catch (e) {
|
|
237
|
+
fail(`STAMP_PUBLIC_URL is malformed: ${e.message}`, 1);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
function main() {
|
|
241
|
+
const publicUrl = process.env["STAMP_PUBLIC_URL"];
|
|
242
|
+
if (!publicUrl) {
|
|
243
|
+
fail(
|
|
244
|
+
"STAMP_PUBLIC_URL is not set on the server \u2014 operator must configure the externally-reachable HTTP URL before minting invites",
|
|
245
|
+
1
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
const publicEndpoint = publicHostFromUrl(publicUrl);
|
|
249
|
+
const args = parseArgs(process.argv.slice(2));
|
|
250
|
+
const caller = readAuthenticatedPubkey();
|
|
251
|
+
if (!caller) {
|
|
252
|
+
fail(
|
|
253
|
+
"could not determine authenticated identity (SSH_USER_AUTH unset or has no publickey entry). Server may be missing 'ExposeAuthInfo yes' in sshd_config.",
|
|
254
|
+
1
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
const db = openServerDb({ skipChmod: true });
|
|
258
|
+
try {
|
|
259
|
+
const callerRow = findUserBySshFingerprint(db, caller.fingerprint);
|
|
260
|
+
if (!callerRow) {
|
|
261
|
+
fail(
|
|
262
|
+
`caller fingerprint ${caller.fingerprint} is not in the membership DB \u2014 this should be impossible after sshd authenticated them. Likely cause: phase-1 env-var sync hasn't run on this server yet.`,
|
|
263
|
+
1
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
if (callerRow.role !== "owner" && callerRow.role !== "admin") {
|
|
267
|
+
fail(
|
|
268
|
+
`role ${callerRow.role} is not permitted to mint invites (need owner or admin)`,
|
|
269
|
+
3
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
if (callerRow.role === "admin" && args.role === "admin") {
|
|
273
|
+
fail(
|
|
274
|
+
"admins may only mint --role member invites; only owners may mint admin invites",
|
|
275
|
+
3
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
const existing = findUserByShortName(db, args.short_name);
|
|
279
|
+
if (existing) {
|
|
280
|
+
fail(
|
|
281
|
+
`short_name ${JSON.stringify(args.short_name)} is already in use (id=${existing.id} role=${existing.role}). Pick a different name or remove the existing user first.`,
|
|
282
|
+
4
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
const minted = mintInvite(db, {
|
|
286
|
+
role: args.role,
|
|
287
|
+
invited_by: callerRow.id
|
|
288
|
+
});
|
|
289
|
+
const insecureSuffix = publicEndpoint.insecure ? "?insecure=1" : "";
|
|
290
|
+
const shareUrl = `stamp+invite://${publicEndpoint.host}/${minted.token}${insecureSuffix}`;
|
|
291
|
+
process.stdout.write(shareUrl + "\n");
|
|
292
|
+
const expiresInMin = Math.round((minted.expires_at - Math.floor(Date.now() / 1e3)) / 60);
|
|
293
|
+
process.stderr.write(
|
|
294
|
+
`note: minted invite for short_name=${args.short_name} role=${args.role} (expires in ~${expiresInMin}m, invited_by=${callerRow.short_name})
|
|
295
|
+
`
|
|
296
|
+
);
|
|
297
|
+
process.stderr.write(
|
|
298
|
+
`note: invitee runs: stamp invites accept "${shareUrl}"
|
|
299
|
+
`
|
|
300
|
+
);
|
|
301
|
+
} finally {
|
|
302
|
+
db.close();
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
main();
|
|
306
|
+
//# sourceMappingURL=mint-invite.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/lib/invites.ts","../../src/lib/serverDb.ts","../../src/lib/paths.ts","../../src/lib/sshUserAuth.ts","../../src/lib/sshKeys.ts","../../src/server/mint-invite.ts"],"sourcesContent":["/**\n * Invite token mint / consume operations against the membership sqlite.\n *\n * Tokens are 32 bytes of cryptographically random data, base64url-encoded\n * (no padding) — 43 ASCII characters that survive copy-paste through\n * Slack/iMessage/wormhole/etc. without losing the trailing `=` padding\n * that several chat clients silently trim.\n *\n * TTL is 15 minutes. Tokens are single-use: consume marks `consumed_at`\n * and refuses subsequent consumption of the same token. Expiry is enforced\n * at consume time (the row stays in the DB for audit until phase 5 prune\n * support sweeps stale rows).\n *\n * The invite role is constrained to 'admin' or 'member' by the schema —\n * minting an owner-via-invite is not supported (owners are promoted via\n * the phase-3 self-promote path against an existing admin/member account).\n */\n\nimport { randomBytes } from \"node:crypto\";\nimport { DatabaseSync } from \"node:sqlite\";\nimport type { InviteRole, InviteRow } from \"./serverDb.js\";\n\nexport const INVITE_TTL_SECONDS = 15 * 60;\nexport const TOKEN_BYTES = 32;\nexport const TOKEN_LENGTH_CHARS = 43; // ceil(32 * 4 / 3) with no padding\n\n/** Generate a fresh single-use invite token: 32 bytes, base64url, no padding. */\nexport function generateInviteToken(): string {\n return randomBytes(TOKEN_BYTES).toString(\"base64url\");\n}\n\nexport interface MintInviteInput {\n role: InviteRole;\n invited_by: number;\n /** Override the wall-clock for deterministic tests. Seconds since epoch. */\n now?: number;\n /** Override the TTL for tests. Defaults to INVITE_TTL_SECONDS. */\n ttl_seconds?: number;\n}\n\nexport interface MintedInvite {\n token: string;\n expires_at: number;\n}\n\nexport function mintInvite(\n db: DatabaseSync,\n input: MintInviteInput,\n): MintedInvite {\n const token = generateInviteToken();\n const now = input.now ?? Math.floor(Date.now() / 1000);\n const ttl = input.ttl_seconds ?? INVITE_TTL_SECONDS;\n const expires_at = now + ttl;\n\n const stmt = db.prepare(\n `INSERT INTO invites (token, role, invited_by, created_at, expires_at)\n VALUES (?, ?, ?, ?, ?)`,\n );\n stmt.run(token, input.role, input.invited_by, now, expires_at);\n\n return { token, expires_at };\n}\n\nexport type ConsumeResult =\n | { ok: true; row: InviteRow }\n | { ok: false; reason: \"not_found\" | \"expired\" | \"already_consumed\" };\n\n/**\n * Atomically look up + consume a token in a single transaction. The atomic\n * read-then-write under SQLITE_BEGIN IMMEDIATE prevents two concurrent\n * accept requests from both succeeding on the same token — the second\n * sees `already_consumed` on its UPDATE.\n *\n * Caller is expected to ALSO write the user row inside the same DB call\n * sequence — but we expose consume as a separate primitive so the caller\n * can perform the user insert against the role from this invite without\n * carrying it through opaquely.\n */\nexport function consumeInviteToken(\n db: DatabaseSync,\n token: string,\n now?: number,\n): ConsumeResult {\n const wallclock = now ?? Math.floor(Date.now() / 1000);\n db.exec(\"BEGIN IMMEDIATE\");\n try {\n const selectStmt = db.prepare(`SELECT * FROM invites WHERE token = ?`);\n const row = selectStmt.get(token) as InviteRow | undefined;\n if (!row) {\n db.exec(\"ROLLBACK\");\n return { ok: false, reason: \"not_found\" };\n }\n if (row.consumed_at !== null) {\n db.exec(\"ROLLBACK\");\n return { ok: false, reason: \"already_consumed\" };\n }\n if (row.expires_at < wallclock) {\n db.exec(\"ROLLBACK\");\n return { ok: false, reason: \"expired\" };\n }\n\n // Mark consumed. consumed_by is set later by the caller once the user\n // row is inserted and we know its id.\n db.prepare(\n `UPDATE invites SET consumed_at = ? WHERE token = ? AND consumed_at IS NULL`,\n ).run(wallclock, token);\n\n db.exec(\"COMMIT\");\n return { ok: true, row };\n } catch (e) {\n try {\n db.exec(\"ROLLBACK\");\n } catch {\n // ignore — propagate the original error\n }\n throw e;\n }\n}\n\n/** Set the consumed_by user id on an already-consumed invite. */\nexport function markInviteConsumer(\n db: DatabaseSync,\n token: string,\n user_id: number,\n): void {\n db.prepare(`UPDATE invites SET consumed_by = ? WHERE token = ?`).run(\n user_id,\n token,\n );\n}\n\nexport function findInvite(db: DatabaseSync, token: string): InviteRow | null {\n const row = db\n .prepare(`SELECT * FROM invites WHERE token = ?`)\n .get(token) as InviteRow | undefined;\n return row ?? null;\n}\n","/**\n * Membership sqlite for the stamp server.\n *\n * Lives on the persistent volume at /srv/git/.stamp-state/users.db. Holds:\n * - users: SSH pubkey → role (owner/admin/member), optional stamp signing\n * pubkey, source provenance (env / bootstrap / invite / manual)\n * - invites: single-use, time-bounded tokens an admin mints to onboard\n * a teammate (phase 2)\n *\n * Two access modes:\n * - Writable (boot-time seed, admin operations): opens the DB read/write,\n * ensures schema, tightens perms on the file and parent dir.\n * - Read-only (sshd's AuthorizedKeysCommand): opens with readOnly:true so\n * the resolver process holds no write fd; lets us run the resolver as\n * an unprivileged user against a root:git 0640 DB without enabling\n * WAL-mode sidecars.\n *\n * Roles and invite roles are CHECK-constrained in the schema so a future\n * code-level bug introducing a typo'd role string fails at insert rather\n * than silently corrupting authorization data.\n */\n\nimport { chmodSync, existsSync } from \"node:fs\";\nimport { DatabaseSync } from \"node:sqlite\";\nimport { dirname } from \"node:path\";\nimport { ensureDir } from \"./paths.js\";\n\nexport type Role = \"owner\" | \"admin\" | \"member\";\nexport type UserSource = \"env\" | \"bootstrap\" | \"invite\" | \"manual\";\nexport type InviteRole = \"admin\" | \"member\";\n\nexport interface UserRow {\n id: number;\n short_name: string;\n ssh_pubkey: string;\n ssh_fp: string;\n stamp_pubkey: string | null;\n role: Role;\n source: UserSource;\n invited_by: number | null;\n created_at: number;\n last_seen_at: number | null;\n}\n\nexport interface InviteRow {\n token: string;\n role: InviteRole;\n invited_by: number;\n created_at: number;\n expires_at: number;\n consumed_at: number | null;\n consumed_by: number | null;\n}\n\n/** Default on-server path. Tests pass an explicit `path` instead. */\nexport const DEFAULT_SERVER_DB_PATH = \"/srv/git/.stamp-state/users.db\";\n\n/**\n * Resolve the effective DB path. Precedence:\n * 1. Explicit `opts.path` (tests, future config)\n * 2. STAMP_SERVER_DB_PATH env var (CLI-spawning tests; also a relief\n * valve for operators who want to relocate the DB on the volume)\n * 3. DEFAULT_SERVER_DB_PATH (production)\n */\nexport function resolveServerDbPath(explicit?: string): string {\n if (explicit) return explicit;\n const envPath = process.env[\"STAMP_SERVER_DB_PATH\"];\n if (envPath && envPath.length > 0) return envPath;\n return DEFAULT_SERVER_DB_PATH;\n}\n\nexport interface OpenServerDbOpts {\n /** Override the on-disk location. Required for tests. */\n path?: string;\n /** Open read-only. Skips schema init, skips chmod, and constructs the\n * DatabaseSync with readOnly:true. Used by the AuthorizedKeysCommand\n * resolver. */\n readOnly?: boolean;\n /** Skip filesystem-perm tightening of the DB file + parent dir. The\n * on-server boot path wants tightening; tests on tmpfs do not. */\n skipChmod?: boolean;\n}\n\nexport function openServerDb(opts: OpenServerDbOpts = {}): DatabaseSync {\n const path = resolveServerDbPath(opts.path);\n const readOnly = opts.readOnly ?? false;\n\n if (!readOnly) {\n const dir = dirname(path);\n ensureDir(dir, 0o750);\n if (!opts.skipChmod) {\n // ensureDir no-ops on an existing directory, so this explicit\n // chmod is what tightens perms on a redeploy where the dir was\n // created at a looser mode by an earlier image version.\n chmodSync(dir, 0o750);\n }\n }\n\n const db = new DatabaseSync(path, { readOnly });\n\n if (!readOnly) {\n db.exec(\"PRAGMA foreign_keys = ON\");\n initSchema(db);\n if (!opts.skipChmod && existsSync(path)) {\n // root:git 0660. The HTTP server (git user) writes new user rows\n // on invite-accept; the AuthorizedKeysCommand resolver also runs\n // as git but opens readOnly:true so the write bit is dormant on\n // its path. Chown is the operator's responsibility (entrypoint.sh\n // sets root:git after each boot); we only set the mode bits.\n //\n // Callers running as the git user (mint-invite, http-server) must\n // pass skipChmod:true — only the file owner can chmod on Linux,\n // and entrypoint.sh has already tightened perms by the time those\n // callers run.\n chmodSync(path, 0o660);\n }\n }\n\n return db;\n}\n\nfunction initSchema(db: DatabaseSync): void {\n db.exec(`\n CREATE TABLE IF NOT EXISTS users (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n short_name TEXT NOT NULL UNIQUE,\n ssh_pubkey TEXT NOT NULL,\n ssh_fp TEXT NOT NULL UNIQUE,\n stamp_pubkey TEXT,\n role TEXT NOT NULL CHECK (role IN ('owner','admin','member')),\n source TEXT NOT NULL DEFAULT 'invite' CHECK (source IN ('env','bootstrap','invite','manual')),\n invited_by INTEGER REFERENCES users(id),\n created_at INTEGER NOT NULL,\n last_seen_at INTEGER\n );\n\n CREATE INDEX IF NOT EXISTS idx_users_ssh_fp ON users(ssh_fp);\n\n CREATE TABLE IF NOT EXISTS invites (\n token TEXT PRIMARY KEY,\n role TEXT NOT NULL CHECK (role IN ('admin','member')),\n invited_by INTEGER NOT NULL REFERENCES users(id),\n created_at INTEGER NOT NULL,\n expires_at INTEGER NOT NULL,\n consumed_at INTEGER,\n consumed_by INTEGER REFERENCES users(id)\n );\n\n CREATE INDEX IF NOT EXISTS idx_invites_expires ON invites(expires_at);\n `);\n}\n\nexport interface InsertUserInput {\n short_name: string;\n ssh_pubkey: string;\n ssh_fp: string;\n stamp_pubkey?: string | null;\n role: Role;\n source: UserSource;\n invited_by?: number | null;\n}\n\n/** Insert a user. Throws if short_name or ssh_fp collide. */\nexport function insertUser(db: DatabaseSync, input: InsertUserInput): number {\n const stmt = db.prepare(\n `INSERT INTO users (short_name, ssh_pubkey, ssh_fp, stamp_pubkey, role, source, invited_by, created_at)\n VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,\n );\n const result = stmt.run(\n input.short_name,\n input.ssh_pubkey,\n input.ssh_fp,\n input.stamp_pubkey ?? null,\n input.role,\n input.source,\n input.invited_by ?? null,\n Math.floor(Date.now() / 1000),\n );\n return Number(result.lastInsertRowid);\n}\n\n/**\n * Idempotent insert keyed on ssh_fp. Returns the row id (newly-inserted or\n * pre-existing) and a `created` flag. Does NOT mutate role/short_name of an\n * existing row — the env-sync path runs on every boot, and we don't want\n * a manual admin demotion in the DB to be silently re-promoted by an\n * env-var entry that's still hanging around.\n *\n * If the caller's proposed short_name collides with an existing row that\n * has a DIFFERENT fingerprint, this throws. The seed-users entrypoint\n * handles that by appending a numeric suffix.\n */\nexport function upsertUserByFingerprint(\n db: DatabaseSync,\n input: InsertUserInput,\n): { id: number; created: boolean } {\n const existing = findUserBySshFingerprint(db, input.ssh_fp);\n if (existing) return { id: existing.id, created: false };\n const id = insertUser(db, input);\n return { id, created: true };\n}\n\nexport function findUserBySshFingerprint(\n db: DatabaseSync,\n ssh_fp: string,\n): UserRow | null {\n const stmt = db.prepare(\n `SELECT id, short_name, ssh_pubkey, ssh_fp, stamp_pubkey, role, source,\n invited_by, created_at, last_seen_at\n FROM users WHERE ssh_fp = ?`,\n );\n const row = stmt.get(ssh_fp) as UserRow | undefined;\n return row ?? null;\n}\n\nexport function findUserByShortName(\n db: DatabaseSync,\n short_name: string,\n): UserRow | null {\n const stmt = db.prepare(\n `SELECT id, short_name, ssh_pubkey, ssh_fp, stamp_pubkey, role, source,\n invited_by, created_at, last_seen_at\n FROM users WHERE short_name = ?`,\n );\n const row = stmt.get(short_name) as UserRow | undefined;\n return row ?? null;\n}\n\nexport function listUsers(db: DatabaseSync): UserRow[] {\n const stmt = db.prepare(\n `SELECT id, short_name, ssh_pubkey, ssh_fp, stamp_pubkey, role, source,\n invited_by, created_at, last_seen_at\n FROM users\n ORDER BY id`,\n );\n return stmt.all() as unknown as UserRow[];\n}\n\nexport function countByRole(db: DatabaseSync, role: Role): number {\n const stmt = db.prepare(`SELECT COUNT(*) AS n FROM users WHERE role = ?`);\n const row = stmt.get(role) as { n: number };\n return row.n;\n}\n\n/**\n * Generate a short_name that doesn't collide with any existing row. If\n * `desired` is free, returns it; otherwise appends `-2`, `-3`, ... until\n * a free slot is found. Used by the env-sync path where the proposed\n * short_name is derived from the SSH key's comment (often \"user@host\"),\n * which can collide if two keys share the same comment.\n */\nexport function suggestUniqueShortName(\n db: DatabaseSync,\n desired: string,\n): string {\n if (!findUserByShortName(db, desired)) return desired;\n for (let i = 2; i < 10000; i++) {\n const candidate = `${desired}-${i}`;\n if (!findUserByShortName(db, candidate)) return candidate;\n }\n throw new Error(\n `could not find a unique short_name for \"${desired}\" after 10000 attempts`,\n );\n}\n","import { existsSync, mkdirSync, readFileSync, statSync } from \"node:fs\";\nimport { homedir } from \"node:os\";\nimport { dirname, isAbsolute, join, resolve } from \"node:path\";\n\nexport function findRepoRoot(startFrom: string = process.cwd()): string {\n let current = resolve(startFrom);\n while (true) {\n if (existsSync(join(current, \".git\"))) return current;\n const parent = dirname(current);\n if (parent === current) {\n throw new Error(\n `not inside a git repository (searched up from ${startFrom})`,\n );\n }\n current = parent;\n }\n}\n\nexport function stampConfigDir(repoRoot: string): string {\n return join(repoRoot, \".stamp\");\n}\n\nexport function stampReviewersDir(repoRoot: string): string {\n return join(repoRoot, \".stamp\", \"reviewers\");\n}\n\nexport function stampTrustedKeysDir(repoRoot: string): string {\n return join(repoRoot, \".stamp\", \"trusted-keys\");\n}\n\nexport function stampConfigFile(repoRoot: string): string {\n return join(repoRoot, \".stamp\", \"config.yml\");\n}\n\nexport function stampStateDbPath(repoRoot: string): string {\n return join(gitCommonDir(repoRoot), \"stamp\", \"state.db\");\n}\n\n/**\n * Marker file that records \"we have shown the LLM data-flow notice in this\n * repo at least once.\" Lives next to state.db under the git common dir so\n * it's per-repo (not per-worktree, not committed).\n */\nexport function stampLlmNoticeMarkerPath(repoRoot: string): string {\n return join(gitCommonDir(repoRoot), \"stamp\", \"llm-notice-shown\");\n}\n\n/**\n * Resolve the git common directory for `repoRoot`. For a normal checkout this\n * is `<repoRoot>/.git`; for a worktree, `<repoRoot>/.git` is a *file* of the\n * form `gitdir: <path>` and the real common dir lives at `<gitdir>/commondir`\n * (a path relative to gitdir, typically `../..`). Mirrors `git rev-parse\n * --git-common-dir` without spawning git.\n *\n * State that should be shared across every worktree of one repository (review\n * verdicts, the per-machine sqlite db) lives under this common dir, so callers\n * resolve their paths through here rather than hard-coding `<repoRoot>/.git`.\n */\nexport function gitCommonDir(repoRoot: string): string {\n const dotGit = join(repoRoot, \".git\");\n const st = statSync(dotGit);\n if (st.isDirectory()) return dotGit;\n\n // Worktree (or submodule): `.git` is a file. Parse the `gitdir:` line, then\n // follow the `commondir` pointer from there. Submodules have no `commondir`,\n // so the gitdir itself is the writable common dir — fall through to that.\n const contents = readFileSync(dotGit, \"utf8\");\n const match = contents.match(/^gitdir:\\s*(.+)$/m);\n if (!match || !match[1]) {\n throw new Error(\n `expected '.git' at ${repoRoot} to be a directory or a 'gitdir:' pointer file, got: ${contents.slice(0, 120)}`,\n );\n }\n const gitdirRaw = match[1].trim();\n const gitdir = isAbsolute(gitdirRaw) ? gitdirRaw : resolve(repoRoot, gitdirRaw);\n\n const commondirPath = join(gitdir, \"commondir\");\n if (!existsSync(commondirPath)) return gitdir;\n const commondirRaw = readFileSync(commondirPath, \"utf8\").trim();\n return isAbsolute(commondirRaw) ? commondirRaw : resolve(gitdir, commondirRaw);\n}\n\nexport function userKeysDir(): string {\n return join(homedir(), \".stamp\", \"keys\");\n}\n\n/**\n * Per-user stamp-server config. Holds {host, port, user, repo_root_prefix}\n * so commands like `stamp provision` can reach the operator's stamp server\n * without making the agent guess at SSH endpoints.\n */\nexport function userServerConfigPath(): string {\n return join(homedir(), \".stamp\", \"server.yml\");\n}\n\n/**\n * Per-user stamp config. Today holds reviewer-model selections; structured\n * as a top-level object so future per-user knobs (telemetry sinks, default\n * timeouts, etc.) can land alongside without renaming the file. Lives\n * separately from per-repo `.stamp/config.yml` because cost/speed is\n * operator infrastructure rather than committed review policy — different\n * operators on the same repo are free to pick different models without\n * a merge-conflict over preference, and this file is intentionally\n * EXCLUDED from the v3 reviewer attestation hash chain.\n */\nexport function userConfigPath(): string {\n return join(homedir(), \".stamp\", \"config.yml\");\n}\n\nexport function ensureDir(path: string, mode = 0o755): void {\n if (!existsSync(path)) {\n mkdirSync(path, { recursive: true, mode });\n }\n}\n\nexport function isFile(path: string): boolean {\n try {\n return statSync(path).isFile();\n } catch {\n return false;\n }\n}\n","/**\n * Parse the SSH_USER_AUTH file that sshd writes during connection setup\n * when `ExposeAuthInfo yes` is set in sshd_config. Each line is one\n * auth method that successfully authenticated the user; for pubkey auth\n * the line looks like:\n *\n * publickey ssh-ed25519 AAAA... user@host\n *\n * The pubkey blob portion is the same wire format AuthorizedKeysFile\n * lines use, so we hand it through parseSshPubkey to compute the\n * fingerprint that keys the membership DB lookup.\n *\n * This is the load-bearing identity-binding step for SSH-invoked admin\n * commands (stamp-mint-invite, future user-management wrappers): without\n * it, a connected client could claim any role at the wrapper level. With\n * it, the wrapper trusts only sshd's already-completed pubkey auth.\n */\n\nimport { readFileSync } from \"node:fs\";\nimport { parseSshPubkey, type SshPubkey } from \"./sshKeys.js\";\n\n/**\n * Read SSH_USER_AUTH from the process env and return the first publickey\n * entry's parsed pubkey. Returns null when:\n * - SSH_USER_AUTH is unset (not run under sshd with ExposeAuthInfo)\n * - the file is missing or unreadable\n * - no `publickey` line is present (auth via a non-publickey method)\n *\n * Callers should treat null as \"no authenticated identity available\" and\n * refuse to proceed with admin actions — the absence of an identity is\n * never a green-light, only an opt-out-of-the-action signal.\n */\nexport function readAuthenticatedPubkey(): SshPubkey | null {\n const path = process.env[\"SSH_USER_AUTH\"];\n if (!path) return null;\n\n let raw: string;\n try {\n raw = readFileSync(path, \"utf8\");\n } catch {\n return null;\n }\n\n for (const line of raw.split(\"\\n\")) {\n const trimmed = line.trim();\n if (!trimmed.startsWith(\"publickey \")) continue;\n // Strip the leading \"publickey \" token; the rest is a normal pubkey line.\n const pubkeyLine = trimmed.slice(\"publickey \".length).trim();\n try {\n return parseSshPubkey(pubkeyLine);\n } catch {\n // Malformed line — keep looking; sshd may emit multiple successful\n // methods if more than one is configured.\n continue;\n }\n }\n return null;\n}\n","/**\n * Parse OpenSSH-format public keys and compute their SHA256 fingerprints\n * in the exact format sshd emits via the `%f` format specifier passed to\n * AuthorizedKeysCommand. That format is `SHA256:<base64-no-padding>`,\n * distinct from the `sha256:<hex>` form used elsewhere in stamp for stamp\n * signing keys (PEM/SPKI). Both formats exist; this module is the SSH\n * side only.\n *\n * The lookup keyed on this fingerprint is the load-bearing path for\n * sshd-based authentication of users stored in the membership sqlite —\n * any drift between this fingerprint and what sshd computes breaks\n * every connection. Test coverage pins the format to a known OpenSSH\n * fixture so the next regression here is loud.\n */\n\nimport { createHash } from \"node:crypto\";\n\nexport interface SshPubkey {\n /** Key algorithm token, e.g. \"ssh-ed25519\", \"ecdsa-sha2-nistp256\". */\n algorithm: string;\n /** Base64-decoded key blob (the bytes between the algorithm token and\n * the trailing comment in a public-key line). */\n keyBlob: Buffer;\n /** Trailing comment, typically \"user@host\". May be empty. */\n comment: string;\n /** The original single-line representation, trimmed of leading/trailing\n * whitespace. Stored verbatim in the membership DB so the value sshd\n * later prints back via AuthorizedKeysCommand is bit-identical to what\n * the operator submitted. */\n full: string;\n /** OpenSSH-style fingerprint: \"SHA256:<base64-no-padding>\". Matches the\n * `%f` value sshd passes to AuthorizedKeysCommand. */\n fingerprint: string;\n}\n\nconst ALLOWED_ALGOS = new Set([\n \"ssh-ed25519\",\n \"ssh-rsa\",\n \"ecdsa-sha2-nistp256\",\n \"ecdsa-sha2-nistp384\",\n \"ecdsa-sha2-nistp521\",\n]);\n\n/**\n * Parse a single OpenSSH-format public-key line into its components.\n * Rejects empty/blank lines and lines whose algorithm token is not on the\n * conservative allowlist (above) — keeps malformed input out of the DB\n * before we ever try to hand it to sshd.\n */\nexport function parseSshPubkey(line: string): SshPubkey {\n const trimmed = line.trim();\n if (trimmed.length === 0) {\n throw new Error(\"ssh pubkey line is empty\");\n }\n if (trimmed.startsWith(\"#\")) {\n throw new Error(\"ssh pubkey line is a comment\");\n }\n\n const parts = trimmed.split(/\\s+/);\n if (parts.length < 2) {\n throw new Error(\n \"ssh pubkey line must have at least <algorithm> <base64> tokens\",\n );\n }\n\n const [algorithm, b64, ...rest] = parts as [string, string, ...string[]];\n if (!ALLOWED_ALGOS.has(algorithm)) {\n throw new Error(`unsupported ssh pubkey algorithm: ${algorithm}`);\n }\n\n // Buffer.from(string, \"base64\") does NOT throw on invalid input — it\n // silently strips non-base64 characters. So a try/catch around this\n // call is dead code; the real validation is the re-encode comparison\n // below, which catches a paste with a stray quote/character that\n // would otherwise produce a key blob mismatched against sshd's view.\n const keyBlob = Buffer.from(b64, \"base64\");\n if (keyBlob.length === 0) {\n throw new Error(\"ssh pubkey base64 blob is empty\");\n }\n if (keyBlob.toString(\"base64\").replace(/=+$/, \"\") !== b64.replace(/=+$/, \"\")) {\n throw new Error(\"ssh pubkey base64 blob has trailing junk\");\n }\n\n return {\n algorithm,\n keyBlob,\n comment: rest.join(\" \"),\n full: trimmed,\n fingerprint: sshFingerprintFromBlob(keyBlob),\n };\n}\n\n/**\n * SHA256 fingerprint of a raw key blob in OpenSSH wire format. Output is\n * `SHA256:<base64-no-padding>` — the exact form sshd emits in logs and via\n * the `%f` format specifier.\n */\nexport function sshFingerprintFromBlob(keyBlob: Buffer): string {\n const hash = createHash(\"sha256\").update(keyBlob).digest();\n const b64 = hash.toString(\"base64\").replace(/=+$/, \"\");\n return `SHA256:${b64}`;\n}\n\n/**\n * Split a multi-line authorized_keys-style blob into individual valid pubkey\n * lines, dropping blanks and `#` comments. Returns parsed pubkeys and any\n * parse failures alongside their source line numbers — callers decide\n * whether to abort or log-and-continue.\n */\nexport function parseSshPubkeyList(blob: string): {\n pubkeys: SshPubkey[];\n errors: Array<{ lineNumber: number; line: string; error: string }>;\n} {\n const pubkeys: SshPubkey[] = [];\n const errors: Array<{ lineNumber: number; line: string; error: string }> = [];\n const lines = blob.split(\"\\n\");\n for (let i = 0; i < lines.length; i++) {\n const raw = lines[i] ?? \"\";\n const stripped = raw.trim();\n if (stripped.length === 0 || stripped.startsWith(\"#\")) continue;\n try {\n pubkeys.push(parseSshPubkey(stripped));\n } catch (e) {\n errors.push({\n lineNumber: i + 1,\n line: stripped,\n error: (e as Error).message,\n });\n }\n }\n return { pubkeys, errors };\n}\n","/**\n * SSH-invoked invite mint command, reachable as:\n *\n * ssh -p <port> git@<host> stamp-mint-invite <short_name> [--role admin|member]\n *\n * Symlinked into /home/git/git-shell-commands/ on the server image so\n * git-shell will dispatch to it. Authenticates the caller by reading the\n * SSH_USER_AUTH file sshd wrote during connection setup (requires\n * ExposeAuthInfo yes in sshd_config) and looking up their fingerprint in\n * the membership DB. Refuses to mint if:\n *\n * - SSH_USER_AUTH is unset or has no publickey entry (no identity)\n * - the caller isn't an admin or owner (role check)\n * - <short_name> is malformed or already taken in the users table\n * - STAMP_PUBLIC_URL env var is unset (we'd have no way to print a\n * shareable URL for the invitee to use)\n *\n * On success, prints a single line on stdout:\n *\n * stamp+invite://<public-host>/<token>\n *\n * The CLI side (`stamp invites mint`) captures that stdout and prints\n * it verbatim along with the server's stderr (which carries the\n * `note:`-prefixed diagnostic prose). Because this stderr crosses the\n * SSH boundary and lands in the operator's terminal as CLI output, it\n * follows the lowercase `error:` / `note:` prefix convention rather\n * than the unix-style program-name prefix used by daemon logs.\n *\n * Exit codes (consumed by the CLI to produce specific operator prose):\n *\n * 0 — success; share URL on stdout\n * 1 — server-side config error (STAMP_PUBLIC_URL unset, ExposeAuthInfo\n * missing, etc.)\n * 2 — usage error (missing/bad argv)\n * 3 — caller's role doesn't permit minting (not admin/owner)\n * 4 — short_name already taken in users table\n */\n\nimport { mintInvite } from \"../lib/invites.js\";\nimport {\n findUserByShortName,\n findUserBySshFingerprint,\n openServerDb,\n type InviteRole,\n} from \"../lib/serverDb.js\";\nimport { readAuthenticatedPubkey } from \"../lib/sshUserAuth.js\";\n\nconst SHORT_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,62}$/;\n\ninterface ParsedArgs {\n short_name: string;\n role: InviteRole;\n}\n\nfunction fail(message: string, exitCode: number): never {\n // Lowercase prose prefix matches the CLI convention: this stderr\n // crosses the SSH boundary and lands in the operator's terminal.\n console.error(`error: ${message}`);\n process.exit(exitCode);\n}\n\nfunction parseArgs(argv: string[]): ParsedArgs {\n if (argv.length < 1) {\n fail(\"usage: stamp-mint-invite <short_name> [--role admin|member]\", 2);\n }\n let short_name = \"\";\n let role: InviteRole = \"member\";\n for (let i = 0; i < argv.length; i++) {\n const arg = argv[i]!;\n if (arg === \"--role\") {\n const next = argv[i + 1];\n if (next !== \"admin\" && next !== \"member\") {\n fail(`--role expects 'admin' or 'member' (got ${JSON.stringify(next)})`, 2);\n }\n role = next;\n i++;\n } else if (arg.startsWith(\"--\")) {\n fail(`unknown flag: ${arg}`, 2);\n } else if (!short_name) {\n short_name = arg;\n } else {\n fail(`unexpected positional argument: ${arg}`, 2);\n }\n }\n if (!short_name) {\n fail(\"missing required <short_name> argument\", 2);\n }\n if (!SHORT_NAME_RE.test(short_name)) {\n fail(\n `<short_name> ${JSON.stringify(short_name)} has an invalid shape (allowed: ` +\n `alphanumerics + . _ -, must start with alnum, max 63 chars)`,\n 2,\n );\n }\n return { short_name, role };\n}\n\nfunction publicHostFromUrl(url: string): { host: string; insecure: boolean } {\n // STAMP_PUBLIC_URL examples:\n // https://stamp.example.com (port implicit at 443)\n // https://stamp.example.com:8443\n // http://localhost:8080 (dev — `insecure` flips on)\n // The invite URL omits scheme; the receiving CLI defaults to https.\n // For dev/self-hosted-on-LAN setups the operator points STAMP_PUBLIC_URL\n // at http://...; we prepend `?insecure=1` to the share URL so the CLI\n // knows to use plain HTTP for the accept POST.\n try {\n const u = new URL(url);\n const host = u.host;\n if (!host) throw new Error(\"URL has no host\");\n return { host, insecure: u.protocol === \"http:\" };\n } catch (e) {\n fail(`STAMP_PUBLIC_URL is malformed: ${(e as Error).message}`, 1);\n }\n}\n\nfunction main(): void {\n const publicUrl = process.env[\"STAMP_PUBLIC_URL\"];\n if (!publicUrl) {\n fail(\n \"STAMP_PUBLIC_URL is not set on the server — operator must configure \" +\n \"the externally-reachable HTTP URL before minting invites\",\n 1,\n );\n }\n const publicEndpoint = publicHostFromUrl(publicUrl);\n\n const args = parseArgs(process.argv.slice(2));\n\n const caller = readAuthenticatedPubkey();\n if (!caller) {\n fail(\n \"could not determine authenticated identity (SSH_USER_AUTH unset or \" +\n \"has no publickey entry). Server may be missing 'ExposeAuthInfo yes' \" +\n \"in sshd_config.\",\n 1,\n );\n }\n\n // skipChmod: this wrapper runs as the git user via git-shell, but the\n // DB file is root-owned (chmod fails with EPERM unless caller is owner).\n // entrypoint.sh already tightened perms at boot; the in-process chmod\n // would be redundant even if it could succeed.\n const db = openServerDb({ skipChmod: true });\n try {\n const callerRow = findUserBySshFingerprint(db, caller.fingerprint);\n if (!callerRow) {\n fail(\n `caller fingerprint ${caller.fingerprint} is not in the membership ` +\n `DB — this should be impossible after sshd authenticated them. ` +\n `Likely cause: phase-1 env-var sync hasn't run on this server yet.`,\n 1,\n );\n }\n if (callerRow.role !== \"owner\" && callerRow.role !== \"admin\") {\n fail(\n `role ${callerRow.role} is not permitted to mint invites (need owner or admin)`,\n 3,\n );\n }\n // Strict authority matrix: admins may invite members only. Inviting\n // an admin is \"creating a peer with equal authority\" — that's\n // owner-only by design, so a compromised admin's blast radius\n // stays member-level. Owners can mint any invite role.\n if (callerRow.role === \"admin\" && args.role === \"admin\") {\n fail(\n \"admins may only mint --role member invites; only owners may mint admin invites\",\n 3,\n );\n }\n\n const existing = findUserByShortName(db, args.short_name);\n if (existing) {\n fail(\n `short_name ${JSON.stringify(args.short_name)} is already in use (id=${existing.id} role=${existing.role}). ` +\n `Pick a different name or remove the existing user first.`,\n 4,\n );\n }\n\n const minted = mintInvite(db, {\n role: args.role,\n invited_by: callerRow.id,\n });\n\n const insecureSuffix = publicEndpoint.insecure ? \"?insecure=1\" : \"\";\n const shareUrl = `stamp+invite://${publicEndpoint.host}/${minted.token}${insecureSuffix}`;\n // stdout = the URL only — that's the machine-readable contract the\n // calling CLI captures and prints to the operator.\n process.stdout.write(shareUrl + \"\\n\");\n // stderr = human-readable diagnostic, surfaced inline by the CLI.\n const expiresInMin = Math.round((minted.expires_at - Math.floor(Date.now() / 1000)) / 60);\n process.stderr.write(\n `note: minted invite for short_name=${args.short_name} role=${args.role} ` +\n `(expires in ~${expiresInMin}m, invited_by=${callerRow.short_name})\\n`,\n );\n process.stderr.write(\n `note: invitee runs: stamp invites accept \"${shareUrl}\"\\n`,\n );\n } finally {\n db.close();\n }\n}\n\nmain();\n"],"mappings":"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","names":["import_node_fs","import_node_path","import_node_fs","import_node_crypto"]}
|