@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,400 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
// src/server/http-server.ts
|
|
5
|
+
var import_node_http = require("http");
|
|
6
|
+
|
|
7
|
+
// src/lib/invites.ts
|
|
8
|
+
var import_node_crypto = require("crypto");
|
|
9
|
+
var INVITE_TTL_SECONDS = 15 * 60;
|
|
10
|
+
function consumeInviteToken(db, token, now) {
|
|
11
|
+
const wallclock = now ?? Math.floor(Date.now() / 1e3);
|
|
12
|
+
db.exec("BEGIN IMMEDIATE");
|
|
13
|
+
try {
|
|
14
|
+
const selectStmt = db.prepare(`SELECT * FROM invites WHERE token = ?`);
|
|
15
|
+
const row = selectStmt.get(token);
|
|
16
|
+
if (!row) {
|
|
17
|
+
db.exec("ROLLBACK");
|
|
18
|
+
return { ok: false, reason: "not_found" };
|
|
19
|
+
}
|
|
20
|
+
if (row.consumed_at !== null) {
|
|
21
|
+
db.exec("ROLLBACK");
|
|
22
|
+
return { ok: false, reason: "already_consumed" };
|
|
23
|
+
}
|
|
24
|
+
if (row.expires_at < wallclock) {
|
|
25
|
+
db.exec("ROLLBACK");
|
|
26
|
+
return { ok: false, reason: "expired" };
|
|
27
|
+
}
|
|
28
|
+
db.prepare(
|
|
29
|
+
`UPDATE invites SET consumed_at = ? WHERE token = ? AND consumed_at IS NULL`
|
|
30
|
+
).run(wallclock, token);
|
|
31
|
+
db.exec("COMMIT");
|
|
32
|
+
return { ok: true, row };
|
|
33
|
+
} catch (e) {
|
|
34
|
+
try {
|
|
35
|
+
db.exec("ROLLBACK");
|
|
36
|
+
} catch {
|
|
37
|
+
}
|
|
38
|
+
throw e;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
function markInviteConsumer(db, token, user_id) {
|
|
42
|
+
db.prepare(`UPDATE invites SET consumed_by = ? WHERE token = ?`).run(
|
|
43
|
+
user_id,
|
|
44
|
+
token
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// src/lib/serverDb.ts
|
|
49
|
+
var import_node_fs2 = require("fs");
|
|
50
|
+
var import_node_sqlite = require("node:sqlite");
|
|
51
|
+
var import_node_path2 = require("path");
|
|
52
|
+
|
|
53
|
+
// src/lib/paths.ts
|
|
54
|
+
var import_node_fs = require("fs");
|
|
55
|
+
var import_node_os = require("os");
|
|
56
|
+
var import_node_path = require("path");
|
|
57
|
+
function ensureDir(path, mode = 493) {
|
|
58
|
+
if (!(0, import_node_fs.existsSync)(path)) {
|
|
59
|
+
(0, import_node_fs.mkdirSync)(path, { recursive: true, mode });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// src/lib/serverDb.ts
|
|
64
|
+
var DEFAULT_SERVER_DB_PATH = "/srv/git/.stamp-state/users.db";
|
|
65
|
+
function resolveServerDbPath(explicit) {
|
|
66
|
+
if (explicit) return explicit;
|
|
67
|
+
const envPath = process.env["STAMP_SERVER_DB_PATH"];
|
|
68
|
+
if (envPath && envPath.length > 0) return envPath;
|
|
69
|
+
return DEFAULT_SERVER_DB_PATH;
|
|
70
|
+
}
|
|
71
|
+
function openServerDb(opts = {}) {
|
|
72
|
+
const path = resolveServerDbPath(opts.path);
|
|
73
|
+
const readOnly = opts.readOnly ?? false;
|
|
74
|
+
if (!readOnly) {
|
|
75
|
+
const dir = (0, import_node_path2.dirname)(path);
|
|
76
|
+
ensureDir(dir, 488);
|
|
77
|
+
if (!opts.skipChmod) {
|
|
78
|
+
(0, import_node_fs2.chmodSync)(dir, 488);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
const db = new import_node_sqlite.DatabaseSync(path, { readOnly });
|
|
82
|
+
if (!readOnly) {
|
|
83
|
+
db.exec("PRAGMA foreign_keys = ON");
|
|
84
|
+
initSchema(db);
|
|
85
|
+
if (!opts.skipChmod && (0, import_node_fs2.existsSync)(path)) {
|
|
86
|
+
(0, import_node_fs2.chmodSync)(path, 432);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return db;
|
|
90
|
+
}
|
|
91
|
+
function initSchema(db) {
|
|
92
|
+
db.exec(`
|
|
93
|
+
CREATE TABLE IF NOT EXISTS users (
|
|
94
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
95
|
+
short_name TEXT NOT NULL UNIQUE,
|
|
96
|
+
ssh_pubkey TEXT NOT NULL,
|
|
97
|
+
ssh_fp TEXT NOT NULL UNIQUE,
|
|
98
|
+
stamp_pubkey TEXT,
|
|
99
|
+
role TEXT NOT NULL CHECK (role IN ('owner','admin','member')),
|
|
100
|
+
source TEXT NOT NULL DEFAULT 'invite' CHECK (source IN ('env','bootstrap','invite','manual')),
|
|
101
|
+
invited_by INTEGER REFERENCES users(id),
|
|
102
|
+
created_at INTEGER NOT NULL,
|
|
103
|
+
last_seen_at INTEGER
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
CREATE INDEX IF NOT EXISTS idx_users_ssh_fp ON users(ssh_fp);
|
|
107
|
+
|
|
108
|
+
CREATE TABLE IF NOT EXISTS invites (
|
|
109
|
+
token TEXT PRIMARY KEY,
|
|
110
|
+
role TEXT NOT NULL CHECK (role IN ('admin','member')),
|
|
111
|
+
invited_by INTEGER NOT NULL REFERENCES users(id),
|
|
112
|
+
created_at INTEGER NOT NULL,
|
|
113
|
+
expires_at INTEGER NOT NULL,
|
|
114
|
+
consumed_at INTEGER,
|
|
115
|
+
consumed_by INTEGER REFERENCES users(id)
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
CREATE INDEX IF NOT EXISTS idx_invites_expires ON invites(expires_at);
|
|
119
|
+
`);
|
|
120
|
+
}
|
|
121
|
+
function insertUser(db, input) {
|
|
122
|
+
const stmt = db.prepare(
|
|
123
|
+
`INSERT INTO users (short_name, ssh_pubkey, ssh_fp, stamp_pubkey, role, source, invited_by, created_at)
|
|
124
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
|
125
|
+
);
|
|
126
|
+
const result = stmt.run(
|
|
127
|
+
input.short_name,
|
|
128
|
+
input.ssh_pubkey,
|
|
129
|
+
input.ssh_fp,
|
|
130
|
+
input.stamp_pubkey ?? null,
|
|
131
|
+
input.role,
|
|
132
|
+
input.source,
|
|
133
|
+
input.invited_by ?? null,
|
|
134
|
+
Math.floor(Date.now() / 1e3)
|
|
135
|
+
);
|
|
136
|
+
return Number(result.lastInsertRowid);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// src/lib/sshKeys.ts
|
|
140
|
+
var import_node_crypto2 = require("crypto");
|
|
141
|
+
var ALLOWED_ALGOS = /* @__PURE__ */ new Set([
|
|
142
|
+
"ssh-ed25519",
|
|
143
|
+
"ssh-rsa",
|
|
144
|
+
"ecdsa-sha2-nistp256",
|
|
145
|
+
"ecdsa-sha2-nistp384",
|
|
146
|
+
"ecdsa-sha2-nistp521"
|
|
147
|
+
]);
|
|
148
|
+
function parseSshPubkey(line) {
|
|
149
|
+
const trimmed = line.trim();
|
|
150
|
+
if (trimmed.length === 0) {
|
|
151
|
+
throw new Error("ssh pubkey line is empty");
|
|
152
|
+
}
|
|
153
|
+
if (trimmed.startsWith("#")) {
|
|
154
|
+
throw new Error("ssh pubkey line is a comment");
|
|
155
|
+
}
|
|
156
|
+
const parts = trimmed.split(/\s+/);
|
|
157
|
+
if (parts.length < 2) {
|
|
158
|
+
throw new Error(
|
|
159
|
+
"ssh pubkey line must have at least <algorithm> <base64> tokens"
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
const [algorithm, b64, ...rest] = parts;
|
|
163
|
+
if (!ALLOWED_ALGOS.has(algorithm)) {
|
|
164
|
+
throw new Error(`unsupported ssh pubkey algorithm: ${algorithm}`);
|
|
165
|
+
}
|
|
166
|
+
const keyBlob = Buffer.from(b64, "base64");
|
|
167
|
+
if (keyBlob.length === 0) {
|
|
168
|
+
throw new Error("ssh pubkey base64 blob is empty");
|
|
169
|
+
}
|
|
170
|
+
if (keyBlob.toString("base64").replace(/=+$/, "") !== b64.replace(/=+$/, "")) {
|
|
171
|
+
throw new Error("ssh pubkey base64 blob has trailing junk");
|
|
172
|
+
}
|
|
173
|
+
return {
|
|
174
|
+
algorithm,
|
|
175
|
+
keyBlob,
|
|
176
|
+
comment: rest.join(" "),
|
|
177
|
+
full: trimmed,
|
|
178
|
+
fingerprint: sshFingerprintFromBlob(keyBlob)
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
function sshFingerprintFromBlob(keyBlob) {
|
|
182
|
+
const hash = (0, import_node_crypto2.createHash)("sha256").update(keyBlob).digest();
|
|
183
|
+
const b64 = hash.toString("base64").replace(/=+$/, "");
|
|
184
|
+
return `SHA256:${b64}`;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// src/server/http-server.ts
|
|
188
|
+
var DEFAULT_PORT = 8080;
|
|
189
|
+
var MAX_BODY_BYTES = 16 * 1024;
|
|
190
|
+
var SHORT_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,62}$/;
|
|
191
|
+
var STAMP_PUBKEY_PEM_RE = /^\s*-----BEGIN PUBLIC KEY-----[A-Za-z0-9+/=\s]+-----END PUBLIC KEY-----\s*$/;
|
|
192
|
+
function logLine(level, msg) {
|
|
193
|
+
const ts = (/* @__PURE__ */ new Date()).toISOString();
|
|
194
|
+
const stream = level === "error" ? process.stderr : process.stdout;
|
|
195
|
+
stream.write(`stamp-http-server ${ts} ${level} ${msg}
|
|
196
|
+
`);
|
|
197
|
+
}
|
|
198
|
+
function sendJson(res, status, body) {
|
|
199
|
+
const payload = JSON.stringify(body);
|
|
200
|
+
res.writeHead(status, {
|
|
201
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
202
|
+
"Content-Length": Buffer.byteLength(payload).toString(),
|
|
203
|
+
// Hint to any future reverse proxy that responses here aren't cacheable
|
|
204
|
+
// (they reflect single-use token state).
|
|
205
|
+
"Cache-Control": "no-store"
|
|
206
|
+
});
|
|
207
|
+
res.end(payload);
|
|
208
|
+
}
|
|
209
|
+
async function readBody(req) {
|
|
210
|
+
return new Promise((resolve2, reject) => {
|
|
211
|
+
const chunks = [];
|
|
212
|
+
let total = 0;
|
|
213
|
+
let tooLarge = false;
|
|
214
|
+
req.on("data", (chunk) => {
|
|
215
|
+
if (tooLarge) return;
|
|
216
|
+
total += chunk.length;
|
|
217
|
+
if (total > MAX_BODY_BYTES) {
|
|
218
|
+
tooLarge = true;
|
|
219
|
+
chunks.length = 0;
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
chunks.push(chunk);
|
|
223
|
+
});
|
|
224
|
+
req.on("end", () => resolve2({ buf: Buffer.concat(chunks), tooLarge }));
|
|
225
|
+
req.on("error", reject);
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
function validateAcceptBody(body) {
|
|
229
|
+
if (typeof body.token !== "string" || body.token.length === 0) {
|
|
230
|
+
return { ok: false, status: 400, error: "token_required" };
|
|
231
|
+
}
|
|
232
|
+
if (body.token.length > 128) {
|
|
233
|
+
return { ok: false, status: 400, error: "token_malformed" };
|
|
234
|
+
}
|
|
235
|
+
if (typeof body.ssh_pubkey !== "string" || body.ssh_pubkey.length === 0) {
|
|
236
|
+
return { ok: false, status: 400, error: "ssh_pubkey_required" };
|
|
237
|
+
}
|
|
238
|
+
if (typeof body.short_name !== "string" || !SHORT_NAME_RE.test(body.short_name)) {
|
|
239
|
+
return { ok: false, status: 400, error: "short_name_malformed" };
|
|
240
|
+
}
|
|
241
|
+
let parsed;
|
|
242
|
+
try {
|
|
243
|
+
parsed = parseSshPubkey(body.ssh_pubkey);
|
|
244
|
+
} catch (e) {
|
|
245
|
+
return {
|
|
246
|
+
ok: false,
|
|
247
|
+
status: 400,
|
|
248
|
+
error: `ssh_pubkey_invalid: ${e.message}`
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
let stamp_pubkey = null;
|
|
252
|
+
if (body.stamp_pubkey !== void 0 && body.stamp_pubkey !== null) {
|
|
253
|
+
if (typeof body.stamp_pubkey !== "string") {
|
|
254
|
+
return { ok: false, status: 400, error: "stamp_pubkey_malformed" };
|
|
255
|
+
}
|
|
256
|
+
if (!STAMP_PUBKEY_PEM_RE.test(body.stamp_pubkey)) {
|
|
257
|
+
return { ok: false, status: 400, error: "stamp_pubkey_not_pem" };
|
|
258
|
+
}
|
|
259
|
+
stamp_pubkey = body.stamp_pubkey;
|
|
260
|
+
}
|
|
261
|
+
return {
|
|
262
|
+
ok: true,
|
|
263
|
+
data: {
|
|
264
|
+
token: body.token,
|
|
265
|
+
ssh_pubkey: parsed.full,
|
|
266
|
+
ssh_fp: parsed.fingerprint,
|
|
267
|
+
short_name: body.short_name,
|
|
268
|
+
stamp_pubkey
|
|
269
|
+
}
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
function acceptInvite(data) {
|
|
273
|
+
const db = openServerDb({ skipChmod: true });
|
|
274
|
+
try {
|
|
275
|
+
const consumed = consumeInviteToken(db, data.token);
|
|
276
|
+
if (!consumed.ok) {
|
|
277
|
+
const statusByReason = {
|
|
278
|
+
not_found: 404,
|
|
279
|
+
expired: 410,
|
|
280
|
+
already_consumed: 410
|
|
281
|
+
};
|
|
282
|
+
return {
|
|
283
|
+
status: statusByReason[consumed.reason],
|
|
284
|
+
body: { ok: false, error: `invite_${consumed.reason}` }
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
let user_id;
|
|
288
|
+
try {
|
|
289
|
+
user_id = insertUser(db, {
|
|
290
|
+
short_name: data.short_name,
|
|
291
|
+
ssh_pubkey: data.ssh_pubkey,
|
|
292
|
+
ssh_fp: data.ssh_fp,
|
|
293
|
+
stamp_pubkey: data.stamp_pubkey,
|
|
294
|
+
role: consumed.row.role,
|
|
295
|
+
source: "invite",
|
|
296
|
+
invited_by: consumed.row.invited_by
|
|
297
|
+
});
|
|
298
|
+
} catch (e) {
|
|
299
|
+
const msg = e.message;
|
|
300
|
+
if (msg.includes("users.ssh_fp")) {
|
|
301
|
+
return {
|
|
302
|
+
status: 409,
|
|
303
|
+
body: { ok: false, error: "ssh_pubkey_already_registered" }
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
if (msg.includes("users.short_name")) {
|
|
307
|
+
return {
|
|
308
|
+
status: 409,
|
|
309
|
+
body: { ok: false, error: "short_name_taken" }
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
throw e;
|
|
313
|
+
}
|
|
314
|
+
markInviteConsumer(db, data.token, user_id);
|
|
315
|
+
return {
|
|
316
|
+
status: 200,
|
|
317
|
+
body: {
|
|
318
|
+
ok: true,
|
|
319
|
+
user_id,
|
|
320
|
+
role: consumed.row.role,
|
|
321
|
+
short_name: data.short_name
|
|
322
|
+
}
|
|
323
|
+
};
|
|
324
|
+
} finally {
|
|
325
|
+
db.close();
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
async function handlePost(req, res) {
|
|
329
|
+
if (req.headers["content-type"]?.split(";")[0]?.trim() !== "application/json") {
|
|
330
|
+
sendJson(res, 415, { ok: false, error: "content_type_must_be_application_json" });
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
let read;
|
|
334
|
+
try {
|
|
335
|
+
read = await readBody(req);
|
|
336
|
+
} catch (e) {
|
|
337
|
+
logLine("warn", `read body failed: ${e.message}`);
|
|
338
|
+
sendJson(res, 400, { ok: false, error: "body_read_failed" });
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
if (read.tooLarge) {
|
|
342
|
+
sendJson(res, 413, { ok: false, error: "body_too_large" });
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
let body;
|
|
346
|
+
try {
|
|
347
|
+
body = JSON.parse(read.buf.toString("utf8"));
|
|
348
|
+
} catch {
|
|
349
|
+
sendJson(res, 400, { ok: false, error: "body_not_json" });
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
const v = validateAcceptBody(body);
|
|
353
|
+
if (!v.ok) {
|
|
354
|
+
sendJson(res, v.status, { ok: false, error: v.error });
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
try {
|
|
358
|
+
const outcome = acceptInvite(v.data);
|
|
359
|
+
logLine(
|
|
360
|
+
outcome.status === 200 ? "info" : "warn",
|
|
361
|
+
`invite/accept short_name=${v.data.short_name} status=${outcome.status}`
|
|
362
|
+
);
|
|
363
|
+
sendJson(res, outcome.status, outcome.body);
|
|
364
|
+
} catch (e) {
|
|
365
|
+
logLine("error", `invite/accept internal error: ${e.message}`);
|
|
366
|
+
sendJson(res, 500, { ok: false, error: "internal_error" });
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
var HTTP_DEFAULT_PORT = DEFAULT_PORT;
|
|
370
|
+
function startServer(port2 = DEFAULT_PORT) {
|
|
371
|
+
const server = (0, import_node_http.createServer)((req, res) => {
|
|
372
|
+
const url = req.url ?? "";
|
|
373
|
+
if (req.method === "GET" && (url === "/healthz" || url === "/")) {
|
|
374
|
+
sendJson(res, 200, { ok: true });
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
if (req.method === "POST" && url === "/invite/accept") {
|
|
378
|
+
void handlePost(req, res);
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
sendJson(res, 404, { ok: false, error: "not_found" });
|
|
382
|
+
});
|
|
383
|
+
server.listen(port2, () => {
|
|
384
|
+
logLine("info", `listening on :${port2}`);
|
|
385
|
+
});
|
|
386
|
+
return server;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// src/server/start-http-server.ts
|
|
390
|
+
var rawPort = process.env["STAMP_HTTP_PORT"];
|
|
391
|
+
var port = rawPort ? Number(rawPort) : HTTP_DEFAULT_PORT;
|
|
392
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
393
|
+
process.stderr.write(
|
|
394
|
+
`stamp-http-server: STAMP_HTTP_PORT must be an integer 1..65535 (got ${JSON.stringify(rawPort)})
|
|
395
|
+
`
|
|
396
|
+
);
|
|
397
|
+
process.exit(2);
|
|
398
|
+
}
|
|
399
|
+
startServer(port);
|
|
400
|
+
//# sourceMappingURL=start-http-server.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/server/http-server.ts","../../src/lib/invites.ts","../../src/lib/serverDb.ts","../../src/lib/paths.ts","../../src/lib/sshKeys.ts","../../src/server/start-http-server.ts"],"sourcesContent":["/**\n * Stamp server HTTP listener — runs alongside sshd, exposes the invite-\n * accept endpoint so a new operator can redeem a token without first\n * having SSH access.\n *\n * Started by entrypoint.sh as the `git` user (which owns write access to\n * the membership sqlite via the root:git 0660 mode bits). Plain HTTP on\n * STAMP_HTTP_PORT (default 8080); TLS is the hosting platform's job\n * (Railway terminates TLS at its edge proxy; self-hosters terminate at\n * their own reverse proxy).\n *\n * Endpoints:\n *\n * POST /invite/accept\n * body: {token, ssh_pubkey, short_name, stamp_pubkey?}\n * 200: {ok:true, user_id, role, short_name}\n * 4xx: {ok:false, error:\"<reason>\"}\n *\n * GET /healthz\n * 200: {ok:true} — for orchestrator probes\n *\n * Hard cap on request body at 16 KiB. Sshd-style fail-open does NOT apply\n * here: this surface is the only path that mutates the users table from a\n * non-root context, so we surface real status codes and refuse the request\n * on any malformed input.\n */\n\nimport { createServer, type IncomingMessage, type ServerResponse } from \"node:http\";\nimport { consumeInviteToken, markInviteConsumer } from \"../lib/invites.js\";\nimport { insertUser, openServerDb } from \"../lib/serverDb.js\";\nimport { parseSshPubkey } from \"../lib/sshKeys.js\";\n\nconst DEFAULT_PORT = 8080;\nconst MAX_BODY_BYTES = 16 * 1024;\nconst SHORT_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,62}$/;\n// Stamp signing pubkeys are PEM-wrapped SPKI. Loose shape check here\n// (anchored so trailing/leading garbage doesn't slip in); the real\n// validation happens at trust-grant time when the key is consumed.\nconst STAMP_PUBKEY_PEM_RE =\n /^\\s*-----BEGIN PUBLIC KEY-----[A-Za-z0-9+/=\\s]+-----END PUBLIC KEY-----\\s*$/;\n\ninterface AcceptBody {\n token?: unknown;\n ssh_pubkey?: unknown;\n stamp_pubkey?: unknown;\n short_name?: unknown;\n}\n\nfunction logLine(level: \"info\" | \"warn\" | \"error\", msg: string): void {\n const ts = new Date().toISOString();\n const stream = level === \"error\" ? process.stderr : process.stdout;\n stream.write(`stamp-http-server ${ts} ${level} ${msg}\\n`);\n}\n\nfunction sendJson(\n res: ServerResponse,\n status: number,\n body: Record<string, unknown>,\n): void {\n const payload = JSON.stringify(body);\n res.writeHead(status, {\n \"Content-Type\": \"application/json; charset=utf-8\",\n \"Content-Length\": Buffer.byteLength(payload).toString(),\n // Hint to any future reverse proxy that responses here aren't cacheable\n // (they reflect single-use token state).\n \"Cache-Control\": \"no-store\",\n });\n res.end(payload);\n}\n\ninterface ReadBodyResult {\n buf: Buffer;\n tooLarge: boolean;\n}\n\nasync function readBody(req: IncomingMessage): Promise<ReadBodyResult> {\n return new Promise((resolve, reject) => {\n const chunks: Buffer[] = [];\n let total = 0;\n let tooLarge = false;\n req.on(\"data\", (chunk: Buffer) => {\n if (tooLarge) return;\n total += chunk.length;\n if (total > MAX_BODY_BYTES) {\n // Stop accumulating; the caller produces a 413 keyed on\n // `tooLarge` so the status-code contract is preserved. We don't\n // destroy the connection here — the response body still needs\n // to land.\n tooLarge = true;\n chunks.length = 0;\n return;\n }\n chunks.push(chunk);\n });\n req.on(\"end\", () => resolve({ buf: Buffer.concat(chunks), tooLarge }));\n req.on(\"error\", reject);\n });\n}\n\ninterface ValidatedAccept {\n token: string;\n ssh_pubkey: string;\n ssh_fp: string;\n short_name: string;\n stamp_pubkey: string | null;\n}\n\nfunction validateAcceptBody(body: AcceptBody):\n | { ok: true; data: ValidatedAccept }\n | { ok: false; status: number; error: string } {\n if (typeof body.token !== \"string\" || body.token.length === 0) {\n return { ok: false, status: 400, error: \"token_required\" };\n }\n // Bound the token length to defeat anyone passing a 16KB-shaped token\n // as a side-channel timing probe. Real tokens are 43 chars (32 bytes\n // base64url no padding).\n if (body.token.length > 128) {\n return { ok: false, status: 400, error: \"token_malformed\" };\n }\n if (typeof body.ssh_pubkey !== \"string\" || body.ssh_pubkey.length === 0) {\n return { ok: false, status: 400, error: \"ssh_pubkey_required\" };\n }\n if (typeof body.short_name !== \"string\" || !SHORT_NAME_RE.test(body.short_name)) {\n return { ok: false, status: 400, error: \"short_name_malformed\" };\n }\n let parsed;\n try {\n parsed = parseSshPubkey(body.ssh_pubkey);\n } catch (e) {\n return {\n ok: false,\n status: 400,\n error: `ssh_pubkey_invalid: ${(e as Error).message}`,\n };\n }\n\n let stamp_pubkey: string | null = null;\n if (body.stamp_pubkey !== undefined && body.stamp_pubkey !== null) {\n if (typeof body.stamp_pubkey !== \"string\") {\n return { ok: false, status: 400, error: \"stamp_pubkey_malformed\" };\n }\n if (!STAMP_PUBKEY_PEM_RE.test(body.stamp_pubkey)) {\n return { ok: false, status: 400, error: \"stamp_pubkey_not_pem\" };\n }\n stamp_pubkey = body.stamp_pubkey;\n }\n\n return {\n ok: true,\n data: {\n token: body.token,\n ssh_pubkey: parsed.full,\n ssh_fp: parsed.fingerprint,\n short_name: body.short_name,\n stamp_pubkey,\n },\n };\n}\n\ninterface AcceptOutcome {\n status: number;\n body: Record<string, unknown>;\n}\n\nfunction acceptInvite(data: ValidatedAccept): AcceptOutcome {\n // skipChmod: HTTP server runs as the git user, but the DB file is\n // root-owned (chmod fails with EPERM unless caller is owner).\n // entrypoint.sh handles boot-time perm tightening as root; the\n // in-process chmod would be redundant even if it could succeed.\n const db = openServerDb({ skipChmod: true });\n try {\n const consumed = consumeInviteToken(db, data.token);\n if (!consumed.ok) {\n const statusByReason: Record<typeof consumed.reason, number> = {\n not_found: 404,\n expired: 410,\n already_consumed: 410,\n };\n return {\n status: statusByReason[consumed.reason],\n body: { ok: false, error: `invite_${consumed.reason}` },\n };\n }\n\n // The consume above committed its own transaction; a failure on the\n // user insert below leaves the token consumed but no user row\n // created. We treat that as \"operator must mint a new invite\"\n // rather than rolling back — preserves the single-use property\n // against retry storms. UNIQUE-constraint collisions on\n // short_name / ssh_fp are operator/input errors (the invitee picked\n // a name already taken, or is trying to enroll a key already in the\n // table); other failures are propagated as internal_error upstream.\n let user_id: number;\n try {\n user_id = insertUser(db, {\n short_name: data.short_name,\n ssh_pubkey: data.ssh_pubkey,\n ssh_fp: data.ssh_fp,\n stamp_pubkey: data.stamp_pubkey,\n role: consumed.row.role,\n source: \"invite\",\n invited_by: consumed.row.invited_by,\n });\n } catch (e) {\n const msg = (e as Error).message;\n if (msg.includes(\"users.ssh_fp\")) {\n return {\n status: 409,\n body: { ok: false, error: \"ssh_pubkey_already_registered\" },\n };\n }\n if (msg.includes(\"users.short_name\")) {\n return {\n status: 409,\n body: { ok: false, error: \"short_name_taken\" },\n };\n }\n throw e;\n }\n\n markInviteConsumer(db, data.token, user_id);\n\n return {\n status: 200,\n body: {\n ok: true,\n user_id,\n role: consumed.row.role,\n short_name: data.short_name,\n },\n };\n } finally {\n db.close();\n }\n}\n\nasync function handlePost(req: IncomingMessage, res: ServerResponse): Promise<void> {\n if (req.headers[\"content-type\"]?.split(\";\")[0]?.trim() !== \"application/json\") {\n sendJson(res, 415, { ok: false, error: \"content_type_must_be_application_json\" });\n return;\n }\n let read: ReadBodyResult;\n try {\n read = await readBody(req);\n } catch (e) {\n logLine(\"warn\", `read body failed: ${(e as Error).message}`);\n sendJson(res, 400, { ok: false, error: \"body_read_failed\" });\n return;\n }\n if (read.tooLarge) {\n sendJson(res, 413, { ok: false, error: \"body_too_large\" });\n return;\n }\n let body: AcceptBody;\n try {\n body = JSON.parse(read.buf.toString(\"utf8\")) as AcceptBody;\n } catch {\n sendJson(res, 400, { ok: false, error: \"body_not_json\" });\n return;\n }\n const v = validateAcceptBody(body);\n if (!v.ok) {\n sendJson(res, v.status, { ok: false, error: v.error });\n return;\n }\n try {\n const outcome = acceptInvite(v.data);\n logLine(\n outcome.status === 200 ? \"info\" : \"warn\",\n `invite/accept short_name=${v.data.short_name} status=${outcome.status}`,\n );\n sendJson(res, outcome.status, outcome.body);\n } catch (e) {\n logLine(\"error\", `invite/accept internal error: ${(e as Error).message}`);\n sendJson(res, 500, { ok: false, error: \"internal_error\" });\n }\n}\n\nexport const HTTP_DEFAULT_PORT = DEFAULT_PORT;\n\nexport function startServer(port = DEFAULT_PORT): ReturnType<typeof createServer> {\n const server = createServer((req, res) => {\n const url = req.url ?? \"\";\n if (req.method === \"GET\" && (url === \"/healthz\" || url === \"/\")) {\n sendJson(res, 200, { ok: true });\n return;\n }\n if (req.method === \"POST\" && url === \"/invite/accept\") {\n void handlePost(req, res);\n return;\n }\n sendJson(res, 404, { ok: false, error: \"not_found\" });\n });\n server.listen(port, () => {\n logLine(\"info\", `listening on :${port}`);\n });\n return server;\n}\n","/**\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 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 * Bundled entry point for the stamp server's HTTP listener.\n *\n * Ships as /usr/local/sbin/stamp-http-server. Entrypoint.sh spawns this\n * as the `git` user in the background before exec-ing sshd. The server\n * reads STAMP_HTTP_PORT (default 8080) for the listening port.\n *\n * Kept as a separate file from http-server.ts so the latter can be\n * imported by tests without spinning up a real socket at import time.\n */\n\nimport { HTTP_DEFAULT_PORT, startServer } from \"./http-server.js\";\n\nconst rawPort = process.env[\"STAMP_HTTP_PORT\"];\nconst port = rawPort ? Number(rawPort) : HTTP_DEFAULT_PORT;\nif (!Number.isInteger(port) || port < 1 || port > 65535) {\n process.stderr.write(\n `stamp-http-server: STAMP_HTTP_PORT must be an integer 1..65535 (got ${JSON.stringify(rawPort)})\\n`,\n );\n process.exit(2);\n}\nstartServer(port);\n"],"mappings":"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","names":["import_node_fs","import_node_path","import_node_crypto","resolve","port"]}
|