codex-snapshots 0.1.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,773 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { randomBytes } from "node:crypto";
4
+ import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
5
+ import http from "node:http";
6
+ import os from "node:os";
7
+ import path from "node:path";
8
+
9
+ const DEFAULT_HOST = "127.0.0.1";
10
+ const DEFAULT_PORT = 8787;
11
+ const MAX_BODY_BYTES = 64 * 1024 * 1024;
12
+
13
+ const parsed = parseArgs(process.argv.slice(2));
14
+
15
+ if (parsed.help) {
16
+ printHelp();
17
+ process.exit(0);
18
+ }
19
+
20
+ const host = parsed.options.host || process.env.HOST || DEFAULT_HOST;
21
+ const port = Number(parsed.options.port || process.env.PORT || DEFAULT_PORT);
22
+ const dataFile = path.resolve(
23
+ expandHome(parsed.options.dataFile || process.env.SNAPSHOT_SHARE_DATA_FILE || ".codex-snapshots/shares.json")
24
+ );
25
+ const shareToken =
26
+ parsed.options.token ||
27
+ process.env.SNAPSHOT_SHARE_TOKEN ||
28
+ process.env.CODEX_SNAPSHOTS_SHARE_TOKEN ||
29
+ process.env.TOKEN_BOARD_AGENT_TOKEN ||
30
+ process.env.TOKEN_BOARD_UPLOAD_TOKEN ||
31
+ "";
32
+
33
+ const storage = createFileShareStore(dataFile);
34
+
35
+ main().catch((error) => {
36
+ console.error(error instanceof Error ? error.message : String(error));
37
+ process.exitCode = 1;
38
+ });
39
+
40
+ async function main() {
41
+ if (!Number.isFinite(port) || port <= 0) {
42
+ throw new Error("port must be a positive number");
43
+ }
44
+
45
+ const server = http.createServer(async (request, response) => {
46
+ try {
47
+ setCorsHeaders(response);
48
+
49
+ if (request.method === "OPTIONS") {
50
+ response.writeHead(204);
51
+ response.end();
52
+ return;
53
+ }
54
+
55
+ const url = new URL(request.url || "/", `http://${request.headers.host || `${host}:${port}`}`);
56
+ const shareId = shareIdFromPath(url.pathname);
57
+
58
+ if (request.method === "GET" && url.pathname === "/") {
59
+ send(response, 200, "text/html; charset=utf-8", renderHome());
60
+ return;
61
+ }
62
+
63
+ if (request.method === "GET" && url.pathname === "/health") {
64
+ sendJson(response, 200, {
65
+ ok: true,
66
+ service: "codex-snapshots-share-api",
67
+ storage: dataFile,
68
+ auth: shareToken ? "token" : "disabled",
69
+ });
70
+ return;
71
+ }
72
+
73
+ if (request.method === "GET" && url.pathname === "/api/snapshots/health") {
74
+ sendJson(response, 200, {
75
+ ok: true,
76
+ shares: await storage.countShares(),
77
+ storage: dataFile,
78
+ });
79
+ return;
80
+ }
81
+
82
+ if (request.method === "GET" && url.pathname === "/snapshots/share/") {
83
+ send(response, 200, "text/html; charset=utf-8", renderSharePage(url.searchParams.get("id") || ""));
84
+ return;
85
+ }
86
+
87
+ if (request.method === "POST" && url.pathname === "/api/snapshots") {
88
+ requireAuth(request);
89
+ const body = await readJsonBody(request, MAX_BODY_BYTES);
90
+ const snapshot = normalizeSnapshotPayloadForShare(body.snapshot ?? body);
91
+
92
+ if (!snapshot.redacted && process.env.SNAPSHOT_SHARE_ALLOW_UNREDACTED !== "true") {
93
+ sendJson(response, 400, {
94
+ error:
95
+ "Refusing to publish an unredacted snapshot. Re-run without --no-redact, or set SNAPSHOT_SHARE_ALLOW_UNREDACTED=true on the server.",
96
+ });
97
+ return;
98
+ }
99
+
100
+ const now = new Date().toISOString();
101
+ const record = {
102
+ id: sanitizeShareId(body.shareId) || createShareId(),
103
+ title: snapshot.title,
104
+ engine: snapshot.engine,
105
+ engineLabel: snapshot.engineLabel,
106
+ sourceRef: snapshot.ref,
107
+ createdAt: now,
108
+ updatedAt: now,
109
+ expiresAt: expiryFromDays(body.expiresInDays),
110
+ redacted: snapshot.redacted,
111
+ turnCount: snapshot.turnCount,
112
+ snapshot: snapshot.payload,
113
+ };
114
+
115
+ await storage.putShare(record);
116
+
117
+ sendJson(response, 200, {
118
+ ok: true,
119
+ id: record.id,
120
+ title: record.title,
121
+ turnCount: record.turnCount,
122
+ redacted: record.redacted,
123
+ expiresAt: record.expiresAt || null,
124
+ url: snapshotShareUrl(request, record.id, body.siteUrl),
125
+ });
126
+ return;
127
+ }
128
+
129
+ if (request.method === "GET" && shareId) {
130
+ const record = await storage.getShare(shareId);
131
+
132
+ if (!record) {
133
+ sendJson(response, 404, { error: "Snapshot share not found" });
134
+ return;
135
+ }
136
+
137
+ sendJson(response, 200, {
138
+ schemaVersion: 1,
139
+ share: {
140
+ id: record.id,
141
+ title: record.title,
142
+ engine: record.engine,
143
+ engineLabel: record.engineLabel,
144
+ sourceRef: record.sourceRef,
145
+ createdAt: record.createdAt,
146
+ updatedAt: record.updatedAt,
147
+ expiresAt: record.expiresAt || null,
148
+ redacted: record.redacted,
149
+ turnCount: record.turnCount,
150
+ },
151
+ snapshot: record.snapshot,
152
+ });
153
+ return;
154
+ }
155
+
156
+ if (request.method === "DELETE" && shareId) {
157
+ requireAuth(request);
158
+ const deleted = await storage.deleteShare(shareId);
159
+ sendJson(response, deleted ? 200 : 404, { ok: deleted, deleted, id: shareId });
160
+ return;
161
+ }
162
+
163
+ send(response, 404, "text/plain; charset=utf-8", "not found");
164
+ } catch (error) {
165
+ const status = error?.statusCode || 500;
166
+ sendJson(response, status, { error: error instanceof Error ? error.message : String(error) });
167
+ }
168
+ });
169
+
170
+ await new Promise((resolve, reject) => {
171
+ server.once("error", reject);
172
+ server.listen(port, host, resolve);
173
+ });
174
+
175
+ console.log(`Codex Snapshots share API is running at http://${host}:${port}`);
176
+ console.log(`Storage: ${dataFile}`);
177
+ console.log(`Auth: ${shareToken ? "SNAPSHOT_SHARE_TOKEN required" : "disabled (local/dev only)"}`);
178
+ }
179
+
180
+ function createFileShareStore(filePath) {
181
+ let queue = Promise.resolve();
182
+
183
+ function enqueue(operation) {
184
+ const run = queue.then(operation, operation);
185
+ queue = run.catch(() => undefined);
186
+ return run;
187
+ }
188
+
189
+ return {
190
+ putShare(record) {
191
+ return enqueue(async () => {
192
+ const existing = await readShares(filePath);
193
+ await writeShares(filePath, existing.filter((share) => share.id !== record.id).concat(record));
194
+ });
195
+ },
196
+ async getShare(id) {
197
+ const record = (await readShares(filePath)).find((share) => share.id === id);
198
+ return isExpired(record) ? undefined : record;
199
+ },
200
+ deleteShare(id) {
201
+ return enqueue(async () => {
202
+ const existing = await readShares(filePath);
203
+ const next = existing.filter((share) => share.id !== id);
204
+ if (next.length === existing.length) {
205
+ return false;
206
+ }
207
+ await writeShares(filePath, next);
208
+ return true;
209
+ });
210
+ },
211
+ async countShares() {
212
+ return (await readShares(filePath)).filter((share) => !isExpired(share)).length;
213
+ },
214
+ };
215
+ }
216
+
217
+ async function readShares(filePath) {
218
+ try {
219
+ const parsed = JSON.parse(await readFile(filePath, "utf8"));
220
+ const entries = Array.isArray(parsed)
221
+ ? parsed
222
+ : parsed && typeof parsed === "object" && Array.isArray(parsed.entries)
223
+ ? parsed.entries
224
+ : [];
225
+ return entries.flatMap((entry) => normalizeShareRecord(entry) ?? []);
226
+ } catch (error) {
227
+ if (error?.code === "ENOENT") {
228
+ return [];
229
+ }
230
+ throw error;
231
+ }
232
+ }
233
+
234
+ async function writeShares(filePath, records) {
235
+ const dir = path.dirname(filePath);
236
+ const tempFile = path.join(
237
+ dir,
238
+ `.${path.basename(filePath)}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`
239
+ );
240
+
241
+ await mkdir(dir, { recursive: true });
242
+
243
+ try {
244
+ await writeFile(
245
+ tempFile,
246
+ `${JSON.stringify({ schemaVersion: 1, updatedAt: new Date().toISOString(), entries: records }, null, 2)}\n`,
247
+ "utf8"
248
+ );
249
+ await rename(tempFile, filePath);
250
+ } catch (error) {
251
+ await rm(tempFile, { force: true }).catch(() => undefined);
252
+ throw error;
253
+ }
254
+ }
255
+
256
+ function normalizeShareRecord(value) {
257
+ if (!value || typeof value !== "object") {
258
+ return undefined;
259
+ }
260
+ const id = sanitizeText(value.id, 120);
261
+ if (!id) {
262
+ return undefined;
263
+ }
264
+
265
+ return {
266
+ id,
267
+ title: sanitizeText(value.title, 240) || id,
268
+ engine: sanitizeText(value.engine, 80) || "codex",
269
+ engineLabel: sanitizeText(value.engineLabel, 80) || "Codex",
270
+ sourceRef: sanitizeText(value.sourceRef, 240) || undefined,
271
+ createdAt: normalizeDate(value.createdAt) || new Date().toISOString(),
272
+ updatedAt: normalizeDate(value.updatedAt) || new Date().toISOString(),
273
+ expiresAt: normalizeDate(value.expiresAt) || undefined,
274
+ redacted: value.redacted !== false,
275
+ turnCount: Number.isFinite(Number(value.turnCount)) ? Number(value.turnCount) : 0,
276
+ snapshot: value.snapshot,
277
+ };
278
+ }
279
+
280
+ function normalizeSnapshotPayloadForShare(value) {
281
+ if (!value || typeof value !== "object") {
282
+ throw new Error("Body must include a snapshot object");
283
+ }
284
+
285
+ const payload = removePrivateSnapshotFields(JSON.parse(JSON.stringify(value)));
286
+ const turns = Array.isArray(payload.turns) ? payload.turns : [];
287
+ const title = sanitizeText(payload.title, 180) || "Untitled snapshot";
288
+ const engine = sanitizeText(payload.engine, 80) || "codex";
289
+ const engineLabel = sanitizeText(payload.engineLabel, 80) || "Codex";
290
+ const ref = sanitizeText(payload.ref, 240) || undefined;
291
+
292
+ if (!turns.length) {
293
+ throw new Error("Snapshot has no shareable turns");
294
+ }
295
+
296
+ sanitizeTurnHtml(payload);
297
+
298
+ return {
299
+ title,
300
+ engine,
301
+ engineLabel,
302
+ ref,
303
+ redacted: payload.redacted !== false,
304
+ turnCount: turns.length,
305
+ payload,
306
+ };
307
+ }
308
+
309
+ function removePrivateSnapshotFields(value) {
310
+ if (!value || typeof value !== "object") {
311
+ return value;
312
+ }
313
+ if (Array.isArray(value)) {
314
+ return value.map(removePrivateSnapshotFields);
315
+ }
316
+
317
+ delete value.cwd;
318
+ delete value.filePath;
319
+ delete value.displayFilePath;
320
+
321
+ for (const [key, item] of Object.entries(value)) {
322
+ if (key === "images") {
323
+ continue;
324
+ }
325
+ value[key] = removePrivateSnapshotFields(item);
326
+ }
327
+
328
+ return value;
329
+ }
330
+
331
+ function sanitizeTurnHtml(snapshot) {
332
+ const turns = Array.isArray(snapshot.turns) ? snapshot.turns : [];
333
+
334
+ for (const turn of turns) {
335
+ if (!turn || typeof turn !== "object") {
336
+ continue;
337
+ }
338
+ if (typeof turn.html === "string") {
339
+ turn.html = sanitizePublishedHtml(turn.html);
340
+ }
341
+ }
342
+ }
343
+
344
+ function sanitizePublishedHtml(value) {
345
+ return value
346
+ .replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, "")
347
+ .replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, "")
348
+ .replace(/<(?:iframe|object|embed)\b[^>]*>[\s\S]*?<\/(?:iframe|object|embed)>/gi, "")
349
+ .replace(/\s+on[a-z]+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, "");
350
+ }
351
+
352
+ function renderHome() {
353
+ return `<!doctype html>
354
+ <html lang="en">
355
+ <head>
356
+ <meta charset="utf-8">
357
+ <meta name="viewport" content="width=device-width, initial-scale=1">
358
+ <title>Codex Snapshots Share API</title>
359
+ <style>${shareCss()}</style>
360
+ </head>
361
+ <body>
362
+ <main class="shell">
363
+ <p class="eyebrow">Codex Snapshots</p>
364
+ <h1>Share API is running</h1>
365
+ <p>Publish redacted snapshots with <code>pnpm snapshot publish</code>, then open the returned share link.</p>
366
+ <dl>
367
+ <div><dt>API</dt><dd><code>/api/snapshots</code></dd></div>
368
+ <div><dt>Viewer</dt><dd><code>/snapshots/share/?id=...</code></dd></div>
369
+ <div><dt>Storage</dt><dd><code>${escapeHtml(dataFile)}</code></dd></div>
370
+ </dl>
371
+ </main>
372
+ </body>
373
+ </html>`;
374
+ }
375
+
376
+ function renderSharePage(initialId) {
377
+ return `<!doctype html>
378
+ <html lang="en">
379
+ <head>
380
+ <meta charset="utf-8">
381
+ <meta name="viewport" content="width=device-width, initial-scale=1">
382
+ <title>Codex Snapshot Share</title>
383
+ <style>${shareCss()}</style>
384
+ </head>
385
+ <body>
386
+ <main class="share">
387
+ <header class="share-header">
388
+ <p class="eyebrow">Cloud Read-only Snapshot</p>
389
+ <h1 id="title">Loading snapshot</h1>
390
+ <p id="meta" class="meta"></p>
391
+ </header>
392
+ <section id="content" class="turns">Loading...</section>
393
+ </main>
394
+ <script>
395
+ const initialId = ${JSON.stringify(initialId)};
396
+ const esc = (value) => String(value ?? "").replace(/[&<>"']/g, (char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[char]));
397
+ const id = initialId || new URLSearchParams(location.search).get("id") || "";
398
+ const title = document.getElementById("title");
399
+ const meta = document.getElementById("meta");
400
+ const content = document.getElementById("content");
401
+
402
+ function renderPlainText(value) {
403
+ return String(value || "").split(/\\n{2,}/).map((block) => "<p>" + esc(block).replace(/\\n/g, "<br>") + "</p>").join("");
404
+ }
405
+
406
+ function renderImages(images) {
407
+ if (!Array.isArray(images) || !images.length) return "";
408
+ return "<div class='attachment-grid'>" + images.map((image, index) => {
409
+ const label = (image.mimeType || "image") + (image.size ? " / " + image.size : "");
410
+ if (!image.src) {
411
+ return "<figure class='image-attachment image-unavailable'><div>" + esc(image.unavailableReason || "Image unavailable") + "</div><figcaption>" + esc(label) + "</figcaption></figure>";
412
+ }
413
+ return "<figure class='image-attachment'><img src='" + esc(image.src) + "' alt='" + esc(image.alt || ("Image attachment " + (index + 1))) + "' decoding='async'><figcaption>" + esc(label) + "</figcaption></figure>";
414
+ }).join("") + "</div>";
415
+ }
416
+
417
+ function renderSnapshot(payload) {
418
+ const snapshot = payload.snapshot || {};
419
+ const share = payload.share || {};
420
+ const turns = Array.isArray(snapshot.turns) ? snapshot.turns : [];
421
+ title.textContent = share.title || snapshot.title || "Snapshot";
422
+ meta.textContent = [
423
+ share.engineLabel || snapshot.engineLabel || "Codex",
424
+ share.id || snapshot.id || "unknown",
425
+ (share.turnCount ?? turns.length) + " entries",
426
+ "redacted: " + ((share.redacted ?? snapshot.redacted) ? "yes" : "no"),
427
+ ].join(" | ");
428
+ content.innerHTML = turns.map((turn, index) => {
429
+ const role = turn.kind === "tool" ? "tool" : turn.role === "user" ? "user" : "assistant";
430
+ const body = turn.kind === "tool"
431
+ ? "<details class='tool-details' open><summary>Tool" + (turn.name ? " / " + esc(turn.name) : "") + "</summary><pre>" + esc(turn.text || "") + "</pre></details>"
432
+ : (turn.html || renderPlainText(turn.text)) + renderImages(turn.images || []);
433
+ return "<article class='turn " + esc(role) + "'><div class='message-card'><div class='body'>" + body + "</div></div></article>";
434
+ }).join("") || "<div class='empty'>This snapshot has no shareable turns.</div>";
435
+ }
436
+
437
+ async function load() {
438
+ if (!id) {
439
+ title.textContent = "Missing share id";
440
+ content.textContent = "Open a link with ?id=...";
441
+ return;
442
+ }
443
+ const response = await fetch("/api/snapshots/" + encodeURIComponent(id), { cache: "no-store" });
444
+ const payload = await response.json().catch(() => ({}));
445
+ if (!response.ok) {
446
+ throw new Error(payload.error || "Failed to load snapshot");
447
+ }
448
+ renderSnapshot(payload);
449
+ }
450
+
451
+ load().catch((error) => {
452
+ title.textContent = "Snapshot unavailable";
453
+ content.textContent = error instanceof Error ? error.message : String(error);
454
+ });
455
+ </script>
456
+ </body>
457
+ </html>`;
458
+ }
459
+
460
+ function shareCss() {
461
+ return `
462
+ :root {
463
+ --ink: #16191f;
464
+ --muted: #69717d;
465
+ --line: #d9dee4;
466
+ --paper: #f4f0e7;
467
+ --panel: #fffdf8;
468
+ --blue: #255f82;
469
+ --shadow-soft: 0 24px 70px -58px rgba(22, 25, 31, 0.5);
470
+ }
471
+ * { box-sizing: border-box; }
472
+ body {
473
+ margin: 0;
474
+ min-height: 100vh;
475
+ color: var(--ink);
476
+ background:
477
+ linear-gradient(90deg, rgba(22, 25, 31, 0.065) 1px, transparent 1px),
478
+ linear-gradient(rgba(22, 25, 31, 0.038) 1px, transparent 1px),
479
+ var(--paper);
480
+ background-size: 24px 24px;
481
+ font-family: "Iowan Old Style", "Palatino Linotype", Georgia, serif;
482
+ }
483
+ code, pre, .eyebrow, .meta { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
484
+ .shell, .share { width: min(1180px, calc(100vw - 32px)); margin: 0 auto; padding: 32px 0 64px; }
485
+ .shell {
486
+ min-height: 100vh;
487
+ display: grid;
488
+ align-content: center;
489
+ }
490
+ .shell > *, .share-header {
491
+ border: 1px solid rgba(22, 25, 31, 0.12);
492
+ background: rgba(255, 253, 248, 0.92);
493
+ box-shadow: var(--shadow-soft);
494
+ }
495
+ .shell > * { padding: 28px; }
496
+ .share-header { padding: 24px; border-bottom: 3px solid var(--ink); }
497
+ .eyebrow {
498
+ margin: 0 0 10px;
499
+ color: var(--blue);
500
+ font-size: 12px;
501
+ font-weight: 900;
502
+ letter-spacing: 0.08em;
503
+ text-transform: uppercase;
504
+ }
505
+ h1 { margin: 0; font-size: clamp(36px, 7vw, 72px); line-height: 0.95; letter-spacing: 0; }
506
+ p { color: var(--muted); font-size: 18px; line-height: 1.65; }
507
+ dl { display: grid; gap: 10px; margin: 24px 0 0; }
508
+ dl div { display: grid; grid-template-columns: 120px minmax(0, 1fr); gap: 16px; border-top: 1px solid var(--line); padding-top: 10px; }
509
+ dt { color: var(--muted); font-weight: 700; }
510
+ dd { margin: 0; min-width: 0; overflow-wrap: anywhere; }
511
+ .turns { display: grid; gap: 34px; margin-top: 34px; }
512
+ .turn { display: flex; min-width: 0; }
513
+ .turn.user { justify-content: flex-end; }
514
+ .turn.assistant, .turn.tool { justify-content: flex-start; }
515
+ .message-card { min-width: 0; max-width: min(960px, 76%); }
516
+ .turn.user .message-card {
517
+ border: 1px solid #d6e9e5;
518
+ border-radius: 18px;
519
+ background: #eef9f6;
520
+ padding: 20px 28px;
521
+ box-shadow: var(--shadow-soft);
522
+ }
523
+ .turn.tool .message-card {
524
+ width: min(960px, 86%);
525
+ border: 1px solid #efd99f;
526
+ border-radius: 8px;
527
+ background: #fff8df;
528
+ padding: 16px 18px;
529
+ }
530
+ .body {
531
+ min-width: 0;
532
+ color: var(--ink);
533
+ font-size: 19px;
534
+ line-height: 1.75;
535
+ overflow-wrap: anywhere;
536
+ }
537
+ .body pre, .tool-details pre {
538
+ max-width: 100%;
539
+ overflow: auto;
540
+ border: 1px solid #253043;
541
+ border-radius: 8px;
542
+ background: #111722;
543
+ color: #edf4ff;
544
+ padding: 16px;
545
+ font: 13px/1.58 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
546
+ }
547
+ .body code {
548
+ border: 1px solid rgba(22, 25, 31, 0.12);
549
+ border-radius: 6px;
550
+ background: rgba(22, 25, 31, 0.06);
551
+ padding: 0.08rem 0.34rem;
552
+ font-size: 0.9em;
553
+ }
554
+ .attachment-grid { display: grid; gap: 18px; margin-top: 24px; }
555
+ .image-attachment { margin: 0; min-width: 0; }
556
+ .image-attachment img {
557
+ display: block;
558
+ max-width: 100%;
559
+ max-height: 540px;
560
+ border: 1px solid rgba(22, 25, 31, 0.18);
561
+ border-radius: 8px;
562
+ background: #fff;
563
+ object-fit: contain;
564
+ }
565
+ .image-attachment figcaption {
566
+ margin-top: 10px;
567
+ color: var(--muted);
568
+ font: 800 14px/1.35 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
569
+ }
570
+ .image-unavailable {
571
+ border: 1px dashed var(--line);
572
+ border-radius: 8px;
573
+ padding: 16px;
574
+ color: var(--muted);
575
+ }
576
+ .empty { border: 1px solid var(--line); background: var(--panel); padding: 18px; color: var(--muted); }
577
+ @media (max-width: 820px) {
578
+ .message-card, .turn.user .message-card, .turn.tool .message-card { max-width: 100%; width: 100%; }
579
+ .body { font-size: 17px; }
580
+ }
581
+ `;
582
+ }
583
+
584
+ function requireAuth(request) {
585
+ if (!shareToken && process.env.SNAPSHOT_SHARE_ALLOW_ANONYMOUS !== "false") {
586
+ return;
587
+ }
588
+ const token = readBearerToken(request);
589
+ if (token && token === shareToken) {
590
+ return;
591
+ }
592
+ const error = new Error("Login required");
593
+ error.statusCode = 401;
594
+ throw error;
595
+ }
596
+
597
+ function readBearerToken(request) {
598
+ const header = request.headers.authorization || "";
599
+ const match = String(header).match(/^Bearer\s+(.+)$/i);
600
+ return match?.[1]?.trim() || "";
601
+ }
602
+
603
+ async function readJsonBody(request, maxBytes) {
604
+ const chunks = [];
605
+ let size = 0;
606
+
607
+ for await (const chunk of request) {
608
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
609
+ size += buffer.length;
610
+ if (size > maxBytes) {
611
+ throw new Error("Request body is too large");
612
+ }
613
+ chunks.push(buffer);
614
+ }
615
+
616
+ const text = Buffer.concat(chunks).toString("utf8");
617
+ if (!text.trim()) {
618
+ throw new Error("Request body is empty");
619
+ }
620
+ return JSON.parse(text);
621
+ }
622
+
623
+ function shareIdFromPath(pathname) {
624
+ const match = pathname.match(/^\/api\/snapshots\/([^/]+)$/);
625
+ return match?.[1] ? decodeURIComponent(match[1]) : "";
626
+ }
627
+
628
+ function createShareId() {
629
+ return `snap_${randomBytes(18).toString("base64url")}`;
630
+ }
631
+
632
+ function sanitizeShareId(value) {
633
+ const text = sanitizeText(value, 90);
634
+ return /^snap_[A-Za-z0-9_-]{16,80}$/.test(text) ? text : "";
635
+ }
636
+
637
+ function expiryFromDays(value) {
638
+ const days = Number(value);
639
+ if (!Number.isFinite(days) || days <= 0) {
640
+ return undefined;
641
+ }
642
+ return new Date(Date.now() + Math.min(days, 365) * 24 * 60 * 60 * 1000).toISOString();
643
+ }
644
+
645
+ function snapshotShareUrl(request, id, rawSiteUrl) {
646
+ const siteUrl =
647
+ sanitizeUrl(rawSiteUrl) ||
648
+ sanitizeUrl(process.env.SNAPSHOT_SHARE_SITE_URL) ||
649
+ `http://${request.headers.host || `${host}:${port}`}`;
650
+ return `${siteUrl.replace(/\/+$/, "")}/snapshots/share/?id=${encodeURIComponent(id)}`;
651
+ }
652
+
653
+ function normalizeDate(value) {
654
+ const date = typeof value === "string" ? new Date(value) : undefined;
655
+ return date && Number.isFinite(date.getTime()) ? date.toISOString() : "";
656
+ }
657
+
658
+ function isExpired(record) {
659
+ return Boolean(record?.expiresAt && new Date(record.expiresAt).getTime() <= Date.now());
660
+ }
661
+
662
+ function sanitizeText(value, maxLength) {
663
+ return typeof value === "string"
664
+ ? value.replace(/[\u0000-\u001f\u007f]/g, " ").replace(/\s+/g, " ").trim().slice(0, maxLength)
665
+ : "";
666
+ }
667
+
668
+ function sanitizeUrl(value) {
669
+ const text = sanitizeText(value, 400).replace(/\/+$/, "");
670
+ if (!text) {
671
+ return "";
672
+ }
673
+ try {
674
+ const url = new URL(text);
675
+ return url.protocol === "http:" || url.protocol === "https:" ? url.toString().replace(/\/+$/, "") : "";
676
+ } catch {
677
+ return "";
678
+ }
679
+ }
680
+
681
+ function setCorsHeaders(response) {
682
+ response.setHeader("access-control-allow-origin", "*");
683
+ response.setHeader("access-control-allow-methods", "GET,POST,DELETE,OPTIONS");
684
+ response.setHeader("access-control-allow-headers", "authorization,content-type");
685
+ response.setHeader("access-control-max-age", "86400");
686
+ }
687
+
688
+ function sendJson(response, status, data) {
689
+ send(response, status, "application/json; charset=utf-8", `${JSON.stringify(data, null, 2)}\n`);
690
+ }
691
+
692
+ function send(response, status, contentType, body) {
693
+ response.writeHead(status, {
694
+ "content-type": contentType,
695
+ "cache-control": "no-store",
696
+ });
697
+ response.end(body);
698
+ }
699
+
700
+ function escapeHtml(value) {
701
+ return String(value)
702
+ .replace(/&/g, "&amp;")
703
+ .replace(/</g, "&lt;")
704
+ .replace(/>/g, "&gt;")
705
+ .replace(/"/g, "&quot;")
706
+ .replace(/'/g, "&#39;");
707
+ }
708
+
709
+ function expandHome(value) {
710
+ const text = String(value || "");
711
+ if (text === "~") {
712
+ return os.homedir();
713
+ }
714
+ if (text.startsWith("~/")) {
715
+ return path.join(os.homedir(), text.slice(2));
716
+ }
717
+ return text;
718
+ }
719
+
720
+ function parseArgs(args) {
721
+ const options = {
722
+ dataFile: "",
723
+ host: "",
724
+ port: "",
725
+ token: "",
726
+ };
727
+ let help = false;
728
+
729
+ for (let index = 0; index < args.length; index += 1) {
730
+ const arg = args[index];
731
+ if (arg === "--") {
732
+ continue;
733
+ }
734
+ if (arg === "-h" || arg === "--help") {
735
+ help = true;
736
+ continue;
737
+ }
738
+ if (arg === "--host") {
739
+ options.host = String(args[++index] || "");
740
+ continue;
741
+ }
742
+ if (arg === "--port" || arg === "-p") {
743
+ options.port = String(args[++index] || "");
744
+ continue;
745
+ }
746
+ if (arg === "--data-file") {
747
+ options.dataFile = String(args[++index] || "");
748
+ continue;
749
+ }
750
+ if (arg === "--token") {
751
+ options.token = String(args[++index] || "");
752
+ continue;
753
+ }
754
+ throw new Error(`unknown option: ${arg}`);
755
+ }
756
+
757
+ return { help, options };
758
+ }
759
+
760
+ function printHelp() {
761
+ console.log(`codex-snapshots share-api
762
+
763
+ Usage:
764
+ node server/share-api.mjs [--host 127.0.0.1] [--port 8787] [--data-file FILE] [--token TOKEN]
765
+
766
+ Environment:
767
+ SNAPSHOT_SHARE_TOKEN Bearer token required for publish/delete
768
+ SNAPSHOT_SHARE_DATA_FILE JSON storage file. Defaults to .codex-snapshots/shares.json
769
+ SNAPSHOT_SHARE_SITE_URL Base URL used in returned share links
770
+ SNAPSHOT_SHARE_ALLOW_UNREDACTED=true
771
+ SNAPSHOT_SHARE_ALLOW_ANONYMOUS=false
772
+ `);
773
+ }