codex-snapshots 0.1.0 → 0.1.1

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.
Files changed (51) hide show
  1. package/README.md +101 -6
  2. package/bin/codex-snapshot.mjs +1 -6326
  3. package/deploy/aliyun/README.md +311 -0
  4. package/deploy/aliyun/backup-share-data.sh +109 -0
  5. package/deploy/aliyun/check-ecs-status.sh +149 -0
  6. package/deploy/aliyun/codex-snapshot-share.env.example +29 -0
  7. package/deploy/aliyun/codex-snapshot-share.service +26 -0
  8. package/deploy/aliyun/configure-github-pages-api.sh +141 -0
  9. package/deploy/aliyun/configure-local-publisher.sh +197 -0
  10. package/deploy/aliyun/deploy-to-ecs.sh +669 -0
  11. package/deploy/aliyun/deploy.env.example +52 -0
  12. package/deploy/aliyun/doctor.mjs +398 -0
  13. package/deploy/aliyun/install-share-api.sh +252 -0
  14. package/deploy/aliyun/install-system-deps.sh +84 -0
  15. package/deploy/aliyun/nginx-codex-snapshots.bootstrap.conf +34 -0
  16. package/deploy/aliyun/nginx-codex-snapshots.conf +52 -0
  17. package/deploy/aliyun/preflight.mjs +321 -0
  18. package/deploy/aliyun/restore-share-data.sh +141 -0
  19. package/deploy/aliyun/verify-public-share.mjs +404 -0
  20. package/dist/cli/codex-snapshot.mjs +2654 -0
  21. package/dist/core/privacy.js +81 -0
  22. package/dist/core/snapshot.js +1 -0
  23. package/dist/renderers/markdown.mjs +81 -0
  24. package/dist/renderers/transcript.js +195 -0
  25. package/dist/server/http.js +10 -0
  26. package/dist/server/local-security.js +66 -0
  27. package/dist/server/local-viewer-app.mjs +1670 -0
  28. package/dist/server/local-viewer.mjs +210 -0
  29. package/dist/server/share-api.mjs +1149 -0
  30. package/dist/server/share-store.js +136 -0
  31. package/dist/shared/sanitize.js +126 -0
  32. package/dist/shared/transcript.js +1 -0
  33. package/dist/sources/index.mjs +2 -0
  34. package/dist/sources/local-history.mjs +2221 -0
  35. package/package.json +42 -14
  36. package/scripts/build-site.mjs +71 -0
  37. package/scripts/launch-agent.mjs +19 -227
  38. package/scripts/serve-site.mjs +2 -2
  39. package/scripts/test-aliyun-deploy-config.sh +230 -0
  40. package/scripts/test-share-api.mjs +967 -0
  41. package/scripts/test-site-config.mjs +100 -0
  42. package/scripts/test-static-site.mjs +403 -0
  43. package/scripts/write-site-config.mjs +161 -0
  44. package/server/share-api.mjs +1 -771
  45. package/site/assets/config.js +3 -0
  46. package/site/assets/share.js +43 -106
  47. package/site/assets/site.css +3 -605
  48. package/site/assets/site.js +15 -92
  49. package/site/favicon.svg +7 -0
  50. package/site/index.html +3 -83
  51. package/site/share/index.html +3 -8
