@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,473 @@
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
+ function findUserByShortName(db, short_name) {
87
+ const stmt = db.prepare(
88
+ `SELECT id, short_name, ssh_pubkey, ssh_fp, stamp_pubkey, role, source,
89
+ invited_by, created_at, last_seen_at
90
+ FROM users WHERE short_name = ?`
91
+ );
92
+ const row = stmt.get(short_name);
93
+ return row ?? null;
94
+ }
95
+ function countByRole(db, role) {
96
+ const stmt = db.prepare(`SELECT COUNT(*) AS n FROM users WHERE role = ?`);
97
+ const row = stmt.get(role);
98
+ return row.n;
99
+ }
100
+
101
+ // src/lib/userOps.ts
102
+ var VALID_ROLES = /* @__PURE__ */ new Set(["owner", "admin", "member"]);
103
+ function checkSetRoleAuthority(caller, target, newRole, ownerCount) {
104
+ let authority_ok = false;
105
+ if (caller.role === "owner") {
106
+ authority_ok = true;
107
+ } else if (caller.role === "admin") {
108
+ if (ownerCount === 0 && newRole === "owner" && target.id === caller.id) {
109
+ authority_ok = true;
110
+ } else if (target.role === "member" && newRole === "member") {
111
+ authority_ok = true;
112
+ }
113
+ }
114
+ if (!authority_ok) return "caller_lacks_authority";
115
+ if (target.role === "owner" && newRole !== "owner" && ownerCount <= 1) {
116
+ return "last_owner_would_be_lost";
117
+ }
118
+ return null;
119
+ }
120
+ function setUserRole(db, caller, target_short_name, newRole) {
121
+ if (!VALID_ROLES.has(newRole)) {
122
+ return { ok: false, reason: "invalid_target_role" };
123
+ }
124
+ const target = findUserByShortName(db, target_short_name);
125
+ if (!target) return { ok: false, reason: "target_not_found" };
126
+ const ownerCount = countByRole(db, "owner");
127
+ const denial = checkSetRoleAuthority(caller, target, newRole, ownerCount);
128
+ if (denial) return { ok: false, reason: denial };
129
+ const old_role = target.role;
130
+ if (old_role === newRole) {
131
+ return { ok: true, old_role, new_role: newRole, no_change: true };
132
+ }
133
+ db.prepare(`UPDATE users SET role = ? WHERE id = ?`).run(newRole, target.id);
134
+ return { ok: true, old_role, new_role: newRole, no_change: false };
135
+ }
136
+ function removeUser(db, caller, target_short_name) {
137
+ const target = findUserByShortName(db, target_short_name);
138
+ if (!target) return { ok: false, reason: "target_not_found" };
139
+ if (target.id === caller.id) return { ok: false, reason: "cannot_remove_self" };
140
+ if (caller.role === "owner") {
141
+ } else if (caller.role === "admin") {
142
+ if (target.role !== "member") {
143
+ return { ok: false, reason: "caller_lacks_authority" };
144
+ }
145
+ } else {
146
+ return { ok: false, reason: "caller_lacks_authority" };
147
+ }
148
+ const ownerCount = countByRole(db, "owner");
149
+ if (target.role === "owner" && ownerCount <= 1) {
150
+ return { ok: false, reason: "last_owner_would_be_lost" };
151
+ }
152
+ db.prepare(`DELETE FROM users WHERE id = ?`).run(target.id);
153
+ return { ok: true, removed: target };
154
+ }
155
+ function listUsersForCaller(db, _caller) {
156
+ const rows = db.prepare(
157
+ `SELECT id, short_name, ssh_pubkey, ssh_fp, stamp_pubkey, role, source,
158
+ invited_by, created_at, last_seen_at
159
+ FROM users
160
+ ORDER BY
161
+ CASE role WHEN 'owner' THEN 0 WHEN 'admin' THEN 1 ELSE 2 END,
162
+ short_name`
163
+ ).all();
164
+ return { ok: true, users: rows };
165
+ }
166
+
167
+ // src/lib/sshUserAuth.ts
168
+ var import_node_fs3 = require("fs");
169
+
170
+ // src/lib/sshKeys.ts
171
+ var import_node_crypto = require("crypto");
172
+ var ALLOWED_ALGOS = /* @__PURE__ */ new Set([
173
+ "ssh-ed25519",
174
+ "ssh-rsa",
175
+ "ecdsa-sha2-nistp256",
176
+ "ecdsa-sha2-nistp384",
177
+ "ecdsa-sha2-nistp521"
178
+ ]);
179
+ function parseSshPubkey(line) {
180
+ const trimmed = line.trim();
181
+ if (trimmed.length === 0) {
182
+ throw new Error("ssh pubkey line is empty");
183
+ }
184
+ if (trimmed.startsWith("#")) {
185
+ throw new Error("ssh pubkey line is a comment");
186
+ }
187
+ const parts = trimmed.split(/\s+/);
188
+ if (parts.length < 2) {
189
+ throw new Error(
190
+ "ssh pubkey line must have at least <algorithm> <base64> tokens"
191
+ );
192
+ }
193
+ const [algorithm, b64, ...rest] = parts;
194
+ if (!ALLOWED_ALGOS.has(algorithm)) {
195
+ throw new Error(`unsupported ssh pubkey algorithm: ${algorithm}`);
196
+ }
197
+ const keyBlob = Buffer.from(b64, "base64");
198
+ if (keyBlob.length === 0) {
199
+ throw new Error("ssh pubkey base64 blob is empty");
200
+ }
201
+ if (keyBlob.toString("base64").replace(/=+$/, "") !== b64.replace(/=+$/, "")) {
202
+ throw new Error("ssh pubkey base64 blob has trailing junk");
203
+ }
204
+ return {
205
+ algorithm,
206
+ keyBlob,
207
+ comment: rest.join(" "),
208
+ full: trimmed,
209
+ fingerprint: sshFingerprintFromBlob(keyBlob)
210
+ };
211
+ }
212
+ function sshFingerprintFromBlob(keyBlob) {
213
+ const hash = (0, import_node_crypto.createHash)("sha256").update(keyBlob).digest();
214
+ const b64 = hash.toString("base64").replace(/=+$/, "");
215
+ return `SHA256:${b64}`;
216
+ }
217
+
218
+ // src/lib/sshUserAuth.ts
219
+ function readAuthenticatedPubkey() {
220
+ const path = process.env["SSH_USER_AUTH"];
221
+ if (!path) return null;
222
+ let raw;
223
+ try {
224
+ raw = (0, import_node_fs3.readFileSync)(path, "utf8");
225
+ } catch {
226
+ return null;
227
+ }
228
+ for (const line of raw.split("\n")) {
229
+ const trimmed = line.trim();
230
+ if (!trimmed.startsWith("publickey ")) continue;
231
+ const pubkeyLine = trimmed.slice("publickey ".length).trim();
232
+ try {
233
+ return parseSshPubkey(pubkeyLine);
234
+ } catch {
235
+ continue;
236
+ }
237
+ }
238
+ return null;
239
+ }
240
+
241
+ // src/server/users-cli.ts
242
+ var EXIT = {
243
+ OK: 0,
244
+ CONFIG: 1,
245
+ USAGE: 2,
246
+ AUTHORITY: 3,
247
+ NOT_FOUND: 4,
248
+ LAST_OWNER: 5,
249
+ CANNOT_REMOVE_SELF: 6
250
+ };
251
+ function fail(message, code) {
252
+ process.stderr.write(`error: ${message}
253
+ `);
254
+ process.exit(code);
255
+ }
256
+ function usage() {
257
+ process.stderr.write(
258
+ "usage:\n stamp-users list\n stamp-users promote <short_name> --to <admin|owner>\n stamp-users demote <short_name> --to <admin|member>\n stamp-users remove <short_name>\n stamp-users get-stamp-pubkey <short_name>\n"
259
+ );
260
+ process.exit(EXIT.USAGE);
261
+ }
262
+ var VALID_PROMOTE_TARGETS = /* @__PURE__ */ new Set(["admin", "owner"]);
263
+ var VALID_DEMOTE_TARGETS = /* @__PURE__ */ new Set(["admin", "member"]);
264
+ function parseArgs(argv) {
265
+ if (argv.length === 0) usage();
266
+ const [sub, ...rest] = argv;
267
+ if (sub === "list") {
268
+ if (rest.length > 0) fail(`'list' takes no arguments (got ${rest.length})`, EXIT.USAGE);
269
+ return { subcommand: "list" };
270
+ }
271
+ if (sub === "promote" || sub === "demote") {
272
+ let short_name = "";
273
+ let to = "";
274
+ for (let i = 0; i < rest.length; i++) {
275
+ const arg = rest[i];
276
+ if (arg === "--to") {
277
+ const next = rest[i + 1];
278
+ if (!next) fail(`'--to' requires a value`, EXIT.USAGE);
279
+ if (next !== "admin" && next !== "member" && next !== "owner") {
280
+ fail(`--to must be 'admin', 'member', or 'owner' (got ${JSON.stringify(next)})`, EXIT.USAGE);
281
+ }
282
+ to = next;
283
+ i++;
284
+ } else if (arg.startsWith("--")) {
285
+ fail(`unknown flag: ${arg}`, EXIT.USAGE);
286
+ } else if (!short_name) {
287
+ short_name = arg;
288
+ } else {
289
+ fail(`unexpected positional argument: ${arg}`, EXIT.USAGE);
290
+ }
291
+ }
292
+ if (!short_name) fail(`missing <short_name>`, EXIT.USAGE);
293
+ if (!to) fail(`'${sub}' requires --to <role>`, EXIT.USAGE);
294
+ if (sub === "promote" && !VALID_PROMOTE_TARGETS.has(to)) {
295
+ fail(`promote --to must be 'admin' or 'owner' (got '${to}')`, EXIT.USAGE);
296
+ }
297
+ if (sub === "demote" && !VALID_DEMOTE_TARGETS.has(to)) {
298
+ fail(`demote --to must be 'admin' or 'member' (got '${to}')`, EXIT.USAGE);
299
+ }
300
+ return { subcommand: sub, short_name, to };
301
+ }
302
+ if (sub === "remove") {
303
+ if (rest.length === 0) fail(`missing <short_name>`, EXIT.USAGE);
304
+ if (rest.length > 1) fail(`unexpected positional argument: ${rest[1]}`, EXIT.USAGE);
305
+ return { subcommand: "remove", short_name: rest[0] };
306
+ }
307
+ if (sub === "get-stamp-pubkey") {
308
+ if (rest.length === 0) fail(`missing <short_name>`, EXIT.USAGE);
309
+ if (rest.length > 1) fail(`unexpected positional argument: ${rest[1]}`, EXIT.USAGE);
310
+ return { subcommand: "get-stamp-pubkey", short_name: rest[0] };
311
+ }
312
+ fail(`unknown subcommand: ${sub}`, EXIT.USAGE);
313
+ }
314
+ function exitFromSetRoleDenial(reason) {
315
+ switch (reason) {
316
+ case "target_not_found":
317
+ return EXIT.NOT_FOUND;
318
+ case "caller_lacks_authority":
319
+ return EXIT.AUTHORITY;
320
+ case "last_owner_would_be_lost":
321
+ return EXIT.LAST_OWNER;
322
+ case "invalid_target_role":
323
+ return EXIT.USAGE;
324
+ }
325
+ }
326
+ function exitFromRemoveDenial(reason) {
327
+ switch (reason) {
328
+ case "target_not_found":
329
+ return EXIT.NOT_FOUND;
330
+ case "caller_lacks_authority":
331
+ return EXIT.AUTHORITY;
332
+ case "last_owner_would_be_lost":
333
+ return EXIT.LAST_OWNER;
334
+ case "cannot_remove_self":
335
+ return EXIT.CANNOT_REMOVE_SELF;
336
+ }
337
+ }
338
+ function exitFromListDenial(_reason) {
339
+ return EXIT.AUTHORITY;
340
+ }
341
+ function resolveCaller() {
342
+ const pubkey = readAuthenticatedPubkey();
343
+ if (!pubkey) {
344
+ fail(
345
+ "could not determine authenticated identity (SSH_USER_AUTH unset or has no publickey entry). Server may be missing 'ExposeAuthInfo yes' in sshd_config.",
346
+ EXIT.CONFIG
347
+ );
348
+ }
349
+ const db = openServerDb({ skipChmod: true });
350
+ try {
351
+ const caller = findUserBySshFingerprint(db, pubkey.fingerprint);
352
+ if (!caller) {
353
+ fail(
354
+ `caller fingerprint ${pubkey.fingerprint} is not in the membership DB. Likely cause: phase-1 env-var sync hasn't run on this server yet.`,
355
+ EXIT.CONFIG
356
+ );
357
+ }
358
+ return caller;
359
+ } finally {
360
+ db.close();
361
+ }
362
+ }
363
+ function runList() {
364
+ const caller = resolveCaller();
365
+ const db = openServerDb({ skipChmod: true });
366
+ try {
367
+ const result = listUsersForCaller(db, caller);
368
+ if (!result.ok) {
369
+ fail(`listing users failed: ${result.reason}`, exitFromListDenial(result.reason));
370
+ }
371
+ const payload = result.users.map((u) => ({
372
+ id: u.id,
373
+ short_name: u.short_name,
374
+ role: u.role,
375
+ source: u.source,
376
+ ssh_fp: u.ssh_fp,
377
+ has_stamp_pubkey: u.stamp_pubkey !== null,
378
+ invited_by: u.invited_by,
379
+ created_at: u.created_at,
380
+ last_seen_at: u.last_seen_at
381
+ }));
382
+ process.stdout.write(JSON.stringify({ users: payload }) + "\n");
383
+ } finally {
384
+ db.close();
385
+ }
386
+ }
387
+ function runSetRole(parsed) {
388
+ const caller = resolveCaller();
389
+ const db = openServerDb({ skipChmod: true });
390
+ try {
391
+ const result = setUserRole(db, caller, parsed.short_name, parsed.to);
392
+ if (!result.ok) {
393
+ fail(
394
+ `${parsed.subcommand} ${parsed.short_name} --to ${parsed.to}: ${result.reason}`,
395
+ exitFromSetRoleDenial(result.reason)
396
+ );
397
+ }
398
+ if (result.no_change) {
399
+ process.stderr.write(
400
+ `note: ${parsed.short_name} was already ${result.new_role} (no change)
401
+ `
402
+ );
403
+ } else {
404
+ process.stderr.write(
405
+ `note: ${parsed.short_name} ${result.old_role} \u2192 ${result.new_role}
406
+ `
407
+ );
408
+ }
409
+ } finally {
410
+ db.close();
411
+ }
412
+ }
413
+ function runRemove(parsed) {
414
+ const caller = resolveCaller();
415
+ const db = openServerDb({ skipChmod: true });
416
+ try {
417
+ const result = removeUser(db, caller, parsed.short_name);
418
+ if (!result.ok) {
419
+ fail(
420
+ `remove ${parsed.short_name}: ${result.reason}`,
421
+ exitFromRemoveDenial(result.reason)
422
+ );
423
+ }
424
+ process.stderr.write(
425
+ `note: removed ${result.removed.short_name} (was ${result.removed.role})
426
+ `
427
+ );
428
+ } finally {
429
+ db.close();
430
+ }
431
+ }
432
+ function runGetStampPubkey(parsed) {
433
+ resolveCaller();
434
+ const db = openServerDb({ skipChmod: true });
435
+ try {
436
+ const target = findUserByShortName(db, parsed.short_name);
437
+ if (!target) {
438
+ fail(`user ${JSON.stringify(parsed.short_name)} not found`, EXIT.NOT_FOUND);
439
+ }
440
+ if (target.stamp_pubkey === null) {
441
+ fail(
442
+ `user ${JSON.stringify(parsed.short_name)} has no stamp signing pubkey on file \u2014 ask them to re-enroll via stamp invites accept with --stamp-pubkey`,
443
+ EXIT.NOT_FOUND
444
+ );
445
+ }
446
+ process.stdout.write(target.stamp_pubkey);
447
+ if (!target.stamp_pubkey.endsWith("\n")) {
448
+ process.stdout.write("\n");
449
+ }
450
+ } finally {
451
+ db.close();
452
+ }
453
+ }
454
+ function main() {
455
+ const parsed = parseArgs(process.argv.slice(2));
456
+ switch (parsed.subcommand) {
457
+ case "list":
458
+ runList();
459
+ break;
460
+ case "promote":
461
+ case "demote":
462
+ runSetRole(parsed);
463
+ break;
464
+ case "remove":
465
+ runRemove(parsed);
466
+ break;
467
+ case "get-stamp-pubkey":
468
+ runGetStampPubkey(parsed);
469
+ break;
470
+ }
471
+ }
472
+ main();
473
+ //# sourceMappingURL=users-cli.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/lib/serverDb.ts","../../src/lib/paths.ts","../../src/lib/userOps.ts","../../src/lib/sshUserAuth.ts","../../src/lib/sshKeys.ts","../../src/server/users-cli.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 * User-management operations against the membership sqlite, with the\n * role authority matrix and last-owner guard enforced in one place.\n *\n * Authority matrix:\n *\n * - Owner: full control. May change anyone's role to anything (subject\n * to the last-owner guard) and remove anyone (subject to the same).\n * - Admin: may manage MEMBERS only. May not modify admins or owners,\n * and may not promote anyone to admin/owner. Bootstrap exception:\n * when no owners exist anywhere in the table, an admin may promote\n * THEMSELVES to owner — exactly once per server lifetime, since\n * after that promotion the no-owners precondition no longer holds.\n * - Member: no user-management surface at all.\n *\n * Last-owner guard: any operation that would leave zero owners (demoting\n * the last owner, or removing the last owner) is refused with a\n * `last_owner_would_be_lost` reason. Operators are expected to promote a\n * successor first.\n *\n * These rules are server-side and authoritative; the CLI sends the\n * caller's request and the server decides. Putting them in a lib module\n * (not the SSH wrapper) so unit tests can exercise the full matrix\n * without spawning subprocesses.\n */\n\nimport { DatabaseSync } from \"node:sqlite\";\nimport {\n countByRole,\n findUserByShortName,\n type Role,\n type UserRow,\n} from \"./serverDb.js\";\n\nexport type SetRoleDenial =\n | \"target_not_found\"\n | \"caller_lacks_authority\"\n | \"last_owner_would_be_lost\"\n | \"invalid_target_role\";\n\nexport type SetRoleResult =\n | { ok: true; old_role: Role; new_role: Role; no_change: boolean }\n | { ok: false; reason: SetRoleDenial };\n\nconst VALID_ROLES: ReadonlySet<Role> = new Set([\"owner\", \"admin\", \"member\"]);\n\n/**\n * Decide whether `caller` may change `target`'s role to `newRole`.\n * Returns null on approval, or the denial reason string.\n *\n * Authority is checked BEFORE the last-owner guard so a non-authoritative\n * caller gets a generic `caller_lacks_authority` rather than a\n * `last_owner_would_be_lost` reason that would leak \"target is the last\n * owner\" downstream. The guard still applies to authoritative callers\n * (e.g. a sole owner attempting to demote themselves).\n */\nfunction checkSetRoleAuthority(\n caller: UserRow,\n target: UserRow,\n newRole: Role,\n ownerCount: number,\n): SetRoleDenial | null {\n // Authority check first.\n let authority_ok = false;\n if (caller.role === \"owner\") {\n // Owners may set any target to any role.\n authority_ok = true;\n } else if (caller.role === \"admin\") {\n // Bootstrap: zero owners exist and admin is promoting THEMSELVES to\n // owner. This is the chicken-and-egg escape — without it, a server\n // seeded only from AUTHORIZED_KEYS (everyone admin, no owner) has\n // no path to a first owner.\n if (\n ownerCount === 0 &&\n newRole === \"owner\" &&\n target.id === caller.id\n ) {\n authority_ok = true;\n } else if (target.role === \"member\" && newRole === \"member\") {\n // Admins may manage members. Promotion to admin/owner is\n // owner-only; touching admins/owners is also owner-only.\n authority_ok = true;\n }\n }\n if (!authority_ok) return \"caller_lacks_authority\";\n\n // Last-owner guard runs after authority. By construction, only owners\n // can reach this branch with a target that is currently owner.\n if (target.role === \"owner\" && newRole !== \"owner\" && ownerCount <= 1) {\n return \"last_owner_would_be_lost\";\n }\n\n return null;\n}\n\n/**\n * Set `target_short_name`'s role to `newRole`, gated by the authority\n * matrix above. Returns a tagged result so callers can map specific\n * denial reasons to specific HTTP statuses / CLI exit codes.\n */\nexport function setUserRole(\n db: DatabaseSync,\n caller: UserRow,\n target_short_name: string,\n newRole: Role,\n): SetRoleResult {\n if (!VALID_ROLES.has(newRole)) {\n return { ok: false, reason: \"invalid_target_role\" };\n }\n const target = findUserByShortName(db, target_short_name);\n if (!target) return { ok: false, reason: \"target_not_found\" };\n\n const ownerCount = countByRole(db, \"owner\");\n const denial = checkSetRoleAuthority(caller, target, newRole, ownerCount);\n if (denial) return { ok: false, reason: denial };\n\n const old_role = target.role;\n if (old_role === newRole) {\n // No-change is still an approved outcome — the caller had authority\n // for the transition, the row simply already holds the requested\n // role. Reported separately so CLI prose can call it out without\n // pretending a change happened.\n return { ok: true, old_role, new_role: newRole, no_change: true };\n }\n\n db.prepare(`UPDATE users SET role = ? WHERE id = ?`).run(newRole, target.id);\n return { ok: true, old_role, new_role: newRole, no_change: false };\n}\n\nexport type RemoveUserDenial =\n | \"target_not_found\"\n | \"caller_lacks_authority\"\n | \"last_owner_would_be_lost\"\n | \"cannot_remove_self\";\n\nexport type RemoveUserResult =\n | { ok: true; removed: UserRow }\n | { ok: false; reason: RemoveUserDenial };\n\nexport function removeUser(\n db: DatabaseSync,\n caller: UserRow,\n target_short_name: string,\n): RemoveUserResult {\n const target = findUserByShortName(db, target_short_name);\n if (!target) return { ok: false, reason: \"target_not_found\" };\n\n // Self-removal is explicitly disallowed. Operators who want to leave\n // a server should have another admin remove their row — prevents the\n // foot-gun of an admin accidentally deleting themselves mid-session\n // and losing access to fix it.\n if (target.id === caller.id) return { ok: false, reason: \"cannot_remove_self\" };\n\n // Authority first (same reason as setUserRole): a non-authoritative\n // caller gets a generic denial rather than a leak that the target is\n // the last owner.\n if (caller.role === \"owner\") {\n // Owners may remove anyone except via the guards above/below.\n } else if (caller.role === \"admin\") {\n // Admins may remove members only.\n if (target.role !== \"member\") {\n return { ok: false, reason: \"caller_lacks_authority\" };\n }\n } else {\n return { ok: false, reason: \"caller_lacks_authority\" };\n }\n\n // Last-owner guard. With authority already enforced, only owners can\n // reach this branch with a target that is currently owner; combined\n // with the self-removal block above, the guard is structurally\n // shadowed today (an owner can never remove \"the last owner\" who is\n // someone else, because if only one owner exists, the caller IS that\n // owner and was caught above). Kept for defense in depth — a future\n // code path that bypasses cannot_remove_self would otherwise zero out\n // ownership.\n const ownerCount = countByRole(db, \"owner\");\n if (target.role === \"owner\" && ownerCount <= 1) {\n return { ok: false, reason: \"last_owner_would_be_lost\" };\n }\n\n db.prepare(`DELETE FROM users WHERE id = ?`).run(target.id);\n return { ok: true, removed: target };\n}\n\nexport type ListUsersDenial = \"caller_lacks_authority\";\n\nexport type ListUsersResult =\n | { ok: true; users: UserRow[] }\n | { ok: false; reason: ListUsersDenial };\n\n/**\n * List all users. Everyone authenticated (member, admin, owner) may\n * call this. The data exposed (short_name, role, ssh pubkey comments)\n * is the same set teammates would see in any other multi-user\n * collaboration tool; keeping the list freely readable lowers the\n * coordination cost (\"who else has access here?\") without revealing\n * anything sensitive.\n */\nexport function listUsersForCaller(\n db: DatabaseSync,\n // Argument kept for API symmetry with setUserRole/removeUser (and so\n // future role-gated read tiers like \"guest\" have an obvious place to\n // land), but every current `Role` value is permitted to list — the\n // exposed columns are coordination-data (who else has access) not\n // secrets. See doc comment above.\n _caller: UserRow,\n): ListUsersResult {\n const rows = db\n .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\n CASE role WHEN 'owner' THEN 0 WHEN 'admin' THEN 1 ELSE 2 END,\n short_name`,\n )\n .all() as unknown as UserRow[];\n return { ok: true, users: rows };\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 user-management dispatcher, reachable as:\n *\n * ssh git@<host> stamp-users list\n * ssh git@<host> stamp-users promote <name> --to <admin|owner>\n * ssh git@<host> stamp-users demote <name> --to <admin|member>\n * ssh git@<host> stamp-users remove <name>\n *\n * Symlinked into /home/git/git-shell-commands/stamp-users on the server.\n * Authenticates the caller by reading SSH_USER_AUTH (requires\n * ExposeAuthInfo yes in sshd_config — already enabled by phase 2) and\n * dispatches to the authority-matrix-aware lib operations in userOps.ts.\n *\n * Exit codes (consumed by the CLI client for specific operator prose):\n *\n * 0 — success\n * 1 — server-side config error (DB unreadable, identity binding\n * failure, etc.)\n * 2 — usage error (missing/bad argv, unknown subcommand)\n * 3 — authority denial (caller's role doesn't permit the action)\n * 4 — target not found\n * 5 — last-owner-would-be-lost guard\n * 6 — cannot remove self\n *\n * stdout = machine-readable payload (a JSON object for `list`, an empty\n * body for write operations). stderr = human-readable prose using the\n * lowercase `note:` / `error:` convention that crosses the SSH boundary\n * unchanged into the operator's terminal.\n */\n\nimport {\n listUsersForCaller,\n removeUser,\n setUserRole,\n type ListUsersDenial,\n type RemoveUserDenial,\n type SetRoleDenial,\n} from \"../lib/userOps.js\";\nimport {\n findUserByShortName,\n findUserBySshFingerprint,\n openServerDb,\n type Role,\n type UserRow,\n} from \"../lib/serverDb.js\";\nimport { readAuthenticatedPubkey } from \"../lib/sshUserAuth.js\";\n\nconst EXIT = {\n OK: 0,\n CONFIG: 1,\n USAGE: 2,\n AUTHORITY: 3,\n NOT_FOUND: 4,\n LAST_OWNER: 5,\n CANNOT_REMOVE_SELF: 6,\n} as const;\n\nfunction fail(message: string, code: number): never {\n process.stderr.write(`error: ${message}\\n`);\n process.exit(code);\n}\n\nfunction usage(): never {\n process.stderr.write(\n \"usage:\\n\" +\n \" stamp-users list\\n\" +\n \" stamp-users promote <short_name> --to <admin|owner>\\n\" +\n \" stamp-users demote <short_name> --to <admin|member>\\n\" +\n \" stamp-users remove <short_name>\\n\" +\n \" stamp-users get-stamp-pubkey <short_name>\\n\",\n );\n process.exit(EXIT.USAGE);\n}\n\ninterface ParsedSetRole {\n subcommand: \"promote\" | \"demote\";\n short_name: string;\n to: Role;\n}\n\ninterface ParsedRemove {\n subcommand: \"remove\";\n short_name: string;\n}\n\ninterface ParsedList {\n subcommand: \"list\";\n}\n\ninterface ParsedGetStampPubkey {\n subcommand: \"get-stamp-pubkey\";\n short_name: string;\n}\n\ntype Parsed =\n | ParsedSetRole\n | ParsedRemove\n | ParsedList\n | ParsedGetStampPubkey;\n\nconst VALID_PROMOTE_TARGETS: ReadonlySet<Role> = new Set([\"admin\", \"owner\"]);\nconst VALID_DEMOTE_TARGETS: ReadonlySet<Role> = new Set([\"admin\", \"member\"]);\n\nfunction parseArgs(argv: string[]): Parsed {\n if (argv.length === 0) usage();\n const [sub, ...rest] = argv as [string, ...string[]];\n if (sub === \"list\") {\n if (rest.length > 0) fail(`'list' takes no arguments (got ${rest.length})`, EXIT.USAGE);\n return { subcommand: \"list\" };\n }\n if (sub === \"promote\" || sub === \"demote\") {\n let short_name = \"\";\n let to: Role | \"\" = \"\";\n for (let i = 0; i < rest.length; i++) {\n const arg = rest[i]!;\n if (arg === \"--to\") {\n const next = rest[i + 1];\n if (!next) fail(`'--to' requires a value`, EXIT.USAGE);\n if (next !== \"admin\" && next !== \"member\" && next !== \"owner\") {\n fail(`--to must be 'admin', 'member', or 'owner' (got ${JSON.stringify(next)})`, EXIT.USAGE);\n }\n to = next;\n i++;\n } else if (arg.startsWith(\"--\")) {\n fail(`unknown flag: ${arg}`, EXIT.USAGE);\n } else if (!short_name) {\n short_name = arg;\n } else {\n fail(`unexpected positional argument: ${arg}`, EXIT.USAGE);\n }\n }\n if (!short_name) fail(`missing <short_name>`, EXIT.USAGE);\n if (!to) fail(`'${sub}' requires --to <role>`, EXIT.USAGE);\n if (sub === \"promote\" && !VALID_PROMOTE_TARGETS.has(to)) {\n fail(`promote --to must be 'admin' or 'owner' (got '${to}')`, EXIT.USAGE);\n }\n if (sub === \"demote\" && !VALID_DEMOTE_TARGETS.has(to)) {\n fail(`demote --to must be 'admin' or 'member' (got '${to}')`, EXIT.USAGE);\n }\n return { subcommand: sub, short_name, to };\n }\n if (sub === \"remove\") {\n if (rest.length === 0) fail(`missing <short_name>`, EXIT.USAGE);\n if (rest.length > 1) fail(`unexpected positional argument: ${rest[1]}`, EXIT.USAGE);\n return { subcommand: \"remove\", short_name: rest[0]! };\n }\n if (sub === \"get-stamp-pubkey\") {\n if (rest.length === 0) fail(`missing <short_name>`, EXIT.USAGE);\n if (rest.length > 1) fail(`unexpected positional argument: ${rest[1]}`, EXIT.USAGE);\n return { subcommand: \"get-stamp-pubkey\", short_name: rest[0]! };\n }\n fail(`unknown subcommand: ${sub}`, EXIT.USAGE);\n}\n\nfunction exitFromSetRoleDenial(reason: SetRoleDenial): number {\n switch (reason) {\n case \"target_not_found\":\n return EXIT.NOT_FOUND;\n case \"caller_lacks_authority\":\n return EXIT.AUTHORITY;\n case \"last_owner_would_be_lost\":\n return EXIT.LAST_OWNER;\n case \"invalid_target_role\":\n return EXIT.USAGE;\n }\n}\n\nfunction exitFromRemoveDenial(reason: RemoveUserDenial): number {\n switch (reason) {\n case \"target_not_found\":\n return EXIT.NOT_FOUND;\n case \"caller_lacks_authority\":\n return EXIT.AUTHORITY;\n case \"last_owner_would_be_lost\":\n return EXIT.LAST_OWNER;\n case \"cannot_remove_self\":\n return EXIT.CANNOT_REMOVE_SELF;\n }\n}\n\nfunction exitFromListDenial(_reason: ListUsersDenial): number {\n return EXIT.AUTHORITY;\n}\n\nfunction resolveCaller(): UserRow {\n const pubkey = readAuthenticatedPubkey();\n if (!pubkey) {\n fail(\n \"could not determine authenticated identity (SSH_USER_AUTH unset or has no publickey entry). \" +\n \"Server may be missing 'ExposeAuthInfo yes' in sshd_config.\",\n EXIT.CONFIG,\n );\n }\n // skipChmod: this wrapper runs as the git user, root-owned file; entrypoint.sh\n // handles boot-time perm tightening. See src/lib/serverDb.ts comment.\n const db = openServerDb({ skipChmod: true });\n try {\n const caller = findUserBySshFingerprint(db, pubkey.fingerprint);\n if (!caller) {\n fail(\n `caller fingerprint ${pubkey.fingerprint} is not in the membership DB. ` +\n `Likely cause: phase-1 env-var sync hasn't run on this server yet.`,\n EXIT.CONFIG,\n );\n }\n return caller;\n } finally {\n db.close();\n }\n}\n\nfunction runList(): void {\n const caller = resolveCaller();\n const db = openServerDb({ skipChmod: true });\n try {\n const result = listUsersForCaller(db, caller);\n if (!result.ok) {\n fail(`listing users failed: ${result.reason}`, exitFromListDenial(result.reason));\n }\n // JSON output for the CLI to format. Includes ssh_pubkey for\n // human-readable comment but excludes any future secret-bearing\n // fields (last_seen_at is operational metadata, included).\n const payload = result.users.map((u) => ({\n id: u.id,\n short_name: u.short_name,\n role: u.role,\n source: u.source,\n ssh_fp: u.ssh_fp,\n has_stamp_pubkey: u.stamp_pubkey !== null,\n invited_by: u.invited_by,\n created_at: u.created_at,\n last_seen_at: u.last_seen_at,\n }));\n process.stdout.write(JSON.stringify({ users: payload }) + \"\\n\");\n } finally {\n db.close();\n }\n}\n\nfunction runSetRole(parsed: ParsedSetRole): void {\n const caller = resolveCaller();\n const db = openServerDb({ skipChmod: true });\n try {\n const result = setUserRole(db, caller, parsed.short_name, parsed.to);\n if (!result.ok) {\n fail(\n `${parsed.subcommand} ${parsed.short_name} --to ${parsed.to}: ${result.reason}`,\n exitFromSetRoleDenial(result.reason),\n );\n }\n if (result.no_change) {\n process.stderr.write(\n `note: ${parsed.short_name} was already ${result.new_role} (no change)\\n`,\n );\n } else {\n process.stderr.write(\n `note: ${parsed.short_name} ${result.old_role} → ${result.new_role}\\n`,\n );\n }\n } finally {\n db.close();\n }\n}\n\nfunction runRemove(parsed: ParsedRemove): void {\n const caller = resolveCaller();\n const db = openServerDb({ skipChmod: true });\n try {\n const result = removeUser(db, caller, parsed.short_name);\n if (!result.ok) {\n fail(\n `remove ${parsed.short_name}: ${result.reason}`,\n exitFromRemoveDenial(result.reason),\n );\n }\n process.stderr.write(\n `note: removed ${result.removed.short_name} (was ${result.removed.role})\\n`,\n );\n } finally {\n db.close();\n }\n}\n\nfunction runGetStampPubkey(parsed: ParsedGetStampPubkey): void {\n // Identity binding still required (so this surface stays consistent\n // with the rest of stamp-users — only authenticated users can read\n // the membership DB) but no role check beyond \"you're enrolled\".\n // The phase-4 trust-grant flow goes through the standard stamp gate\n // anyway, so an enrolled member who fetches a peer's stamp_pubkey\n // can't unilaterally widen anyone's trust.\n resolveCaller();\n const db = openServerDb({ skipChmod: true });\n try {\n const target = findUserByShortName(db, parsed.short_name);\n if (!target) {\n fail(`user ${JSON.stringify(parsed.short_name)} not found`, EXIT.NOT_FOUND);\n }\n if (target.stamp_pubkey === null) {\n fail(\n `user ${JSON.stringify(parsed.short_name)} has no stamp signing pubkey on file ` +\n `— ask them to re-enroll via stamp invites accept with --stamp-pubkey`,\n EXIT.NOT_FOUND,\n );\n }\n // PEM goes to stdout exactly as stored. The receiving CLI pipes\n // this verbatim into the repo's .stamp/trusted-keys/<name>.pub\n // file, so any drift here is observable in the next diff review.\n process.stdout.write(target.stamp_pubkey);\n if (!target.stamp_pubkey.endsWith(\"\\n\")) {\n process.stdout.write(\"\\n\");\n }\n } finally {\n db.close();\n }\n}\n\nfunction main(): void {\n const parsed = parseArgs(process.argv.slice(2));\n switch (parsed.subcommand) {\n case \"list\":\n runList();\n break;\n case \"promote\":\n case \"demote\":\n runSetRole(parsed);\n break;\n case \"remove\":\n runRemove(parsed);\n break;\n case \"get-stamp-pubkey\":\n runGetStampPubkey(parsed);\n break;\n }\n}\n\nmain();\n"],"mappings":"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","names":["import_node_fs","import_node_path","import_node_fs"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openthink/stamp",
3
- "version": "1.3.1",
3
+ "version": "1.5.0",
4
4
  "type": "module",