@@ -0,0 +1,1149 @@
1
+ #!/usr/bin/env node
2
+ // @ts-nocheck
3
+ import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
4
+ import http from "node:http";
5
+ import os from "node:os";
6
+ import path from "node:path";
7
+ import { createShareStore } from "./share-store.js";
8
+ import { sanitizeSnapshotHtml as sanitizeSnapshotTurnHtml } from "../shared/sanitize.js";
9
+ import { renderTranscriptHtml } from "../renderers/transcript.js";
10
+ const DEFAULT_HOST = "127.0.0.1";
11
+ const DEFAULT_PORT = 8787;
12
+ const MAX_BODY_BYTES = 64 * 1024 * 1024;
13
+ const SNAPSHOT_LOGO_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Codex Snapshots"><rect width="64" height="64" rx="14" fill="#17202a"/><path d="M19 16h26a3 3 0 0 1 3 3v26a3 3 0 0 1-3 3H19a3 3 0 0 1-3-3V19a3 3 0 0 1 3-3Z" fill="none" stroke="#eef9f6" stroke-width="4"/><path d="M23 22h11M22 23v11M41 42H30M42 41V30" fill="none" stroke="#7dd3c7" stroke-width="4" stroke-linecap="round"/><circle cx="32" cy="32" r="9" fill="#f2cc60"/><path d="M27 32h10M32 27v10" stroke="#17202a" stroke-width="3" stroke-linecap="round"/></svg>`;
14
+ const parsed = parseArgs(process.argv.slice(2));
15
+ if (parsed.help) {
16
+ printHelp();
17
+ process.exit(0);
18
+ }
19
+ const host = parsed.options.host || process.env.HOST || DEFAULT_HOST;
20
+ const port = Number(parsed.options.port || process.env.PORT || DEFAULT_PORT);
21
+ const dataFile = path.resolve(expandHome(parsed.options.dataFile || process.env.SNAPSHOT_SHARE_DATA_FILE || ".codex-snapshots/shares.json"));
22
+ const shareToken = parsed.options.token ||
23
+ process.env.SNAPSHOT_SHARE_TOKEN ||
24
+ process.env.CODEX_SNAPSHOTS_SHARE_TOKEN ||
25
+ process.env.TOKEN_BOARD_AGENT_TOKEN ||
26
+ process.env.TOKEN_BOARD_UPLOAD_TOKEN ||
27
+ "";
28
+ const githubAuth = readGithubAuthConfig();
29
+ const authCookieName = sanitizeCookieName(process.env.SNAPSHOT_AUTH_COOKIE_NAME) || "codex_snapshots_session";
30
+ const tokenAuthEnabled = Boolean(shareToken) && (!githubAuth.enabled || process.env.SNAPSHOT_SHARE_ALLOW_TOKEN_AUTH === "true");
31
+ const storage = createShareStore({ kind: "file", filePath: dataFile });
32
+ main().catch((error) => {
33
+ console.error(error instanceof Error ? error.message : String(error));
34
+ process.exitCode = 1;
35
+ });
36
+ async function main() {
37
+ if (!Number.isFinite(port) || port <= 0) {
38
+ throw new Error("port must be a positive number");
39
+ }
40
+ const server = http.createServer(async (request, response) => {
41
+ try {
42
+ setCorsHeaders(request, response);
43
+ if (request.method === "OPTIONS") {
44
+ response.writeHead(204);
45
+ response.end();
46
+ return;
47
+ }
48
+ const url = new URL(request.url || "/", `http://${request.headers.host || `${host}:${port}`}`);
49
+ const shareId = shareIdFromPath(url.pathname);
50
+ if (request.method === "GET" && url.pathname === "/api/auth/me") {
51
+ const user = readSessionUser(request);
52
+ sendJson(response, 200, {
53
+ configured: githubAuth.enabled,
54
+ provider: "github",
55
+ user,
56
+ loginUrl: githubAuth.enabled ? githubLoginStartUrl(request, url.searchParams.get("returnTo")) : null,
57
+ });
58
+ return;
59
+ }
60
+ if (request.method === "GET" && url.pathname === "/api/auth/github/start") {
61
+ redirectToGithubLogin(request, response, url.searchParams.get("returnTo"));
62
+ return;
63
+ }
64
+ if (request.method === "GET" && url.pathname === "/api/auth/github/callback") {
65
+ await handleGithubCallback(request, response, url);
66
+ return;
67
+ }
68
+ if (request.method === "POST" && url.pathname === "/api/auth/logout") {
69
+ requireAllowedMutationOrigin(request);
70
+ clearSessionCookie(request, response);
71
+ sendJson(response, 200, { ok: true });
72
+ return;
73
+ }
74
+ if (request.method === "GET" && url.pathname === "/") {
75
+ send(response, 200, "text/html; charset=utf-8", renderHome());
76
+ return;
77
+ }
78
+ if (request.method === "GET" && (url.pathname === "/favicon.svg" || url.pathname === "/favicon.ico")) {
79
+ send(response, 200, "image/svg+xml; charset=utf-8", SNAPSHOT_LOGO_SVG);
80
+ return;
81
+ }
82
+ if (request.method === "GET" && url.pathname === "/health") {
83
+ sendJson(response, 200, {
84
+ ok: true,
85
+ service: "codex-snapshots-share-api",
86
+ auth: githubAuth.enabled ? "github" : tokenAuthEnabled ? "token" : "disabled",
87
+ owner: githubAuth.ownerLabel || null,
88
+ });
89
+ return;
90
+ }
91
+ if (request.method === "GET" && url.pathname === "/api/snapshots/health") {
92
+ sendJson(response, 200, {
93
+ ok: true,
94
+ shares: await storage.countShares(),
95
+ });
96
+ return;
97
+ }
98
+ if (request.method === "GET" && url.pathname === "/snapshots/share/") {
99
+ send(response, 200, "text/html; charset=utf-8", await renderSharePage(url.searchParams.get("id") || ""));
100
+ return;
101
+ }
102
+ if (request.method === "GET" && url.pathname === "/api/snapshots") {
103
+ const limit = readIntegerParam(url.searchParams.get("limit"), 50, 100);
104
+ const offset = readIntegerParam(url.searchParams.get("offset"), 0, 100_000);
105
+ const records = await storage.listShares();
106
+ const page = records.slice(offset, offset + limit);
107
+ sendJson(response, 200, {
108
+ schemaVersion: 1,
109
+ shares: page.map((record) => toShareSummary(record, request, url.searchParams.get("siteUrl"), url.searchParams.get("apiUrl"))),
110
+ count: page.length,
111
+ total: records.length,
112
+ limit,
113
+ offset,
114
+ });
115
+ return;
116
+ }
117
+ if (request.method === "POST" && url.pathname === "/api/snapshots") {
118
+ requireAllowedMutationOrigin(request);
119
+ const auth = requirePublishAuth(request);
120
+ const body = await readJsonBody(request, MAX_BODY_BYTES);
121
+ const snapshot = normalizeSnapshotPayloadForShare(body.snapshot ?? body);
122
+ if (!snapshot.redacted && process.env.SNAPSHOT_SHARE_ALLOW_UNREDACTED !== "true") {
123
+ sendJson(response, 400, {
124
+ error: "Refusing to publish an unredacted snapshot. Re-run without --no-redact, or set SNAPSHOT_SHARE_ALLOW_UNREDACTED=true on the server.",
125
+ });
126
+ return;
127
+ }
128
+ const now = new Date().toISOString();
129
+ const record = {
130
+ id: sanitizeShareId(body.shareId) || createShareId(),
131
+ title: snapshot.title,
132
+ engine: snapshot.engine,
133
+ engineLabel: snapshot.engineLabel,
134
+ sourceRef: snapshot.ref,
135
+ goalObjective: snapshot.goalObjective,
136
+ createdAt: now,
137
+ updatedAt: now,
138
+ expiresAt: expiryFromDays(body.expiresInDays),
139
+ redacted: snapshot.redacted,
140
+ turnCount: snapshot.turnCount,
141
+ owner: auth.user ? shareOwnerFromUser(auth.user) : undefined,
142
+ snapshot: snapshot.payload,
143
+ };
144
+ await storage.putShare(record);
145
+ sendJson(response, 200, {
146
+ ok: true,
147
+ id: record.id,
148
+ title: record.title,
149
+ turnCount: record.turnCount,
150
+ redacted: record.redacted,
151
+ expiresAt: record.expiresAt || null,
152
+ owner: record.owner || null,
153
+ url: snapshotShareUrl(request, record.id, body.siteUrl, body.apiUrl),
154
+ });
155
+ return;
156
+ }
157
+ if (request.method === "GET" && shareId) {
158
+ const record = await storage.getShare(shareId);
159
+ if (!record) {
160
+ sendJson(response, 404, { error: "Snapshot share not found" });
161
+ return;
162
+ }
163
+ sendJson(response, 200, {
164
+ schemaVersion: 1,
165
+ share: {
166
+ id: record.id,
167
+ title: record.title,
168
+ engine: record.engine,
169
+ engineLabel: record.engineLabel,
170
+ sourceRef: record.sourceRef,
171
+ goalObjective: record.goalObjective || record.snapshot?.goalObjective,
172
+ createdAt: record.createdAt,
173
+ updatedAt: record.updatedAt,
174
+ expiresAt: record.expiresAt || null,
175
+ redacted: record.redacted,
176
+ turnCount: record.turnCount,
177
+ owner: record.owner || null,
178
+ },
179
+ snapshot: record.snapshot,
180
+ });
181
+ return;
182
+ }
183
+ if (request.method === "DELETE" && shareId) {
184
+ requireAllowedMutationOrigin(request);
185
+ const auth = requireDeleteAuth(request);
186
+ const record = await storage.getShare(shareId);
187
+ if (!record) {
188
+ sendJson(response, 404, { error: "Snapshot share not found" });
189
+ return;
190
+ }
191
+ if (!canDeleteShare(auth, record)) {
192
+ sendJson(response, 403, { error: "Only the share owner or site owner can delete this snapshot" });
193
+ return;
194
+ }
195
+ const deleted = await storage.deleteShare(shareId);
196
+ sendJson(response, deleted ? 200 : 404, { ok: deleted, deleted, id: shareId });
197
+ return;
198
+ }
199
+ send(response, 404, "text/plain; charset=utf-8", "not found");
200
+ }
201
+ catch (error) {
202
+ const status = error?.statusCode || 500;
203
+ sendJson(response, status, { error: error instanceof Error ? error.message : String(error) });
204
+ }
205
+ });
206
+ await new Promise((resolve, reject) => {
207
+ server.once("error", reject);
208
+ server.listen(port, host, resolve);
209
+ });
210
+ console.log(`Codex Snapshots share API is running at http://${host}:${port}`);
211
+ console.log(`Storage: ${dataFile}`);
212
+ console.log(`Auth: ${githubAuth.enabled ? "GitHub OAuth required" : tokenAuthEnabled ? "SNAPSHOT_SHARE_TOKEN required" : "disabled (local/dev only)"}`);
213
+ }
214
+ function toShareSummary(record, request, rawSiteUrl, rawApiUrl) {
215
+ return {
216
+ id: record.id,
217
+ title: record.title,
218
+ engine: record.engine,
219
+ engineLabel: record.engineLabel,
220
+ sourceRef: record.sourceRef,
221
+ goalObjective: record.goalObjective || record.snapshot?.goalObjective,
222
+ createdAt: record.createdAt,
223
+ updatedAt: record.updatedAt,
224
+ expiresAt: record.expiresAt || null,
225
+ redacted: record.redacted,
226
+ turnCount: record.turnCount,
227
+ owner: record.owner || null,
228
+ url: snapshotShareUrl(request, record.id, rawSiteUrl, rawApiUrl),
229
+ };
230
+ }
231
+ function normalizeSnapshotPayloadForShare(value) {
232
+ if (!value || typeof value !== "object") {
233
+ throw new Error("Body must include a snapshot object");
234
+ }
235
+ const payload = removePrivateSnapshotFields(JSON.parse(JSON.stringify(value)));
236
+ const turns = Array.isArray(payload.turns) ? payload.turns : [];
237
+ const title = sanitizeText(payload.title, 180) || "Untitled snapshot";
238
+ const engine = sanitizeText(payload.engine, 80) || "codex";
239
+ const engineLabel = sanitizeText(payload.engineLabel, 80) || "Codex";
240
+ const ref = sanitizeText(payload.ref, 240) || undefined;
241
+ const goalObjective = sanitizeMultilineText(payload.goalObjective, 8000);
242
+ if (goalObjective) {
243
+ payload.goalObjective = goalObjective;
244
+ }
245
+ else {
246
+ delete payload.goalObjective;
247
+ }
248
+ if (!turns.length) {
249
+ throw new Error("Snapshot has no shareable turns");
250
+ }
251
+ sanitizeTurnHtml(payload);
252
+ return {
253
+ title,
254
+ engine,
255
+ engineLabel,
256
+ ref,
257
+ goalObjective,
258
+ redacted: payload.redacted !== false,
259
+ turnCount: turns.length,
260
+ payload,
261
+ };
262
+ }
263
+ function removePrivateSnapshotFields(value) {
264
+ if (!value || typeof value !== "object") {
265
+ return value;
266
+ }
267
+ if (Array.isArray(value)) {
268
+ return value.map(removePrivateSnapshotFields);
269
+ }
270
+ delete value.cwd;
271
+ delete value.filePath;
272
+ delete value.displayFilePath;
273
+ for (const [key, item] of Object.entries(value)) {
274
+ if (key === "images") {
275
+ continue;
276
+ }
277
+ value[key] = removePrivateSnapshotFields(item);
278
+ }
279
+ return value;
280
+ }
281
+ function sanitizeTurnHtml(snapshot) {
282
+ sanitizeSnapshotTurnHtml(snapshot);
283
+ }
284
+ function renderHome() {
285
+ return `<!doctype html>
286
+ <html lang="en">
287
+ <head>
288
+ <meta charset="utf-8">
289
+ <meta name="viewport" content="width=device-width, initial-scale=1">
290
+ <title>Codex Snapshots Share API</title>
291
+ <link rel="icon" type="image/svg+xml" href="/favicon.svg">
292
+ <style>${shareCss()}</style>
293
+ </head>
294
+ <body>
295
+ <main class="shell">
296
+ <p class="eyebrow">Codex Snapshots</p>
297
+ <h1>Share API is running</h1>
298
+ <p>Publish redacted snapshots with <code>pnpm snapshot publish</code>, then open the returned share link.</p>
299
+ <dl>
300
+ <div><dt>API</dt><dd><code>/api/snapshots</code></dd></div>
301
+ <div><dt>Viewer</dt><dd><code>/snapshots/share/?id=...</code></dd></div>
302
+ <div><dt>Storage</dt><dd><code>${escapeHtml(dataFile)}</code></dd></div>
303
+ </dl>
304
+ </main>
305
+ </body>
306
+ </html>`;
307
+ }
308
+ async function renderSharePage(initialId) {
309
+ const record = initialId ? await storage.getShare(initialId) : null;
310
+ const titleText = !initialId ? "Missing share id" : record ? record.title || "Snapshot" : "Snapshot unavailable";
311
+ const metaText = record
312
+ ? [
313
+ record.engineLabel || "Codex",
314
+ record.id,
315
+ `${record.turnCount || 0} entries`,
316
+ `redacted: ${record.redacted ? "yes" : "no"}`,
317
+ ].join(" | ")
318
+ : initialId
319
+ ? "Snapshot share not found."
320
+ : "Open a link with ?id=snap_...";
321
+ const contentHtml = record
322
+ ? renderTranscriptHtml(record.snapshot?.turns || [], {
323
+ emptyHtml: "<div class='empty'>This snapshot has no shareable turns.</div>",
324
+ labels: {
325
+ processed: "Processed",
326
+ tool: "Tool",
327
+ imageUnavailable: "Image unavailable",
328
+ imageAltPrefix: "Image attachment",
329
+ },
330
+ })
331
+ : `<div class="empty">${escapeHtml(initialId ? "Snapshot share not found." : "Open a link with ?id=snap_...")}</div>`;
332
+ const goalHtml = record?.snapshot?.goalObjective
333
+ ? `<section class="goal-meta"><span>Goal</span><p>${escapeHtml(record.snapshot.goalObjective)}</p></section>`
334
+ : "";
335
+ return `<!doctype html>
336
+ <html lang="en">
337
+ <head>
338
+ <meta charset="utf-8">
339
+ <meta name="viewport" content="width=device-width, initial-scale=1">
340
+ <title>Codex Snapshot Share</title>
341
+ <link rel="icon" type="image/svg+xml" href="/favicon.svg">
342
+ <style>${shareCss()}</style>
343
+ </head>
344
+ <body>
345
+ <main class="share">
346
+ <header class="share-header">
347
+ <p class="eyebrow">Cloud Read-only Snapshot</p>
348
+ <h1 id="title">${escapeHtml(titleText)}</h1>
349
+ <p id="meta" class="meta">${escapeHtml(metaText)}</p>
350
+ ${goalHtml}
351
+ </header>
352
+ <section id="content" class="turns">${contentHtml}</section>
353
+ </main>
354
+ </body>
355
+ </html>`;
356
+ }
357
+ function shareCss() {
358
+ return `
359
+ :root {
360
+ --ink: #16191f;
361
+ --muted: #69717d;
362
+ --line: #d9dee4;
363
+ --paper: #f4f0e7;
364
+ --panel: #fffdf8;
365
+ --blue: #255f82;
366
+ --shadow-soft: 0 24px 70px -58px rgba(22, 25, 31, 0.5);
367
+ }
368
+ * { box-sizing: border-box; }
369
+ body {
370
+ margin: 0;
371
+ min-height: 100vh;
372
+ color: var(--ink);
373
+ background:
374
+ linear-gradient(90deg, rgba(22, 25, 31, 0.065) 1px, transparent 1px),
375
+ linear-gradient(rgba(22, 25, 31, 0.038) 1px, transparent 1px),
376
+ var(--paper);
377
+ background-size: 24px 24px;
378
+ font-family: "Iowan Old Style", "Palatino Linotype", Georgia, serif;
379
+ }
380
+ code, pre, .eyebrow, .meta { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
381
+ .shell, .share { width: min(1180px, calc(100vw - 32px)); margin: 0 auto; padding: 32px 0 64px; }
382
+ .shell {
383
+ min-height: 100vh;
384
+ display: grid;
385
+ align-content: center;
386
+ }
387
+ .shell > *, .share-header {
388
+ border: 1px solid rgba(22, 25, 31, 0.12);
389
+ background: rgba(255, 253, 248, 0.92);
390
+ box-shadow: var(--shadow-soft);
391
+ }
392
+ .shell > * { padding: 28px; }
393
+ .share-header { padding: 24px; border-bottom: 3px solid var(--ink); }
394
+ .eyebrow {
395
+ margin: 0 0 10px;
396
+ color: var(--blue);
397
+ font-size: 12px;
398
+ font-weight: 900;
399
+ letter-spacing: 0.08em;
400
+ text-transform: uppercase;
401
+ }
402
+ h1 { margin: 0; font-size: clamp(36px, 7vw, 72px); line-height: 0.95; letter-spacing: 0; }
403
+ p { color: var(--muted); font-size: 18px; line-height: 1.65; }
404
+ dl { display: grid; gap: 10px; margin: 24px 0 0; }
405
+ dl div { display: grid; grid-template-columns: 120px minmax(0, 1fr); gap: 16px; border-top: 1px solid var(--line); padding-top: 10px; }
406
+ dt { color: var(--muted); font-weight: 700; }
407
+ dd { margin: 0; min-width: 0; overflow-wrap: anywhere; }
408
+ .goal-meta {
409
+ display: grid;
410
+ grid-template-columns: 72px minmax(0, 1fr);
411
+ gap: 14px;
412
+ border-top: 1px solid var(--line);
413
+ margin-top: 18px;
414
+ padding-top: 12px;
415
+ }
416
+ .goal-meta span {
417
+ color: var(--muted);
418
+ font: 900 11px/1.2 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
419
+ text-transform: uppercase;
420
+ }
421
+ .goal-meta p {
422
+ margin: 0;
423
+ color: var(--ink);
424
+ overflow-wrap: anywhere;
425
+ white-space: pre-wrap;
426
+ font-size: 15px;
427
+ line-height: 1.55;
428
+ }
429
+ .turns { display: grid; gap: 34px; margin-top: 34px; }
430
+ .turn { display: flex; min-width: 0; }
431
+ .turn.user { justify-content: flex-end; }
432
+ .turn.assistant, .turn.tool, .turn.process { justify-content: flex-start; }
433
+ .message-card { min-width: 0; max-width: min(960px, 76%); }
434
+ .turn.user .message-card {
435
+ border: 1px solid #d6e9e5;
436
+ border-radius: 18px;
437
+ background: #eef9f6;
438
+ padding: 20px 28px;
439
+ box-shadow: var(--shadow-soft);
440
+ }
441
+ .turn.tool .message-card {
442
+ width: min(960px, 86%);
443
+ border: 1px solid #efd99f;
444
+ border-radius: 8px;
445
+ background: #fff8df;
446
+ padding: 16px 18px;
447
+ }
448
+ .process-details {
449
+ width: min(960px, 76%);
450
+ border-top: 1px solid rgba(22, 25, 31, 0.12);
451
+ color: rgba(22, 25, 31, 0.62);
452
+ }
453
+ .process-summary {
454
+ display: inline-flex;
455
+ align-items: center;
456
+ gap: 9px;
457
+ min-height: 42px;
458
+ cursor: pointer;
459
+ list-style: none;
460
+ user-select: none;
461
+ font: 800 17px/1.2 ui-monospace, SFMono-Regular, Menlo, monospace;
462
+ }
463
+ .process-summary::-webkit-details-marker { display: none; }
464
+ .process-summary::after {
465
+ width: 8px;
466
+ height: 8px;
467
+ border-right: 2px solid currentColor;
468
+ border-bottom: 2px solid currentColor;
469
+ content: "";
470
+ transform: translateY(-2px) rotate(45deg);
471
+ transition: transform 0.16s ease;
472
+ }
473
+ .process-details[open] .process-summary::after {
474
+ transform: translateY(2px) rotate(225deg);
475
+ }
476
+ .process-body {
477
+ display: grid;
478
+ gap: 22px;
479
+ padding: 6px 0 8px;
480
+ }
481
+ .process-entry { min-width: 0; }
482
+ .process-tool {
483
+ max-width: min(880px, 100%);
484
+ border-left: 3px solid rgba(183, 121, 31, 0.32);
485
+ padding-left: 12px;
486
+ }
487
+ .body {
488
+ min-width: 0;
489
+ color: var(--ink);
490
+ font-size: 19px;
491
+ line-height: 1.75;
492
+ overflow-wrap: anywhere;
493
+ }
494
+ .body pre, .tool-details pre {
495
+ max-width: 100%;
496
+ overflow: auto;
497
+ border: 1px solid #253043;
498
+ border-radius: 8px;
499
+ background: #111722;
500
+ color: #edf4ff;
501
+ padding: 16px;
502
+ font: 13px/1.58 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
503
+ }
504
+ .body code {
505
+ border: 1px solid rgba(22, 25, 31, 0.12);
506
+ border-radius: 6px;
507
+ background: rgba(22, 25, 31, 0.06);
508
+ padding: 0.08rem 0.34rem;
509
+ font-size: 0.9em;
510
+ }
511
+ .attachment-grid { display: grid; gap: 18px; margin-top: 24px; }
512
+ .image-attachment { margin: 0; min-width: 0; }
513
+ .image-attachment img {
514
+ display: block;
515
+ max-width: 100%;
516
+ max-height: 540px;
517
+ border: 1px solid rgba(22, 25, 31, 0.18);
518
+ border-radius: 8px;
519
+ background: #fff;
520
+ object-fit: contain;
521
+ }
522
+ .image-unavailable {
523
+ border: 1px dashed var(--line);
524
+ border-radius: 8px;
525
+ padding: 16px;
526
+ color: var(--muted);
527
+ }
528
+ .empty { border: 1px solid var(--line); background: var(--panel); padding: 18px; color: var(--muted); }
529
+ @media (max-width: 820px) {
530
+ .message-card, .process-details, .turn.user .message-card, .turn.tool .message-card { max-width: 100%; width: 100%; }
531
+ .body { font-size: 17px; }
532
+ }
533
+ `;
534
+ }
535
+ function requirePublishAuth(request) {
536
+ const sessionUser = readSessionUser(request);
537
+ if (githubAuth.enabled && sessionUser) {
538
+ return { kind: "github", user: sessionUser };
539
+ }
540
+ if (tokenAuthEnabled && readBearerToken(request) === shareToken) {
541
+ return { kind: "token", user: null, isOwner: true };
542
+ }
543
+ if (!githubAuth.enabled && !tokenAuthEnabled && process.env.SNAPSHOT_SHARE_ALLOW_ANONYMOUS !== "false") {
544
+ return { kind: "anonymous", user: null };
545
+ }
546
+ const error = new Error(githubAuth.enabled ? "GitHub login required" : "Login required");
547
+ error.statusCode = 401;
548
+ throw error;
549
+ }
550
+ function requireDeleteAuth(request) {
551
+ return requirePublishAuth(request);
552
+ }
553
+ function canDeleteShare(auth, record) {
554
+ if (auth.kind === "token" || auth.isOwner) {
555
+ return true;
556
+ }
557
+ if (!auth.user) {
558
+ return false;
559
+ }
560
+ if (auth.user.isOwner) {
561
+ return true;
562
+ }
563
+ return sameGithubUser(auth.user, record.owner);
564
+ }
565
+ function sameGithubUser(left, right) {
566
+ if (!left || !right) {
567
+ return false;
568
+ }
569
+ const leftId = String(left.id || "");
570
+ const rightId = String(right.id || "");
571
+ if (leftId && rightId && leftId === rightId) {
572
+ return true;
573
+ }
574
+ const leftLogin = String(left.login || "").toLowerCase();
575
+ const rightLogin = String(right.login || "").toLowerCase();
576
+ return Boolean(leftLogin && rightLogin && leftLogin === rightLogin);
577
+ }
578
+ function shareOwnerFromUser(user) {
579
+ return {
580
+ id: String(user.id || ""),
581
+ login: String(user.login || ""),
582
+ avatarUrl: user.avatarUrl || "",
583
+ profileUrl: user.profileUrl || "",
584
+ };
585
+ }
586
+ function readGithubAuthConfig() {
587
+ const ownerId = sanitizeText(process.env.SNAPSHOT_GITHUB_OWNER_ID || process.env.SNAPSHOT_GITHUB_SITE_OWNER_ID, 80);
588
+ const ownerLogin = sanitizeText(process.env.SNAPSHOT_GITHUB_OWNER_LOGIN ||
589
+ process.env.SNAPSHOT_GITHUB_OWNER ||
590
+ process.env.SNAPSHOT_GITHUB_SITE_OWNER ||
591
+ "", 80).toLowerCase();
592
+ const clientId = sanitizeText(process.env.SNAPSHOT_GITHUB_CLIENT_ID || process.env.GITHUB_CLIENT_ID, 200);
593
+ const clientSecret = String(process.env.SNAPSHOT_GITHUB_CLIENT_SECRET || process.env.GITHUB_CLIENT_SECRET || "");
594
+ const sessionSecret = String(process.env.SNAPSHOT_SESSION_SECRET || process.env.SNAPSHOT_AUTH_SECRET || "");
595
+ return {
596
+ clientId,
597
+ clientSecret,
598
+ sessionSecret,
599
+ ownerId,
600
+ ownerLogin,
601
+ ownerLabel: ownerLogin || ownerId,
602
+ enabled: Boolean(clientId && clientSecret && sessionSecret),
603
+ };
604
+ }
605
+ function githubLoginStartUrl(request, rawReturnTo) {
606
+ const url = new URL(apiEndpointUrl(request, "/api/auth/github/start"));
607
+ const returnTo = sanitizeReturnTo(rawReturnTo, request);
608
+ if (returnTo) {
609
+ url.searchParams.set("returnTo", returnTo);
610
+ }
611
+ return url.toString();
612
+ }
613
+ function redirectToGithubLogin(request, response, rawReturnTo) {
614
+ if (!githubAuth.enabled) {
615
+ sendJson(response, 501, { error: "GitHub OAuth is not configured on this share API" });
616
+ return;
617
+ }
618
+ const redirectUri = githubCallbackUrl(request);
619
+ const state = signAuthPayload({
620
+ createdAt: Date.now(),
621
+ nonce: randomBytes(18).toString("base64url"),
622
+ returnTo: sanitizeReturnTo(rawReturnTo, request),
623
+ }, githubAuth.sessionSecret);
624
+ const githubUrl = new URL("https://github.com/login/oauth/authorize");
625
+ githubUrl.searchParams.set("client_id", githubAuth.clientId);
626
+ githubUrl.searchParams.set("redirect_uri", redirectUri);
627
+ githubUrl.searchParams.set("scope", "read:user");
628
+ githubUrl.searchParams.set("state", state);
629
+ response.writeHead(302, {
630
+ location: githubUrl.toString(),
631
+ "cache-control": "no-store",
632
+ });
633
+ response.end();
634
+ }
635
+ async function handleGithubCallback(request, response, url) {
636
+ if (!githubAuth.enabled) {
637
+ sendJson(response, 501, { error: "GitHub OAuth is not configured on this share API" });
638
+ return;
639
+ }
640
+ const code = sanitizeText(url.searchParams.get("code"), 400);
641
+ const state = sanitizeText(url.searchParams.get("state"), 8000);
642
+ const statePayload = verifyAuthPayload(state, githubAuth.sessionSecret);
643
+ if (!code || !statePayload || Date.now() - Number(statePayload.createdAt || 0) > 10 * 60 * 1000) {
644
+ sendJson(response, 400, { error: "Invalid or expired GitHub login state" });
645
+ return;
646
+ }
647
+ const accessToken = await exchangeGithubCodeForToken(code, githubCallbackUrl(request));
648
+ const githubUser = await fetchGithubUser(accessToken);
649
+ const sessionUser = sessionUserFromGithubUser(githubUser);
650
+ const cookieValue = signAuthPayload({
651
+ expiresAt: Date.now() + sessionMaxAgeSeconds() * 1000,
652
+ user: sessionUser,
653
+ }, githubAuth.sessionSecret);
654
+ setSessionCookie(request, response, cookieValue);
655
+ response.writeHead(302, {
656
+ location: sanitizeReturnTo(statePayload.returnTo, request),
657
+ "cache-control": "no-store",
658
+ });
659
+ response.end();
660
+ }
661
+ async function exchangeGithubCodeForToken(code, redirectUri) {
662
+ const response = await fetch("https://github.com/login/oauth/access_token", {
663
+ method: "POST",
664
+ headers: {
665
+ accept: "application/json",
666
+ "content-type": "application/json",
667
+ "user-agent": "codex-snapshots-share-api",
668
+ },
669
+ body: JSON.stringify({
670
+ client_id: githubAuth.clientId,
671
+ client_secret: githubAuth.clientSecret,
672
+ code,
673
+ redirect_uri: redirectUri,
674
+ }),
675
+ });
676
+ const payload = await response.json().catch(() => ({}));
677
+ if (!response.ok || !payload.access_token) {
678
+ const message = payload.error_description || payload.error || `GitHub token exchange failed with HTTP ${response.status}`;
679
+ const error = new Error(message);
680
+ error.statusCode = 502;
681
+ throw error;
682
+ }
683
+ return payload.access_token;
684
+ }
685
+ async function fetchGithubUser(accessToken) {
686
+ const response = await fetch("https://api.github.com/user", {
687
+ headers: {
688
+ accept: "application/vnd.github+json",
689
+ authorization: `Bearer ${accessToken}`,
690
+ "user-agent": "codex-snapshots-share-api",
691
+ "x-github-api-version": "2022-11-28",
692
+ },
693
+ });
694
+ const payload = await response.json().catch(() => ({}));
695
+ if (!response.ok || !payload.id || !payload.login) {
696
+ const error = new Error(payload.message || `GitHub user lookup failed with HTTP ${response.status}`);
697
+ error.statusCode = 502;
698
+ throw error;
699
+ }
700
+ return payload;
701
+ }
702
+ function sessionUserFromGithubUser(user) {
703
+ const id = String(user.id || "");
704
+ const login = sanitizeText(user.login, 80);
705
+ const sessionUser = {
706
+ id,
707
+ login,
708
+ name: sanitizeText(user.name, 120),
709
+ avatarUrl: sanitizeUrl(user.avatar_url),
710
+ profileUrl: sanitizeUrl(user.html_url) || `https://github.com/${encodeURIComponent(login)}`,
711
+ };
712
+ sessionUser.isOwner = isSiteOwner(sessionUser);
713
+ return sessionUser;
714
+ }
715
+ function isSiteOwner(user) {
716
+ if (!user) {
717
+ return false;
718
+ }
719
+ if (githubAuth.ownerId && String(user.id || "") === githubAuth.ownerId) {
720
+ return true;
721
+ }
722
+ return Boolean(githubAuth.ownerLogin && String(user.login || "").toLowerCase() === githubAuth.ownerLogin);
723
+ }
724
+ function readSessionUser(request) {
725
+ if (!githubAuth.enabled) {
726
+ return null;
727
+ }
728
+ const cookie = readCookies(request)[authCookieName];
729
+ const payload = verifyAuthPayload(cookie, githubAuth.sessionSecret);
730
+ if (!payload || Number(payload.expiresAt || 0) <= Date.now()) {
731
+ return null;
732
+ }
733
+ const rawUser = payload.user || {};
734
+ const user = {
735
+ id: sanitizeText(rawUser.id, 80),
736
+ login: sanitizeText(rawUser.login, 80),
737
+ name: sanitizeText(rawUser.name, 120),
738
+ avatarUrl: sanitizeUrl(rawUser.avatarUrl),
739
+ profileUrl: sanitizeUrl(rawUser.profileUrl),
740
+ };
741
+ if (!user.id || !user.login) {
742
+ return null;
743
+ }
744
+ user.isOwner = isSiteOwner(user);
745
+ return user;
746
+ }
747
+ function signAuthPayload(payload, secret) {
748
+ const body = Buffer.from(JSON.stringify(payload), "utf8").toString("base64url");
749
+ return `${body}.${signValue(body, secret)}`;
750
+ }
751
+ function verifyAuthPayload(value, secret) {
752
+ const text = String(value || "");
753
+ const dot = text.lastIndexOf(".");
754
+ if (dot <= 0) {
755
+ return null;
756
+ }
757
+ const body = text.slice(0, dot);
758
+ const signature = text.slice(dot + 1);
759
+ if (!constantTimeEqual(signature, signValue(body, secret))) {
760
+ return null;
761
+ }
762
+ try {
763
+ return JSON.parse(Buffer.from(body, "base64url").toString("utf8"));
764
+ }
765
+ catch {
766
+ return null;
767
+ }
768
+ }
769
+ function signValue(value, secret) {
770
+ return createHmac("sha256", secret).update(value).digest("base64url");
771
+ }
772
+ function constantTimeEqual(left, right) {
773
+ const leftBuffer = Buffer.from(String(left || ""));
774
+ const rightBuffer = Buffer.from(String(right || ""));
775
+ return leftBuffer.length === rightBuffer.length && timingSafeEqual(leftBuffer, rightBuffer);
776
+ }
777
+ function readCookies(request) {
778
+ const cookies = {};
779
+ const header = String(request.headers.cookie || "");
780
+ for (const part of header.split(";")) {
781
+ const [rawName, ...rawValue] = part.trim().split("=");
782
+ const name = rawName ? safeDecodeURIComponent(rawName) : "";
783
+ if (!name) {
784
+ continue;
785
+ }
786
+ cookies[name] = safeDecodeURIComponent(rawValue.join("=") || "");
787
+ }
788
+ return cookies;
789
+ }
790
+ function safeDecodeURIComponent(value) {
791
+ try {
792
+ return decodeURIComponent(value);
793
+ }
794
+ catch {
795
+ return "";
796
+ }
797
+ }
798
+ function setSessionCookie(request, response, value) {
799
+ response.setHeader("set-cookie", serializeCookie(authCookieName, value, {
800
+ httpOnly: true,
801
+ maxAge: sessionMaxAgeSeconds(),
802
+ path: "/",
803
+ sameSite: authCookieSameSite(request),
804
+ secure: authCookieSecure(request),
805
+ }));
806
+ }
807
+ function clearSessionCookie(request, response) {
808
+ response.setHeader("set-cookie", serializeCookie(authCookieName, "", {
809
+ httpOnly: true,
810
+ maxAge: 0,
811
+ path: "/",
812
+ sameSite: authCookieSameSite(request),
813
+ secure: authCookieSecure(request),
814
+ }));
815
+ }
816
+ function serializeCookie(name, value, options) {
817
+ const parts = [`${encodeURIComponent(name)}=${encodeURIComponent(value)}`, `Path=${options.path || "/"}`];
818
+ if (options.maxAge !== undefined) {
819
+ parts.push(`Max-Age=${Math.max(0, Math.floor(options.maxAge))}`);
820
+ }
821
+ if (options.httpOnly) {
822
+ parts.push("HttpOnly");
823
+ }
824
+ if (options.secure) {
825
+ parts.push("Secure");
826
+ }
827
+ if (options.sameSite) {
828
+ parts.push(`SameSite=${options.sameSite}`);
829
+ }
830
+ return parts.join("; ");
831
+ }
832
+ function sessionMaxAgeSeconds() {
833
+ const value = Number(process.env.SNAPSHOT_AUTH_SESSION_DAYS || 30);
834
+ const days = Number.isFinite(value) && value > 0 ? Math.min(value, 365) : 30;
835
+ return days * 24 * 60 * 60;
836
+ }
837
+ function authCookieSecure(request) {
838
+ if (process.env.SNAPSHOT_AUTH_COOKIE_SECURE === "false") {
839
+ return false;
840
+ }
841
+ if (process.env.SNAPSHOT_AUTH_COOKIE_SECURE === "true") {
842
+ return true;
843
+ }
844
+ return requestOrigin(request).startsWith("https://");
845
+ }
846
+ function authCookieSameSite(request) {
847
+ const value = sanitizeText(process.env.SNAPSHOT_AUTH_COOKIE_SAMESITE, 16).toLowerCase();
848
+ if (value === "lax" || value === "strict" || value === "none") {
849
+ return value[0].toUpperCase() + value.slice(1);
850
+ }
851
+ return authCookieSecure(request) ? "None" : "Lax";
852
+ }
853
+ function githubCallbackUrl(request) {
854
+ return apiEndpointUrl(request, "/api/auth/github/callback");
855
+ }
856
+ function apiEndpointUrl(request, pathname) {
857
+ const base = requestOrigin(request).replace(/\/+$/, "");
858
+ const path = pathname.startsWith("/") ? pathname : `/${pathname}`;
859
+ return `${base}${path}`;
860
+ }
861
+ function requestOrigin(request) {
862
+ const publicApiUrl = sanitizeUrl(process.env.SNAPSHOT_SHARE_PUBLIC_API_URL);
863
+ if (publicApiUrl) {
864
+ return publicApiUrl;
865
+ }
866
+ const forwardedProto = String(request.headers["x-forwarded-proto"] || "").split(",")[0].trim();
867
+ const forwardedHost = String(request.headers["x-forwarded-host"] || "").split(",")[0].trim();
868
+ const protocol = forwardedProto || (request.socket?.encrypted ? "https" : "http");
869
+ const requestHost = forwardedHost || request.headers.host || `${host}:${port}`;
870
+ return `${protocol}://${requestHost}`;
871
+ }
872
+ function sanitizeReturnTo(value, request) {
873
+ const fallback = sanitizeUrl(process.env.SNAPSHOT_SHARE_SITE_URL) || requestOrigin(request);
874
+ try {
875
+ const url = new URL(String(value || fallback));
876
+ if (isAllowedWebOrigin(url.origin)) {
877
+ return url.toString();
878
+ }
879
+ }
880
+ catch { }
881
+ return fallback;
882
+ }
883
+ function requireAllowedMutationOrigin(request) {
884
+ const origin = sanitizeOrigin(request.headers.origin);
885
+ if (!origin || isAllowedWebOrigin(origin)) {
886
+ return;
887
+ }
888
+ const error = new Error("Origin is not allowed");
889
+ error.statusCode = 403;
890
+ throw error;
891
+ }
892
+ function readBearerToken(request) {
893
+ const header = request.headers.authorization || "";
894
+ const match = String(header).match(/^Bearer\s+(.+)$/i);
895
+ return match?.[1]?.trim() || "";
896
+ }
897
+ async function readJsonBody(request, maxBytes) {
898
+ const chunks = [];
899
+ let size = 0;
900
+ for await (const chunk of request) {
901
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
902
+ size += buffer.length;
903
+ if (size > maxBytes) {
904
+ throw new Error("Request body is too large");
905
+ }
906
+ chunks.push(buffer);
907
+ }
908
+ const text = Buffer.concat(chunks).toString("utf8");
909
+ if (!text.trim()) {
910
+ throw new Error("Request body is empty");
911
+ }
912
+ return JSON.parse(text);
913
+ }
914
+ function shareIdFromPath(pathname) {
915
+ const match = pathname.match(/^\/api\/snapshots\/([^/]+)$/);
916
+ return match?.[1] ? decodeURIComponent(match[1]) : "";
917
+ }
918
+ function createShareId() {
919
+ return `snap_${randomBytes(18).toString("base64url")}`;
920
+ }
921
+ function sanitizeShareId(value) {
922
+ const text = sanitizeText(value, 90);
923
+ return /^snap_[A-Za-z0-9_-]{16,80}$/.test(text) ? text : "";
924
+ }
925
+ function expiryFromDays(value) {
926
+ const days = Number(value);
927
+ if (!Number.isFinite(days) || days <= 0) {
928
+ return undefined;
929
+ }
930
+ return new Date(Date.now() + Math.min(days, 365) * 24 * 60 * 60 * 1000).toISOString();
931
+ }
932
+ function snapshotShareUrl(request, id, rawSiteUrl, rawApiUrl) {
933
+ const requestUrl = `http://${request.headers.host || `${host}:${port}`}`;
934
+ const apiUrl = sanitizeUrl(rawApiUrl) || sanitizeUrl(process.env.SNAPSHOT_SHARE_PUBLIC_API_URL) || requestUrl;
935
+ const siteUrl = sanitizeUrl(rawSiteUrl) || sanitizeUrl(process.env.SNAPSHOT_SHARE_SITE_URL) || apiUrl;
936
+ const viewerPath = sanitizeViewerPath(process.env.SNAPSHOT_SHARE_VIEWER_PATH || (sameOrigin(siteUrl, apiUrl) ? "/snapshots/share/" : "/share/"));
937
+ const url = new URL(`${siteUrl.replace(/\/+$/, "")}${viewerPath}`);
938
+ url.searchParams.set("id", id);
939
+ if (!sameOrigin(siteUrl, apiUrl)) {
940
+ url.searchParams.set("api", apiUrl);
941
+ }
942
+ return url.toString();
943
+ }
944
+ function readIntegerParam(value, fallback, max) {
945
+ if (value === null || value === undefined || value === "") {
946
+ return fallback;
947
+ }
948
+ const parsed = Math.floor(Number(value));
949
+ if (!Number.isFinite(parsed) || parsed < 0) {
950
+ return fallback;
951
+ }
952
+ return Math.min(parsed, max);
953
+ }
954
+ function sameOrigin(left, right) {
955
+ try {
956
+ return new URL(left).origin === new URL(right).origin;
957
+ }
958
+ catch {
959
+ return false;
960
+ }
961
+ }
962
+ function sanitizeViewerPath(value) {
963
+ const text = sanitizeText(value, 160) || "/snapshots/share/";
964
+ const path = text.startsWith("/") ? text : `/${text}`;
965
+ return path.endsWith("/") ? path : `${path}/`;
966
+ }
967
+ function sanitizeText(value, maxLength) {
968
+ return typeof value === "string"
969
+ ? value.replace(/[\u0000-\u001f\u007f]/g, " ").replace(/\s+/g, " ").trim().slice(0, maxLength)
970
+ : "";
971
+ }
972
+ function sanitizeMultilineText(value, maxLength) {
973
+ return typeof value === "string"
974
+ ? value
975
+ .replace(/\r\n/g, "\n")
976
+ .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, " ")
977
+ .replace(/[ \t]+\n/g, "\n")
978
+ .replace(/\n{3,}/g, "\n\n")
979
+ .trim()
980
+ .slice(0, maxLength)
981
+ : "";
982
+ }
983
+ function sanitizeUrl(value) {
984
+ const text = sanitizeText(value, 400).replace(/\/+$/, "");
985
+ if (!text) {
986
+ return "";
987
+ }
988
+ try {
989
+ const url = new URL(text);
990
+ return url.protocol === "http:" || url.protocol === "https:" ? url.toString().replace(/\/+$/, "") : "";
991
+ }
992
+ catch {
993
+ return "";
994
+ }
995
+ }
996
+ function setCorsHeaders(request, response) {
997
+ const origin = sanitizeOrigin(request.headers.origin);
998
+ if (origin && isAllowedWebOrigin(origin)) {
999
+ response.setHeader("access-control-allow-origin", origin);
1000
+ response.setHeader("access-control-allow-credentials", "true");
1001
+ response.setHeader("vary", "origin");
1002
+ }
1003
+ else {
1004
+ response.setHeader("access-control-allow-origin", "*");
1005
+ }
1006
+ response.setHeader("access-control-allow-methods", "GET,POST,DELETE,OPTIONS");
1007
+ response.setHeader("access-control-allow-headers", "authorization,content-type");
1008
+ response.setHeader("access-control-max-age", "86400");
1009
+ }
1010
+ function isAllowedWebOrigin(origin) {
1011
+ const normalized = sanitizeOrigin(origin);
1012
+ if (!normalized) {
1013
+ return false;
1014
+ }
1015
+ if (isLocalOrigin(normalized)) {
1016
+ return true;
1017
+ }
1018
+ const allowed = new Set([
1019
+ sanitizeUrl(process.env.SNAPSHOT_SHARE_SITE_URL),
1020
+ sanitizeUrl(process.env.SNAPSHOT_SHARE_PUBLIC_API_URL),
1021
+ requestlessLocalApiOrigin(),
1022
+ ...String(process.env.SNAPSHOT_AUTH_ALLOWED_ORIGINS || process.env.SNAPSHOT_SHARE_ALLOWED_ORIGINS || "")
1023
+ .split(",")
1024
+ .map((value) => sanitizeUrl(value)),
1025
+ ]
1026
+ .filter(Boolean)
1027
+ .map((value) => new URL(value).origin));
1028
+ return allowed.has(normalized);
1029
+ }
1030
+ function isLocalOrigin(origin) {
1031
+ try {
1032
+ const url = new URL(origin);
1033
+ return (url.protocol === "http:" || url.protocol === "https:") && ["127.0.0.1", "localhost", "::1", "[::1]"].includes(url.hostname);
1034
+ }
1035
+ catch {
1036
+ return false;
1037
+ }
1038
+ }
1039
+ function sanitizeOrigin(value) {
1040
+ const text = sanitizeText(Array.isArray(value) ? value[0] : value, 400);
1041
+ if (!text) {
1042
+ return "";
1043
+ }
1044
+ try {
1045
+ const url = new URL(text);
1046
+ return url.protocol === "http:" || url.protocol === "https:" ? url.origin : "";
1047
+ }
1048
+ catch {
1049
+ return "";
1050
+ }
1051
+ }
1052
+ function requestlessLocalApiOrigin() {
1053
+ return `http://${host}:${port}`;
1054
+ }
1055
+ function sanitizeCookieName(value) {
1056
+ const text = sanitizeText(value, 80);
1057
+ return /^[A-Za-z0-9._-]+$/.test(text) ? text : "";
1058
+ }
1059
+ function sendJson(response, status, data) {
1060
+ send(response, status, "application/json; charset=utf-8", `${JSON.stringify(data, null, 2)}\n`);
1061
+ }
1062
+ function send(response, status, contentType, body) {
1063
+ response.writeHead(status, {
1064
+ "content-type": contentType,
1065
+ "cache-control": "no-store",
1066
+ });
1067
+ response.end(body);
1068
+ }
1069
+ function escapeHtml(value) {
1070
+ return String(value)
1071
+ .replace(/&/g, "&amp;")
1072
+ .replace(/</g, "&lt;")
1073
+ .replace(/>/g, "&gt;")
1074
+ .replace(/"/g, "&quot;")
1075
+ .replace(/'/g, "&#39;");
1076
+ }
1077
+ function expandHome(value) {
1078
+ const text = String(value || "");
1079
+ if (text === "~") {
1080
+ return os.homedir();
1081
+ }
1082
+ if (text.startsWith("~/")) {
1083
+ return path.join(os.homedir(), text.slice(2));
1084
+ }
1085
+ return text;
1086
+ }
1087
+ function parseArgs(args) {
1088
+ const options = {
1089
+ dataFile: "",
1090
+ host: "",
1091
+ port: "",
1092
+ token: "",
1093
+ };
1094
+ let help = false;
1095
+ for (let index = 0; index < args.length; index += 1) {
1096
+ const arg = args[index];
1097
+ if (arg === "--") {
1098
+ continue;
1099
+ }
1100
+ if (arg === "-h" || arg === "--help") {
1101
+ help = true;
1102
+ continue;
1103
+ }
1104
+ if (arg === "--host") {
1105
+ options.host = String(args[++index] || "");
1106
+ continue;
1107
+ }
1108
+ if (arg === "--port" || arg === "-p") {
1109
+ options.port = String(args[++index] || "");
1110
+ continue;
1111
+ }
1112
+ if (arg === "--data-file") {
1113
+ options.dataFile = String(args[++index] || "");
1114
+ continue;
1115
+ }
1116
+ if (arg === "--token") {
1117
+ options.token = String(args[++index] || "");
1118
+ continue;
1119
+ }
1120
+ throw new Error(`unknown option: ${arg}`);
1121
+ }
1122
+ return { help, options };
1123
+ }
1124
+ function printHelp() {
1125
+ console.log(`codex-snapshots share-api
1126
+
1127
+ Usage:
1128
+ node server/share-api.mjs [--host 127.0.0.1] [--port 8787] [--data-file FILE] [--token TOKEN]
1129
+
1130
+ Environment:
1131
+ SNAPSHOT_SHARE_TOKEN Bearer token required for publish/delete
1132
+ when GitHub OAuth is not configured
1133
+ SNAPSHOT_SHARE_DATA_FILE JSON storage file. Defaults to .codex-snapshots/shares.json
1134
+ SNAPSHOT_SHARE_SITE_URL Base URL used in returned share links
1135
+ SNAPSHOT_SHARE_PUBLIC_API_URL
1136
+ Public API base used in returned share links
1137
+ SNAPSHOT_SHARE_VIEWER_PATH Share page path. Defaults to /snapshots/share/ for same-origin links,
1138
+ or /share/ when API and site origins differ
1139
+ SNAPSHOT_GITHUB_CLIENT_ID
1140
+ SNAPSHOT_GITHUB_CLIENT_SECRET
1141
+ SNAPSHOT_SESSION_SECRET Enables GitHub login for publish/delete
1142
+ SNAPSHOT_GITHUB_OWNER_LOGIN
1143
+ SNAPSHOT_GITHUB_OWNER_ID Site owner; can delete any shared session
1144
+ SNAPSHOT_AUTH_ALLOWED_ORIGINS
1145
+ Extra comma-separated browser origins allowed to use login cookies
1146
+ SNAPSHOT_SHARE_ALLOW_UNREDACTED=true
1147
+ SNAPSHOT_SHARE_ALLOW_ANONYMOUS=false
1148
+ `);
1149
+ }