5
5
  "description": "Local, headless pull-request system for agent-to-agent code review workflows",
6
6
  "bin": {
@@ -10,7 +10,7 @@
10
10
  "dist"
11
11
  ],
12
12
  "scripts": {
13
- "build": "tsup && node -e \"require('fs').readdirSync('dist',{recursive:true,withFileTypes:true}).filter(e=>e.isFile()&&/\\.(js|cjs)$/.test(e.name)).forEach(e=>{const p=require('path').join(e.parentPath||e.path,e.name);const c=require('fs').readFileSync(p,'utf8').replace(/from \\\"sqlite\\\"/g,'from \\\"node:sqlite\\\"');require('fs').writeFileSync(p,c)})\"",
13
+ "build": "tsup && node -e \"require('fs').readdirSync('dist',{recursive:true,withFileTypes:true}).filter(e=>e.isFile()&&/\\.(js|cjs)$/.test(e.name)).forEach(e=>{const p=require('path').join(e.parentPath||e.path,e.name);const c=require('fs').readFileSync(p,'utf8').replace(/from \\\"sqlite\\\"/g,'from \\\"node:sqlite\\\"').replace(/require\\(\\\"sqlite\\\"\\)/g,'require(\\\"node:sqlite\\\")');require('fs').writeFileSync(p,c)})\"",
14
14
  "dev": "tsx src/index.ts",
15
15
  "typecheck": "tsc --noEmit",
16
16
  "check-conventions": "bash scripts/check-conventions.sh",