@phnx-labs/agents-cli 1.22.114 → 1.22.115

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 (176) hide show
  1. package/CHANGELOG.md +110 -0
  2. package/README.md +22 -102
  3. package/dist/browser.js +0 -0
  4. package/dist/cli/command-registry.js +0 -7
  5. package/dist/commands/accounts.d.ts +3 -77
  6. package/dist/commands/accounts.js +13 -366
  7. package/dist/commands/audit.js +1 -1
  8. package/dist/commands/auth.js +0 -2
  9. package/dist/commands/browser.d.ts +53 -0
  10. package/dist/commands/browser.js +199 -9
  11. package/dist/commands/doctor.d.ts +2 -3
  12. package/dist/commands/doctor.js +0 -11
  13. package/dist/commands/events.d.ts +1 -3
  14. package/dist/commands/events.js +4 -7
  15. package/dist/commands/exec.d.ts +0 -1
  16. package/dist/commands/exec.js +2 -7
  17. package/dist/commands/feed-watch.js +52 -8
  18. package/dist/commands/focus.js +1 -1
  19. package/dist/commands/go.d.ts +0 -17
  20. package/dist/commands/go.js +2 -19
  21. package/dist/commands/logs.js +1 -1
  22. package/dist/commands/mcp.js +8 -83
  23. package/dist/commands/memory.js +4 -47
  24. package/dist/commands/message.js +4 -4
  25. package/dist/commands/plugins.js +1 -93
  26. package/dist/commands/repo.js +0 -44
  27. package/dist/commands/resume.js +4 -1
  28. package/dist/commands/secrets-passthrough.js +2 -2
  29. package/dist/commands/send.d.ts +4 -4
  30. package/dist/commands/send.js +6 -51
  31. package/dist/commands/sessions-backup-setup.js +1 -1
  32. package/dist/commands/sessions-resume.js +0 -1
  33. package/dist/commands/sessions-share.d.ts +5 -7
  34. package/dist/commands/sessions-share.js +98 -49
  35. package/dist/commands/sessions.js +1 -8
  36. package/dist/commands/setup-browser.js +18 -2
  37. package/dist/commands/setup-computer.js +20 -6
  38. package/dist/commands/setup-secrets.js +24 -4
  39. package/dist/commands/setup-terminal.d.ts +3 -0
  40. package/dist/commands/setup-terminal.js +22 -0
  41. package/dist/commands/setup.d.ts +1 -1
  42. package/dist/commands/setup.js +20 -10
  43. package/dist/commands/skills.js +0 -8
  44. package/dist/commands/ssh.d.ts +6 -0
  45. package/dist/commands/ssh.js +92 -233
  46. package/dist/commands/sync.js +14 -5
  47. package/dist/commands/teams.js +1 -1
  48. package/dist/commands/traces.js +1 -1
  49. package/dist/lib/accounts/add.d.ts +0 -5
  50. package/dist/lib/accounts/add.js +1 -7
  51. package/dist/lib/artifacts-client.d.ts +20 -0
  52. package/dist/lib/artifacts-client.js +46 -0
  53. package/dist/lib/auth-mint.js +2 -2
  54. package/dist/lib/browser/runtime-state.d.ts +55 -0
  55. package/dist/lib/browser/runtime-state.js +99 -18
  56. package/dist/lib/browser/service.js +21 -1
  57. package/dist/lib/cli-resources.js +3 -1
  58. package/dist/lib/cloud/dispatch.js +1 -1
  59. package/dist/lib/cloudflare/creds.d.ts +10 -0
  60. package/dist/lib/cloudflare/creds.js +46 -0
  61. package/dist/lib/cloudflare/provision.d.ts +35 -0
  62. package/dist/lib/cloudflare/provision.js +144 -0
  63. package/dist/lib/computer/sessions-list.d.ts +55 -0
  64. package/dist/lib/computer/sessions-list.js +168 -1
  65. package/dist/lib/daemon/daemon.js +8 -1
  66. package/dist/lib/daemon/feed-stream-service.d.ts +23 -0
  67. package/dist/lib/daemon/feed-stream-service.js +40 -0
  68. package/dist/lib/daemon-services.d.ts +1 -1
  69. package/dist/lib/daemon-services.js +5 -0
  70. package/dist/lib/devices/connect.d.ts +49 -5
  71. package/dist/lib/devices/connect.js +169 -21
  72. package/dist/lib/feed/envelope.d.ts +79 -0
  73. package/dist/lib/feed/envelope.js +23 -0
  74. package/dist/lib/feed/events.d.ts +6 -0
  75. package/dist/lib/feed/events.js +8 -0
  76. package/dist/lib/feed/hub-server.d.ts +86 -0
  77. package/dist/lib/feed/hub-server.js +334 -0
  78. package/dist/lib/feed/hub.d.ts +95 -0
  79. package/dist/lib/feed/hub.js +255 -0
  80. package/dist/lib/feed/tool-activity.d.ts +108 -0
  81. package/dist/lib/feed/tool-activity.js +313 -0
  82. package/dist/lib/feed/tools.d.ts +198 -0
  83. package/dist/lib/feed/tools.js +265 -0
  84. package/dist/lib/feed/watch.d.ts +50 -50
  85. package/dist/lib/feed/watch.js +147 -16
  86. package/dist/lib/format.d.ts +1 -1
  87. package/dist/lib/format.js +1 -1
  88. package/dist/lib/git.d.ts +0 -16
  89. package/dist/lib/git.js +0 -58
  90. package/dist/lib/helper-versions.js +1 -1
  91. package/dist/lib/hosts/remote-cmd.d.ts +51 -1
  92. package/dist/lib/hosts/remote-cmd.js +125 -8
  93. package/dist/lib/hosts/remote-cmd.test-fixture.d.ts +2 -0
  94. package/dist/lib/hosts/remote-cmd.test-fixture.js +22 -0
  95. package/dist/lib/mcp.js +17 -11
  96. package/dist/lib/probe.d.ts +4 -1
  97. package/dist/lib/probe.js +5 -2
  98. package/dist/lib/pwsh.d.ts +33 -0
  99. package/dist/lib/pwsh.js +56 -0
  100. package/dist/lib/redact.d.ts +8 -0
  101. package/dist/lib/redact.js +11 -0
  102. package/dist/lib/refresh.d.ts +6 -2
  103. package/dist/lib/refresh.js +92 -72
  104. package/dist/lib/secrets-cli.d.ts +11 -0
  105. package/dist/lib/secrets-cli.js +30 -0
  106. package/dist/lib/secrets-client.js +3 -2
  107. package/dist/lib/session/detached.d.ts +7 -0
  108. package/dist/lib/session/detached.js +29 -0
  109. package/dist/lib/session/remote/peer-stream.d.ts +24 -2
  110. package/dist/lib/session/remote/peer-stream.js +33 -6
  111. package/dist/lib/session/sync/backend.d.ts +3 -3
  112. package/dist/lib/session/sync/backend.js +3 -3
  113. package/dist/lib/session/sync/provision.d.ts +1 -1
  114. package/dist/lib/session/sync/provision.js +2 -2
  115. package/dist/lib/sessions-client.js +0 -3
  116. package/dist/lib/setup-tool-install.d.ts +3 -0
  117. package/dist/lib/setup-tool-install.js +26 -0
  118. package/dist/lib/setup-tool-status.d.ts +22 -0
  119. package/dist/lib/setup-tool-status.js +215 -0
  120. package/dist/lib/share-runtime.d.ts +11 -0
  121. package/dist/lib/share-runtime.js +63 -0
  122. package/dist/lib/smart-launch.d.ts +1 -5
  123. package/dist/lib/smart-launch.js +3 -11
  124. package/dist/lib/ssh-exec.d.ts +44 -0
  125. package/dist/lib/ssh-exec.js +119 -0
  126. package/dist/lib/startup/command-registry.d.ts +6 -4
  127. package/dist/lib/startup/command-registry.js +10 -7
  128. package/dist/lib/state.js +2 -2
  129. package/dist/lib/storage/selection.d.ts +2 -2
  130. package/dist/lib/storage/selection.js +2 -2
  131. package/dist/lib/sync-umbrella.d.ts +5 -0
  132. package/dist/lib/sync-umbrella.js +18 -10
  133. package/dist/lib/traces/backend.d.ts +1 -2
  134. package/dist/lib/traces/backend.js +1 -2
  135. package/dist/lib/traces/provision.d.ts +1 -1
  136. package/dist/lib/traces/provision.js +2 -2
  137. package/dist/lib/types.d.ts +8 -6
  138. package/package.json +2 -3
  139. package/dist/commands/artifacts-setup.d.ts +0 -53
  140. package/dist/commands/artifacts-setup.js +0 -161
  141. package/dist/commands/artifacts.d.ts +0 -18
  142. package/dist/commands/artifacts.js +0 -58
  143. package/dist/commands/attach.d.ts +0 -12
  144. package/dist/commands/attach.js +0 -86
  145. package/dist/commands/auth-mint.d.ts +0 -12
  146. package/dist/commands/auth-mint.js +0 -108
  147. package/dist/commands/reconnect.d.ts +0 -46
  148. package/dist/commands/reconnect.js +0 -115
  149. package/dist/commands/share.d.ts +0 -293
  150. package/dist/commands/share.js +0 -1424
  151. package/dist/lib/share/analytics.d.ts +0 -13
  152. package/dist/lib/share/analytics.js +0 -45
  153. package/dist/lib/share/backend.d.ts +0 -120
  154. package/dist/lib/share/backend.js +0 -176
  155. package/dist/lib/share/capture.d.ts +0 -31
  156. package/dist/lib/share/capture.js +0 -174
  157. package/dist/lib/share/config.d.ts +0 -72
  158. package/dist/lib/share/config.js +0 -211
  159. package/dist/lib/share/delete.d.ts +0 -123
  160. package/dist/lib/share/delete.js +0 -173
  161. package/dist/lib/share/html.d.ts +0 -20
  162. package/dist/lib/share/html.js +0 -88
  163. package/dist/lib/share/http-error.d.ts +0 -53
  164. package/dist/lib/share/http-error.js +0 -65
  165. package/dist/lib/share/og.d.ts +0 -26
  166. package/dist/lib/share/og.js +0 -84
  167. package/dist/lib/share/provision.d.ts +0 -127
  168. package/dist/lib/share/provision.js +0 -285
  169. package/dist/lib/share/publish.d.ts +0 -379
  170. package/dist/lib/share/publish.js +0 -818
  171. package/dist/lib/share/worker-template.d.ts +0 -27
  172. package/dist/lib/share/worker-template.js +0 -2424
  173. package/dist/lib/storage/index.d.ts +0 -14
  174. package/dist/lib/storage/index.js +0 -14
  175. package/dist/lib/storage/visibility.d.ts +0 -82
  176. package/dist/lib/storage/visibility.js +0 -99
@@ -1,2424 +0,0 @@
1
- import { buildSync } from 'esbuild';
2
- import { dirname } from 'node:path';
3
- import { fileURLToPath } from 'node:url';
4
- let bundledWorker;
5
- let nodeWorkerScript;
6
- /** Bundle the Worker renderer into an ES module plus workerd-compiled WASM modules. */
7
- export function renderWorkerBundle() {
8
- if (bundledWorker)
9
- return bundledWorker;
10
- const result = buildSync({
11
- stdin: {
12
- contents: renderWorkerSource(),
13
- loader: 'js',
14
- resolveDir: dirname(fileURLToPath(import.meta.url)),
15
- sourcefile: 'agents-share-worker.js',
16
- },
17
- bundle: true,
18
- format: 'esm',
19
- platform: 'browser',
20
- target: 'es2022',
21
- write: false,
22
- outdir: 'worker-bundle',
23
- assetNames: '[name]-[hash]',
24
- minify: true,
25
- // Fonts are plain data and can live in JavaScript. WASM must remain a
26
- // compiled module: workerd deliberately forbids runtime code generation,
27
- // so esbuild's `binary` loader produces a bundle that works in Node but
28
- // throws "Wasm code generation disallowed by embedder" in Cloudflare.
29
- loader: { '.wasm': 'copy', '.woff': 'binary' },
30
- });
31
- const script = result.outputFiles.find((output) => output.path.endsWith('.js'));
32
- if (!script)
33
- throw new Error('Worker bundling produced no JavaScript output.');
34
- const modules = result.outputFiles
35
- .filter((output) => output.path.endsWith('.wasm'))
36
- .map((output) => ({
37
- name: output.path.split('/').pop(),
38
- contentType: 'application/wasm',
39
- contents: new Uint8Array(output.contents),
40
- }));
41
- if (modules.length !== 2) {
42
- throw new Error(`Worker bundling produced ${modules.length} WASM modules; expected yoga and resvg.`);
43
- }
44
- bundledWorker = { script: script.text, modules };
45
- return bundledWorker;
46
- }
47
- /** Single-file representation for direct Node tests, which cannot import compiled WASM modules. */
48
- export function renderWorkerScript() {
49
- if (nodeWorkerScript)
50
- return nodeWorkerScript;
51
- const result = buildSync({
52
- stdin: {
53
- contents: renderWorkerSource(),
54
- loader: 'js',
55
- resolveDir: dirname(fileURLToPath(import.meta.url)),
56
- sourcefile: 'agents-share-worker.js',
57
- },
58
- bundle: true,
59
- format: 'esm',
60
- platform: 'browser',
61
- target: 'es2022',
62
- write: false,
63
- minify: true,
64
- loader: { '.wasm': 'binary', '.woff': 'binary' },
65
- });
66
- const output = result.outputFiles[0];
67
- if (!output)
68
- throw new Error('Worker test bundling produced no JavaScript output.');
69
- nodeWorkerScript = output.text;
70
- return nodeWorkerScript;
71
- }
72
- /** Unbundled Worker source. Kept separate so esbuild can resolve npm modules. */
73
- export function renderWorkerSource() {
74
- return `import satori, { init as initSatori } from 'satori/wasm';
75
- import initYoga from 'yoga-wasm-web';
76
- import { Resvg, initWasm as initResvg } from '@resvg/resvg-wasm';
77
- import yogaWasm from 'yoga-wasm-web/dist/yoga.wasm';
78
- import resvgWasm from '@resvg/resvg-wasm/index_bg.wasm';
79
- import interRegular from '@fontsource/inter/files/inter-latin-400-normal.woff';
80
- import interBold from '@fontsource/inter/files/inter-latin-700-normal.woff';
81
- import jetbrainsMono from '@fontsource/jetbrains-mono/files/jetbrains-mono-latin-600-normal.woff';
82
-
83
- // GENERATED by agents-cli agents share setup — do not edit here; edit
84
- // src/lib/share/worker-template.ts and re-run setup.
85
- export const hooks = {
86
- verifyPhoenixToken: defaultVerifyPhoenixToken,
87
- renderOgCard: renderOgCard,
88
- collabFetch: defaultCollabFetch,
89
- };
90
-
91
- export default {
92
- async fetch(request, env, ctx) {
93
- const url = new URL(request.url);
94
- const path = decodeURIComponent(url.pathname.replace(/^\\/+/, ''));
95
-
96
- if (!path) {
97
- return new Response('agents share — POST is not it; PUT /<username>/<slug> to publish, GET /<username>/<slug> to view.', {
98
- status: 200, headers: { 'content-type': 'text/plain; charset=utf-8' },
99
- });
100
- }
101
-
102
- const firstSeg = path.split('/').filter(Boolean)[0] || '';
103
-
104
- // Same-origin human collaboration transport (PHNX-3835). Every /__collab/*
105
- // verb loads the R2 share object FIRST, re-derives identity/visibility/
106
- // provenance server-side, reuses the exact page read gate, then proxies to
107
- // the Prix artifact-collaboration API with the service token (never surfaced
108
- // to the browser). Routed BEFORE the __-prefix GET 404 guard below because
109
- // GET /__collab/context|threads|events are first-class. Fails closed (404)
110
- // when the managed backend is unconfigured — BYO has no Phoenix identity, so
111
- // managed collaboration correctly does not exist there.
112
- if (firstSeg === '__collab') {
113
- return handleCollab(request, env, url, path, ctx);
114
- }
115
-
116
- if ((request.method === 'GET' || request.method === 'HEAD') && firstSeg.startsWith('__')) {
117
- return new Response('not found', { status: 404, headers: { 'content-type': 'text/plain' } });
118
- }
119
-
120
- // POST /__ticket — mint a short-lived, single-navigation login ticket for the
121
- // authenticated owner (PHNX-3370). The CLI (which holds the Phoenix bearer)
122
- // calls this, then opens the owner's page with ?phoenix_ticket=<t> so the
123
- // browser trades it for the __share cookie and the inline visibility control
124
- // goes live. The ticket is SELF-signed with the Worker's own HMAC secret — no
125
- // external ticket service — and carries the SAME identity authorizeWrite
126
- // already proved, so it grants nothing the caller didn't already have.
127
- if (request.method === 'POST' && firstSeg === '__ticket') {
128
- const auth = await authorizeWrite(request, env);
129
- if (auth.error) return auth.error;
130
- const ticket = await signSelfTicket({ userId: auth.owner, email: auth.email || '' }, env);
131
- if (!ticket) return json({ error: 'ticket minting is not configured' }, 501);
132
- return json({ ticket: ticket }, 200);
133
- }
134
-
135
- if (request.method === 'PUT') {
136
- const auth = await authorizeWrite(request, env);
137
- if (auth.error) return auth.error;
138
- const vis = normalizeVisibility(request.headers.get('x-share-visibility'));
139
- if (vis.error) return vis.error;
140
- const visibility = vis.value;
141
- if (visibility === 'me' || visibility === 'org') {
142
- const phoenixBase = typeof env.PHOENIX_ID_BASE === 'string' ? env.PHOENIX_ID_BASE.replace(/\\/+$/, '') : '';
143
- if (auth.kind !== 'phoenix' || !phoenixBase) {
144
- return json({ error: 'visibility me/org requires Phoenix identity' }, 400);
145
- }
146
- if (visibility === 'org') {
147
- const domain = emailDomain(auth.email);
148
- if (!domain) return json({ error: 'org visibility requires a verified email domain' }, 400);
149
- if (PUBLIC_INBOX_DOMAINS.indexOf(domain) !== -1) {
150
- return json({ error: 'org visibility cannot use a public email domain', domain: domain }, 400);
151
- }
152
- }
153
- }
154
- // Token-gated read auth (PHNX-3654). A 'private' page is served only to a
155
- // request carrying the matching viewer key; we store just its SHA-256 hash
156
- // so the secret never lands in R2 metadata. Any write principal (Phoenix or
157
- // BYO WRITE_TOKEN) may publish 'private' — the gate is the token, not an
158
- // identity, unlike me/org. The token is mandatory: a private page with no
159
- // stored hash fails closed on read, so refuse the write instead.
160
- let viewerTokenHash = '';
161
- if (visibility === 'private') {
162
- const rawToken = request.headers.get('x-share-viewer-token') || '';
163
- if (!rawToken) return json({ error: 'visibility private requires a viewer token' }, 400);
164
- viewerTokenHash = await sha256Hex(rawToken);
165
- }
166
- const segments = path.split('/').filter(Boolean);
167
- if (auth.kind === 'phoenix') {
168
- // The caller's handle is normally derived from the email local-part. An
169
- // explicit x-share-handle (the CLI's --handle, PHNX-3547) lets a Phoenix
170
- // user choose a DIFFERENT free handle — the escape hatch when their
171
- // derived handle is taken or they want a vanity namespace. Sanitized to
172
- // the same [a-z0-9-] shape; claim rules below bind it first-writer-writes,
173
- // exactly like a derived handle.
174
- const requestedRaw = request.headers.get('x-share-handle') || '';
175
- const requested = sanitizeNamespace(requestedRaw);
176
- if (requestedRaw && (!requested || requested.length > 63)) {
177
- return json({ error: 'invalid handle', handle: requestedRaw }, 400);
178
- }
179
- const expected = requested || phoenixHandle(auth);
180
- if (!expected || segments[0] !== expected) {
181
- return json({ error: 'namespace mismatch', owner: expected }, 403);
182
- }
183
- const claimed = await claimHandle(env.BUCKET, expected, auth.owner, auth.email || '');
184
- if (claimed.error) return claimed.error;
185
- }
186
- const expiresAt = request.headers.get('x-share-expires-at') || '';
187
- // 'unlisted' hides the page from the public gallery + JSON listing while
188
- // keeping the direct URL world-readable (capability URL, not secret).
189
- // me/org are also hidden from gallery/listing; GET is identity-gated
190
- // and always sends X-Robots-Tag: noindex (same as unlisted).
191
- const contentType = request.headers.get('content-type') || 'text/html; charset=utf-8';
192
- // Provenance (RUSH-2683): captured client-side from the exec env/git/clock,
193
- // never invented here — a header is simply absent when the CLI had nothing
194
- // to say. --meta entries ride one JSON header; reserved keys are stripped
195
- // from that JSON UNCONDITIONALLY (not just overwritten when a provenance
196
- // header happens to be present) so a same-named x-share-meta entry can
197
- // never smuggle through on a publish that carries no agent/session/host/
198
- // repo/date at all — e.g. a human publishing outside an agent session, or
199
- // outside a git checkout.
200
- // Full-Unicode headers (PHNX-2786): the CLI always sends the latin1-safe
201
- // x-share-<field> (what a pre-Unicode Worker read), and when the fold was
202
- // lossy ALSO a percent-encoded x-share-<field>-u companion plus an
203
- // x-share-encoding: percent opt-in. Prefer the decoded companion when the
204
- // opt-in is present, so a Japanese/emoji title/label/--meta renders in full
205
- // instead of (unnamed) or a dropped glyph; a malformed companion falls
206
- // back to the folded value rather than failing the publish.
207
- const usePercent = (request.headers.get('x-share-encoding') || '') === 'percent';
208
- const readText = (name) => {
209
- const folded = request.headers.get('x-share-' + name) || '';
210
- if (usePercent) {
211
- const encoded = request.headers.get('x-share-' + name + '-u');
212
- if (encoded) {
213
- try {
214
- return decodeURIComponent(encoded);
215
- } catch (e) {
216
- return folded;
217
- }
218
- }
219
- }
220
- return folded;
221
- };
222
- const agent = readText('agent');
223
- const session = readText('session');
224
- const host = readText('host');
225
- const repo = readText('repo');
226
- const date = readText('date');
227
- const avatar = readText('avatar');
228
- const label = readText('label');
229
- const ogTitle = readText('og-title');
230
- const ogDescription = readText('og-description');
231
- const labelSource = request.headers.get('x-share-label-source') || '';
232
- let extraMeta = {};
233
- // The -u meta companion carries the whole raw object percent-encoded, so a
234
- // single decode+parse recovers full-Unicode keys and values; fall back to
235
- // the per-value-folded x-share-meta when it is absent or malformed.
236
- const metaEncoded = usePercent ? request.headers.get('x-share-meta-u') : null;
237
- if (metaEncoded) {
238
- try {
239
- const parsed = JSON.parse(decodeURIComponent(metaEncoded));
240
- if (parsed && typeof parsed === 'object') extraMeta = parsed;
241
- } catch (e) {
242
- // malformed unicode meta companion — fall through to the folded header
243
- }
244
- }
245
- if (!Object.keys(extraMeta).length) {
246
- const metaHeader = request.headers.get('x-share-meta');
247
- if (metaHeader) {
248
- try {
249
- const parsed = JSON.parse(metaHeader);
250
- if (parsed && typeof parsed === 'object') extraMeta = parsed;
251
- } catch {
252
- // malformed --meta header — ignore rather than fail the whole publish
253
- }
254
- }
255
- }
256
- const customMetadata = { ...extraMeta };
257
- // Strip every reserved key UNCONDITIONALLY before re-applying the real
258
- // provenance below — an if(value)-guarded overwrite alone leaves a
259
- // same-named --meta entry in place whenever the real header is absent.
260
- for (const reservedKey of RESERVED_METADATA_KEYS) {
261
- delete customMetadata[reservedKey];
262
- }
263
- if (expiresAt) customMetadata['expires-at'] = expiresAt;
264
- customMetadata['visibility'] = visibility;
265
- if (viewerTokenHash) customMetadata['viewer-token-hash'] = viewerTokenHash;
266
- const owner =
267
- auth.kind === 'phoenix'
268
- ? auth.owner
269
- : (env.SHARE_NAMESPACE || segments[0] || 'byo');
270
- if (owner) customMetadata['owner'] = owner;
271
- if (visibility === 'org') customMetadata['org_domain'] = emailDomain(auth.email);
272
- if (agent) customMetadata['agent'] = agent;
273
- if (session) customMetadata['session'] = session;
274
- if (host) customMetadata['host'] = host;
275
- if (repo) customMetadata['repo'] = repo;
276
- if (date) customMetadata['date'] = date;
277
- if (avatar) customMetadata['avatar'] = avatar;
278
- if (label) customMetadata['label'] = label;
279
- if (labelSource) customMetadata['label-source'] = labelSource;
280
- if (ogTitle) customMetadata['og-title'] = ogTitle;
281
- if (ogDescription) customMetadata['og-description'] = ogDescription;
282
-
283
- // Revision retention (RUSH-2683): R2 has no native object versioning, so a
284
- // republish over an EXISTING key first copies the current object to
285
- // <key>/rev-<ts>-<rand> before the canonical key is overwritten. Default
286
- // keep-all; --no-revision (x-share-no-revision) skips it. The random
287
- // suffix guards against two rapid overwrites of the same slug colliding on
288
- // the millisecond, and against a slug whose canonical key already sits 3+
289
- // segments deep (an unsupported shape outside the CLI) landing on the same
290
- // literal revision key.
291
- const noRevision = !!request.headers.get('x-share-no-revision');
292
- // PHNX-3542: per-user storage quota + object count + per-file size cap +
293
- // publish rate limit, enforced ONLY for a managed Phoenix identity. A BYO
294
- // WRITE_TOKEN publish writes to the operator's OWN bucket at their own
295
- // cost, so it skips all four — a deliberate, documented policy, NOT a
296
- // silent no-op.
297
- //
298
- // Enforcement measures the REAL request body, never a client-declared size.
299
- // A spoofed-low content-length must NOT (a) slip an oversized body past the
300
- // per-file cap, (b) let real bytes exceed the total quota, or — most
301
- // dangerously — (c) reach the destructive revision-copy + canonical
302
- // overwrite before the size is known and DESTROY the existing page. So for a
303
- // Phoenix write we buffer the body bounded by the plan's per-file cap and
304
- // reject on the REAL size BEFORE any write; only then do we copy the
305
- // revision and store the buffered bytes. BYO streams unbuffered (uncapped,
306
- // its own bucket) — which also means a BYO body cannot be re-read for a
307
- // retry, so BYO gets a single conditional attempt below.
308
- let putBody = request.body;
309
- let realBytes = 0;
310
- if (auth.kind === 'phoenix') {
311
- const limits = planLimits((await readUsage(env, auth.owner)).usage.plan);
312
- // Fast-reject an HONEST oversized content-length without reading the body.
313
- // A dishonest (absent or lied-low) length falls through to the bounded
314
- // read below, which measures the truth.
315
- const declaredLen = parseInt(request.headers.get('content-length') || '', 10);
316
- if (Number.isFinite(declaredLen) && declaredLen > limits.maxFileBytes) {
317
- return json({ error: 'file too large: max ' + limits.maxFileBytes + ' bytes', maxBytes: limits.maxFileBytes, gotBytes: declaredLen }, 413);
318
- }
319
- // readBodyBounded aborts the moment it passes the cap, so a chunked/
320
- // streaming body can never buffer more than the cap (+ one chunk).
321
- const read = await readBodyBounded(request, limits.maxFileBytes);
322
- if (read.oversize) {
323
- return json({ error: 'file too large: max ' + limits.maxFileBytes + ' bytes', maxBytes: limits.maxFileBytes, gotBytes: read.size }, 413);
324
- }
325
- realBytes = read.size;
326
- putBody = read.bytes;
327
- }
328
-
329
- // Revision retention + quota charge + canonical write as a BOUNDED
330
- // compare-and-swap loop (PHNX-3547). The old code read the current object,
331
- // archived it, then overwrote the canonical key UNCONDITIONALLY: two
332
- // concurrent republishers both archived the same old version and the
333
- // loser's new body ended up neither canonical nor retained — silently
334
- // discarded. R2 has no transactions, so each attempt re-reads the canonical
335
- // object, archives it as a revision, and overwrites ONLY while the etag
336
- // still matches the read (onlyIf.etagMatches — the same CAS primitive the
337
- // PATCH path at :513 and the usage ledger already rely on; R2 returns null
338
- // on the mismatch instead of storing). A conflicted attempt therefore loses
339
- // nothing: it re-reads the winner's body, archives THAT as the revision on
340
- // the next attempt, and lands its own body canonical — both writers survive.
341
- // Phoenix bytes are buffered above, so retrying is safe; the rate counter
342
- // and object count advance once (attempt 0) and a conflicted re-charge only
343
- // bills the growth beyond what this request already paid. A BYO stream is
344
- // consumed by the first attempt, so it gets one conditional try and a 409
345
- // asking the caller to retry the whole publish.
346
- const MAX_PUT_ATTEMPTS = 3;
347
- let chargedAlready = 0;
348
- let chargedNewCanonical = false;
349
- let putResult = null;
350
- for (let attempt = 0; attempt < MAX_PUT_ATTEMPTS; attempt++) {
351
- // The current object is needed for BOTH the revision copy and the charge
352
- // math, and even a first/no-revision publish must read before its
353
- // conditional write so two concurrent creates cannot both report 200.
354
- const existing = await env.BUCKET.get(path);
355
- if (auth.kind === 'phoenix') {
356
- const existingSize = existing && typeof existing.size === 'number' ? existing.size : 0;
357
- const newCanonical = !existing;
358
- // Keeping a revision retains the old canonical bytes AND adds the new
359
- // ones, so storage grows by the full new size. A no-revision or first
360
- // publish grows by new minus the bytes it replaces (may be negative on a
361
- // shrink; the ledger clamps at >= 0).
362
- const charge = (!noRevision && existing) ? realBytes : realBytes - existingSize;
363
- // Conflict retry: only the growth beyond what this request already
364
- // paid. Exact accounting under a lost race is impossible without
365
- // reconciling the bucket; this stays the ledger's documented
366
- // best-effort, same as its >= 0 clamp.
367
- const bill = Math.max(0, charge - chargedAlready);
368
- chargedAlready += bill;
369
- const chargeNewCanonical = newCanonical && attempt === 0;
370
- const charged = await chargeShareWrite(env, auth, {
371
- charge: bill,
372
- newCanonical: chargeNewCanonical,
373
- fileBytes: realBytes,
374
- countRate: attempt === 0,
375
- });
376
- if (charged.error) return charged.error; // rejected BEFORE any destructive write
377
- if (chargeNewCanonical) chargedNewCanonical = true;
378
- }
379
-
380
- if (!noRevision && existing) {
381
- const existingHeaders = new Headers();
382
- if (typeof existing.writeHttpMetadata === 'function') existing.writeHttpMetadata(existingHeaders);
383
- const existingContentType = existingHeaders.get('content-type');
384
- const revKey = path + '/rev-' + Date.now() + '-' + Math.random().toString(36).slice(2, 8);
385
- await env.BUCKET.put(revKey, existing.body, {
386
- httpMetadata: existingContentType ? { contentType: existingContentType } : undefined,
387
- customMetadata: existing.customMetadata || {},
388
- });
389
- }
390
-
391
- const putOpts = { httpMetadata: { contentType }, customMetadata };
392
- // Existing objects use the bare R2Object#etag for CAS; missing objects
393
- // use R2's create-only condition so a concurrent first writer wins loud.
394
- if (existing && existing.etag) putOpts.onlyIf = { etagMatches: existing.etag };
395
- else putOpts.onlyIf = { etagDoesNotMatch: '*' };
396
- putResult = await env.BUCKET.put(path, putBody, putOpts);
397
- if (putResult !== null) break;
398
- // A failed create-only condition means another first publisher won.
399
- // Do not turn that loser into a republish: the caller must see the race.
400
- if (!existing) break;
401
- if (auth.kind !== 'phoenix') break; // BYO stream is spent — cannot retry
402
- }
403
- if (putResult === null) {
404
- if (auth.kind === 'phoenix') {
405
- await refundShareWrite(env, auth.owner, {
406
- refund: chargedAlready,
407
- freeCanonical: chargedNewCanonical,
408
- });
409
- }
410
- return json({ error: 'publish conflict: another write landed first, retry the publish' }, 409);
411
- }
412
- // A managed republish may change the title/description. Invalidate only
413
- // its generated sibling so the next crawler receives a fresh card; BYO
414
- // publishes send no OG metadata and keep their explicitly uploaded cover.
415
- if (ogTitle) await env.BUCKET.delete(path + '.png');
416
- return json({ ok: true, url: url.origin + '/' + path, expiresAt: expiresAt || null, unlisted: visibility === 'unlisted', visibility }, 200);
417
- }
418
-
419
- if (request.method === 'PATCH') {
420
- const auth = await authorizeWrite(request, env);
421
- if (auth.error) return auth.error;
422
- const segments = path.split('/').filter(Boolean);
423
- if (segments.length < 2) return json({ error: 'metadata edit requires /<username>/<slug>' }, 400);
424
- if (auth.kind === 'phoenix') {
425
- const expected = phoenixHandle(auth);
426
- const handle = segments[0];
427
- if (!expected || handle !== expected) {
428
- // An alternate handle must already have a claim; unlike PUT, PATCH
429
- // cannot create a new namespace as a side effect.
430
- const claim = handle ? await env.BUCKET.get('__handles/' + handle) : null;
431
- if (!claim) return json({ error: 'namespace mismatch', owner: expected }, 403);
432
- }
433
- // Use the same ownership path as PUT/DELETE so the same verified email
434
- // under a new userId transfers the claim and re-stamps old pages before
435
- // the per-object ownership check below.
436
- const owned = await assertHandleOwner(env.BUCKET, handle, auth.owner, auth.email || '');
437
- if (owned.error) return json({ error: 'forbidden' }, 403);
438
- }
439
- const existing = await env.BUCKET.get(path);
440
- if (!existing) return json({ error: 'share not found', key: path }, 404);
441
- // Ownership was settled above, the same way DELETE settles it: WRITE_TOKEN
442
- // is the endpoint owner/admin credential, and a Phoenix caller has proven
443
- // the handle claim (or, pre-claim, that no rival userId owns the prefix).
444
- // There is deliberately NO per-object owner comparison here. Pages in a
445
- // claimed namespace can carry a stamp that is not the claim holder's
446
- // userId — a BYO WRITE_TOKEN publish stamps owner = the namespace, a page
447
- // from the same human's earlier userId that transferHandle never saw, or
448
- // a page with no stamp at all — and the claim holder could DELETE every
449
- // one of them yet was refused a visibility change (403 'forbidden'), which
450
- // left confidential pages public with takedown as the only remedy. The
451
- // claim is the authority. The stamp itself is deliberately NOT rewritten:
452
- // the anonymous lazy-expiry path refunds the STAMPED owner's usage ledger
453
- // (see the GET expiry branch + refundShareWrite), and a fleet/BYO page was
454
- // never charged to a Phoenix ledger — re-stamping it to the caller would
455
- // credit her quota with bytes and a slot she never paid for on expiry.
456
-
457
- let edit;
458
- try { edit = await request.json(); } catch { return json({ error: 'PATCH body must be JSON' }, 400); }
459
- if (!edit || typeof edit !== 'object') return json({ error: 'PATCH body must be an object' }, 400);
460
- const metadata = { ...(existing.customMetadata || {}) };
461
- const previousVisibility = metadata.visibility || 'public';
462
- let visibilityChanged = false;
463
- if (Object.prototype.hasOwnProperty.call(edit, 'label')) {
464
- if (edit.label !== null && typeof edit.label !== 'string') return json({ error: 'label must be a string or null' }, 400);
465
- if (typeof edit.label === 'string' && (edit.label.trim() !== edit.label || !edit.label || edit.label.length > 200)) return json({ error: 'label must be 1-200 trimmed characters' }, 400);
466
- if (edit.label === null) { delete metadata.label; delete metadata['label-source']; }
467
- else { metadata.label = edit.label; metadata['label-source'] = 'explicit'; }
468
- }
469
- // Visibility is a first-class edit field (like label), not an arbitrary
470
- // --meta entry — 'visibility' is a RESERVED key, so it can only be changed
471
- // here, never smuggled through edit.meta. me/org require a Phoenix identity
472
- // and org additionally requires a private (non-public-inbox) email domain,
473
- // the SAME gate PUT enforces. This is a metadata-only rewrite: the body is
474
- // untouched, so no revision is created (identical to a label/meta edit).
475
- if (Object.prototype.hasOwnProperty.call(edit, 'visibility')) {
476
- const vis = normalizeVisibility(edit.visibility);
477
- if (vis.error) return vis.error;
478
- const visibility = vis.value;
479
- // 'private' can't be set via the metadata-edit route: token-gating needs a
480
- // fresh viewer key, which only a full publish mints (PHNX-3654). Re-stamping
481
- // visibility=private here alone would leave the page gated by a hash that
482
- // never got stored — inaccessible to everyone. Fail loud.
483
- if (visibility === 'private') return json({ error: 'visibility private must be set at publish time (share --protected)' }, 400);
484
- if (visibility === 'me' || visibility === 'org') {
485
- const phoenixBase = typeof env.PHOENIX_ID_BASE === 'string' ? env.PHOENIX_ID_BASE.replace(/\\/+$/, '') : '';
486
- if (auth.kind !== 'phoenix' || !phoenixBase) return json({ error: 'visibility me/org requires Phoenix identity' }, 400);
487
- if (visibility === 'org') {
488
- const domain = emailDomain(auth.email);
489
- if (!domain) return json({ error: 'org visibility requires a verified email domain' }, 400);
490
- if (PUBLIC_INBOX_DOMAINS.indexOf(domain) !== -1) return json({ error: 'org visibility cannot use a public email domain', domain: domain }, 400);
491
- }
492
- }
493
- metadata.visibility = visibility;
494
- visibilityChanged = visibility !== previousVisibility;
495
- // org_domain is meaningful only for org — set it there, drop the stale
496
- // value on any move away from org so it can never gate a later read.
497
- if (visibility === 'org') metadata.org_domain = emailDomain(auth.email);
498
- else delete metadata.org_domain;
499
- }
500
- const mode = edit.metaMode || 'merge';
501
- if (mode !== 'merge' && mode !== 'replace') return json({ error: 'metaMode must be merge or replace' }, 400);
502
- const incoming = edit.meta || {};
503
- if (!incoming || typeof incoming !== 'object' || Array.isArray(incoming)) return json({ error: 'meta must be an object' }, 400);
504
- for (const key in incoming) {
505
- if (RESERVED_METADATA_KEYS.indexOf(key) !== -1) return json({ error: 'reserved metadata key', key: key }, 400);
506
- if (!/^[a-z0-9-]{1,64}$/.test(key) || typeof incoming[key] !== 'string') return json({ error: 'invalid metadata entry', key: key }, 400);
507
- }
508
- if (mode === 'replace') {
509
- for (const key in metadata) if (RESERVED_METADATA_KEYS.indexOf(key) === -1) delete metadata[key];
510
- }
511
- for (const key in incoming) metadata[key] = incoming[key];
512
- const remove = edit.removeMeta || [];
513
- if (!Array.isArray(remove)) return json({ error: 'removeMeta must be an array' }, 400);
514
- for (const key of remove) {
515
- if (typeof key !== 'string' || RESERVED_METADATA_KEYS.indexOf(key) !== -1 || !/^[a-z0-9-]{1,64}$/.test(key)) return json({ error: 'invalid removable metadata key', key: key }, 400);
516
- delete metadata[key];
517
- }
518
- if (new TextEncoder().encode(JSON.stringify(extraMetaOf(metadata))).length > 2048) return json({ error: 'metadata exceeds 2048 bytes' }, 400);
519
- // R2 assigns a fresh uploaded timestamp to every put. Preserve the
520
- // canonical publication time before this metadata-only rewrite so gallery
521
- // ordering and list JSON do not pretend the page was republished today.
522
- if (!metadata['published-at']) metadata['published-at'] = new Date(existing.uploaded).toISOString();
523
- const httpHeaders = new Headers();
524
- if (typeof existing.writeHttpMetadata === 'function') existing.writeHttpMetadata(httpHeaders);
525
- const putOpts = { httpMetadata: httpHeaders, customMetadata: metadata };
526
- // onlyIf.etagMatches wants the bare hash (R2Object#etag), not the
527
- // quoted HTTP header form (R2Object#httpEtag) — passing httpEtag 500s
528
- // on a real Workers R2 binding ("Conditional ETag should not be
529
- // wrapped in quotes"), a defect only a real R2 backend surfaces.
530
- if (existing.etag) putOpts.onlyIf = { etagMatches: existing.etag };
531
- const putResult = await env.BUCKET.put(path, existing.body, putOpts);
532
- if (putResult === null) return json({ error: 'conflict', key: path }, 409);
533
- // A generated cover copies the canonical visibility gate into its own R2
534
- // metadata. Invalidate it after a successful visibility rewrite so the
535
- // next cover GET re-gates and renders from the canonical page. Explicitly
536
- // uploaded BYO siblings carry no marker and remain independent assets.
537
- if (visibilityChanged) {
538
- const cover = await env.BUCKET.get(path + '.png');
539
- if (cover && cover.customMetadata && cover.customMetadata['og-generated'] === 'true') {
540
- await env.BUCKET.delete(path + '.png');
541
- }
542
- }
543
- return json({ ok: true, url: url.origin + '/' + path, label: metadata.label || null, meta: extraMetaOf(metadata), visibility: metadata.visibility || 'public', previousVisibility: previousVisibility }, 200);
544
- }
545
-
546
- if (request.method === 'GET' || request.method === 'HEAD') {
547
- // Single-segment path may be a user gallery/listing OR a legacy flat slug.
548
- // The disambiguator is whether the <seg>/ prefix holds any object: only a
549
- // genuine per-user namespace does. BOTH the HTML gallery and the
550
- // machine-readable listing gate on it, so ?format=json on a legacy flat
551
- // slug (an object stored at the bare key, before per-user namespaces) does
552
- // NOT hijack it into a fake empty listing — it falls through to the object
553
- // GET below and serves its real content, exactly as before.
554
- const segments = path.split('/').filter(Boolean);
555
- if (segments.length === 1) {
556
- const list = await env.BUCKET.list({ prefix: segments[0] + '/', limit: 1 });
557
- const isNamespace = list.objects && list.objects.length > 0;
558
- if (isNamespace) {
559
- if (url.searchParams.get('format') === 'json') {
560
- const scope = await resolveListingScope(request, env, segments[0]);
561
- if (scope.error) return scope.error;
562
- return renderListing(env.BUCKET, url.origin, segments[0], request.method, scope.includeHidden);
563
- }
564
- return renderGallery(env.BUCKET, url.origin, segments[0], request.method);
565
- }
566
- // Not a namespace: fall through. A legacy flat-slug object resolves to its
567
- // real content; anything else 404s (an empty/nonexistent namespace, which
568
- // agents share list reads as "nothing published" via the template-hash
569
- // signal rather than a missing route).
570
- }
571
-
572
- // Revision history for one canonical <user>/<slug> key (RUSH-2683). Checked
573
- // before the plain object GET below so the query param routes even though
574
- // the canonical key itself resolves to a real object. me/org use the same
575
- // identity gate as the page GET — listing session/host/repo on an unauthed
576
- // ?revisions=json must not leak that the page exists.
577
- if (segments.length === 2 && url.searchParams.get('revisions') === 'json') {
578
- const canonical = await env.BUCKET.get(path);
579
- const canonicalVis = canonical ? ((canonical.customMetadata && canonical.customMetadata.visibility) || 'public') : 'public';
580
- // Only a me/org canonical needs the viewer resolved — and only there does
581
- // a phoenix_ticket failure gate the response, exactly as before this route
582
- // shared resolveViewer with the page GET. A public/unlisted revision list
583
- // never invoked ticket redemption, so a stale ticket must not 401 it.
584
- if (canonical && isIdentityGated(canonicalVis)) {
585
- const viewer = await resolveViewer(request, env, url);
586
- if (viewer.redirect) return viewer.redirect;
587
- if (viewer.error) return viewer.error;
588
- const denied = await gateVisibility(url, env, canonical, viewer.identity || null);
589
- if (denied) return denied;
590
- }
591
- // A private canonical gates its revision list on the viewer key too
592
- // (PHNX-3654) — listing session/host/repo of a token-gated page to an
593
- // unauthenticated caller would leak the very metadata the gate protects.
594
- if (canonical && canonicalVis === 'private') {
595
- const viewer = await resolveViewer(request, env, url);
596
- if (viewer.redirect) return viewer.redirect;
597
- const tokenDenied = await gateTokenRead(request, url, canonical, viewer.identity || null);
598
- if (tokenDenied) return tokenDenied;
599
- }
600
- return renderRevisions(
601
- env.BUCKET,
602
- url.origin,
603
- path,
604
- request.method,
605
- !!canonical && (isIdentityGated(canonicalVis) || canonicalVis === 'private'),
606
- );
607
- }
608
-
609
- // Managed OG cover: generated siblings are caches, never visibility
610
- // authorities. Gate every request against the current canonical page and
611
- // bind each cached render to that page's etag. Re-read after rendering and
612
- // after storing so a concurrent PATCH cannot publish a card from the old
613
- // visibility/title/description snapshot. Explicit BYO siblings carry no
614
- // generated marker and continue through the ordinary object route below.
615
- if (segments.length === 2 && path.endsWith('.png')) {
616
- let existingCover = await env.BUCKET.get(path);
617
- if (!existingCover || (existingCover.customMetadata && existingCover.customMetadata['og-generated'] === 'true')) {
618
- const pagePath = path.slice(0, -4);
619
- const viewer = await resolveViewer(request, env, url);
620
- if (viewer.redirect) return viewer.redirect;
621
- for (let attempt = 0; attempt < 3; attempt += 1) {
622
- const page = await env.BUCKET.get(pagePath);
623
- if (!page) return new Response('not found', { status: 404, headers: { 'content-type': 'text/plain' } });
624
- const pageVisibility = (page.customMetadata && page.customMetadata.visibility) || 'public';
625
- if (viewer.error && isIdentityGated(pageVisibility)) return viewer.error;
626
- const denied = await gateVisibility(url, env, page, viewer.identity || null);
627
- if (denied) return denied;
628
- // A token-gated page's cover is token-gated too (PHNX-3654): a crawler
629
- // fetching <slug>.png without the key gets 404, so no preview leaks.
630
- const coverTokenDenied = await gateTokenRead(request, url, page, viewer.identity || null);
631
- if (coverTokenDenied) return coverTokenDenied;
632
-
633
- if (existingCover && existingCover.customMetadata['og-source-etag'] === page.etag) {
634
- const current = await env.BUCKET.get(pagePath);
635
- if (!current || current.etag !== page.etag) { existingCover = null; continue; }
636
- const currentDenied = await gateVisibility(url, env, current, viewer.identity || null);
637
- if (currentDenied) return currentDenied;
638
- return new Response(request.method === 'HEAD' ? null : existingCover.body, {
639
- status: 200,
640
- headers: managedCoverHeaders(pageVisibility),
641
- });
642
- }
643
-
644
- const pageHtml = await page.text();
645
- const meta = page.customMetadata || {};
646
- let png;
647
- try {
648
- png = await hooks.renderOgCard({
649
- title: meta['og-title'] || extractHtmlMeta(pageHtml, 'title') || meta.label || segments[1],
650
- description: meta['og-description'] || extractHtmlMeta(pageHtml, 'description') || '',
651
- handle: segments[0],
652
- visibility: pageVisibility,
653
- orgDomain: meta.org_domain || '',
654
- });
655
- } catch (error) {
656
- const detail = error instanceof Error ? error.message : String(error);
657
- return new Response('OG card render failed: ' + detail, {
658
- status: 500,
659
- headers: { 'content-type': 'text/plain; charset=utf-8' },
660
- });
661
- }
662
- const beforeStore = await env.BUCKET.get(pagePath);
663
- if (!beforeStore || beforeStore.etag !== page.etag) { existingCover = null; continue; }
664
- await env.BUCKET.put(path, png, {
665
- httpMetadata: { contentType: 'image/png' },
666
- customMetadata: { ...meta, 'og-generated': 'true', 'og-source-etag': page.etag },
667
- });
668
- const beforeServe = await env.BUCKET.get(pagePath);
669
- if (!beforeServe || beforeServe.etag !== page.etag) {
670
- const justStored = await env.BUCKET.get(path);
671
- if (justStored && justStored.customMetadata && justStored.customMetadata['og-source-etag'] === page.etag) {
672
- await env.BUCKET.delete(path);
673
- }
674
- existingCover = null;
675
- continue;
676
- }
677
- return new Response(request.method === 'HEAD' ? null : png, {
678
- status: 200,
679
- headers: managedCoverHeaders(pageVisibility),
680
- });
681
- }
682
- return new Response('cover changed during rendering; retry', { status: 503, headers: { 'content-type': 'text/plain', 'retry-after': '1' } });
683
- }
684
- }
685
-
686
- const obj = await env.BUCKET.get(path);
687
- if (!obj) return new Response('not found', { status: 404, headers: { 'content-type': 'text/plain' } });
688
- const expiresAt = obj.customMetadata && obj.customMetadata['expires-at'];
689
- if (expiresAt && Date.now() > Date.parse(expiresAt)) {
690
- await env.BUCKET.delete(path);
691
- // PHNX-3542: expiry is a lazy delete with no auth context. Refund the
692
- // stamped owner's quota (bytes + object count) best-effort — a refund
693
- // failure must never break serving the 410. A canonical page is 2
694
- // segments; covers are excluded (never charged) so refund is safe here
695
- // because only canonical pages ever carry an expires-at.
696
- const expiredOwner = obj.customMetadata && obj.customMetadata['owner'];
697
- if (expiredOwner && !path.endsWith('.png')) {
698
- try {
699
- await refundShareWrite(env, expiredOwner, {
700
- refund: typeof obj.size === 'number' ? obj.size : 0,
701
- freeCanonical: path.split('/').filter(Boolean).length === 2,
702
- });
703
- } catch (e) {
704
- // best-effort: serving the expiry 410 must never depend on the refund
705
- }
706
- }
707
- return new Response('gone — this link has expired', { status: 410, headers: { 'content-type': 'text/plain' } });
708
- }
709
- // Resolve the viewer ONCE — needed both to gate me/org reads AND to decide
710
- // whether THIS viewer OWNS this namespace (the interactive visibility
711
- // control below). A successful phoenix_ticket redemption (redirect: set the
712
- // identity cookie, strip the ticket) is honored on ANY page. But a ticket
713
- // FAILURE (expired/consumed/unreachable) must only gate a page that needs
714
- // identity — a public/unlisted page never invoked ticket redemption before
715
- // this refactor, so a stale ticket there must serve anonymously, not 401.
716
- const visibility = (obj.customMetadata && obj.customMetadata.visibility) || 'public';
717
- const viewer = await resolveViewer(request, env, url);
718
- if (viewer.redirect) return viewer.redirect;
719
- if (viewer.error && isIdentityGated(visibility)) return viewer.error;
720
- const identity = viewer.identity || null;
721
- const denied = await gateVisibility(url, env, obj, identity);
722
- if (denied) return denied;
723
- // Token-gated read auth (PHNX-3654): a 'private' page is served only to a
724
- // request carrying the matching viewer key (?k= or Bearer), or to its owner.
725
- // A miss returns 404 — never leaking that the page exists — exactly like a
726
- // wrong me/org viewer.
727
- const tokenDenied = await gateTokenRead(request, url, obj, identity);
728
- if (tokenDenied) return tokenDenied;
729
- // The owner of the namespace (their handle === the first path segment) gets
730
- // an interactive visibility control; everyone else keeps the static cue.
731
- const isOwner = !!identity && handleFromEmail(identity.email) === firstSeg;
732
- const ownerDomain = identity ? emailDomain(identity.email) : '';
733
- const headers = new Headers();
734
- obj.writeHttpMetadata(headers);
735
- headers.set('etag', obj.httpEtag);
736
- if (!headers.has('content-type')) headers.set('content-type', 'text/html; charset=utf-8');
737
- if (visibility === 'me' || visibility === 'org' || visibility === 'private') {
738
- // Token-gated + identity-gated reads must never be cached by a shared
739
- // proxy, and never indexed.
740
- headers.set('cache-control', 'private, no-store');
741
- headers.set('X-Robots-Tag', 'noindex');
742
- } else {
743
- headers.set('cache-control', 'public, max-age=60');
744
- if (visibility === 'unlisted') headers.set('X-Robots-Tag', 'noindex');
745
- }
746
- if (request.method === 'HEAD') return new Response(null, { status: 200, headers });
747
- const ctype = headers.get('content-type') || '';
748
- // HTML pages ALWAYS get the attribution bar injected at serve time (who
749
- // shared it, what made it, when, and — the point — a visual VISIBILITY cue),
750
- // all from stamped metadata. This is unconditional: ?raw does NOT strip the
751
- // bar from a shared page (the always-on cue from #3122/#3140 is not
752
- // defeatable with a query param). The body changes, so the R2 etag no
753
- // longer matches — drop it rather than serve a lying validator.
754
- if (ctype.indexOf('text/html') !== -1) {
755
- const rawHtml = await obj.text();
756
- // Per-slug view counter (RUSH view stats). Stored as a SEPARATE R2 object
757
- // (__views/<path>) so it never rewrites the page object — a rewrite would
758
- // reset obj.uploaded and corrupt "last updated". The __-prefix key is GET-
759
- // blocked for direct requests and lives outside every <user>/ list prefix,
760
- // so it never leaks into the gallery/listing/revisions. Count only a real
761
- // visitor's page view: not ?raw (an embed/OG fetch), not the owner's own
762
- // view, and never HEAD (which returned above). The write rides
763
- // ctx.waitUntil so it never blocks the response; the displayed count folds
764
- // in the current view so the visitor sees themselves included.
765
- const wantsRaw = url.searchParams.get('raw') != null;
766
- const counting = !wantsRaw && !isOwner;
767
- const storedViews = await readViews(env, path);
768
- const views = storedViews + (counting ? 1 : 0);
769
- if (counting && ctx && typeof ctx.waitUntil === 'function') {
770
- ctx.waitUntil(writeViews(env, path, views));
771
- }
772
- const withBar = injectAttributionBar(rawHtml, obj.customMetadata || {}, firstSeg, {
773
- isOwner,
774
- ownerDomain,
775
- views,
776
- uploaded: obj.uploaded,
777
- });
778
- headers.delete('etag');
779
- return new Response(withBar, { status: 200, headers });
780
- }
781
- // Non-HTML asset. ?raw returns the stored bytes untouched — the escape hatch
782
- // for <img src>, OG crawlers, iframes, and anyone embedding the asset (the
783
- // viewer's own media element points back at ?raw). Otherwise a BROWSER
784
- // navigating directly to a viewable asset (image/video/audio/pdf) gets a
785
- // lightweight viewer page carrying the same bar; a non-browser fetch (Accept
786
- // without text/html) falls through to raw bytes so embedding is never broken.
787
- const wantsRaw = url.searchParams.get('raw') != null;
788
- const kind = viewableAssetKind(ctype);
789
- // A token-gated (private) asset is served as raw bytes, NOT wrapped in the
790
- // viewer page (PHNX-3654): the viewer's inner <img src=…?raw> would drop the
791
- // ?k= key and 404 (there's no cookie to carry it, unlike me/org), breaking the
792
- // media. The gate already passed above, so the raw bytes are safe to serve.
793
- if (!wantsRaw && kind && acceptsHtml(request) && visibility !== 'private') {
794
- const viewerPage = renderAssetViewer(url.pathname, kind, obj.customMetadata || {}, firstSeg, {
795
- isOwner,
796
- ownerDomain,
797
- });
798
- const vheaders = new Headers(headers);
799
- vheaders.set('content-type', 'text/html; charset=utf-8');
800
- vheaders.delete('etag');
801
- return new Response(viewerPage, { status: 200, headers: vheaders });
802
- }
803
- return new Response(obj.body, { status: 200, headers });
804
- }
805
-
806
- if (request.method === 'DELETE') {
807
- const auth = await authorizeWrite(request, env);
808
- if (auth.error) return auth.error;
809
- if (auth.kind === 'phoenix') {
810
- const handle = phoenixHandle(auth);
811
- const uid = sanitizeNamespace(auth.owner);
812
- const delSegments = path.split('/').filter(Boolean);
813
- // Handle is the public namespace; userId prefix is still deletable so
814
- // P1 UUID-namespaced objects the same account published can be taken down.
815
- // A colliding local-part (same handle, different userId) must not be
816
- // able to DELETE the claimant's pages — consult the claim the same way PUT does.
817
- if (delSegments[0] && delSegments[0] === uid) {
818
- // own leftover UUID prefix
819
- } else if (delSegments[0] && delSegments[0] === handle) {
820
- const owned = await assertHandleOwner(env.BUCKET, handle, auth.owner, auth.email || '');
821
- if (owned.error) return owned.error;
822
- } else if (delSegments[0]) {
823
- // An explicitly-chosen handle (the CLI's --handle, PHNX-3547): the
824
- // caller's derived handle differs from the namespace, so the claim is
825
- // the authority — assertHandleOwner refuses strangers (409) and
826
- // recovers a same-email account move. No claim at all is a mismatch.
827
- const claim = await env.BUCKET.get('__handles/' + delSegments[0]);
828
- if (!claim) return json({ error: 'namespace mismatch', owner: handle || uid }, 403);
829
- const owned = await assertHandleOwner(env.BUCKET, delSegments[0], auth.owner, auth.email || '');
830
- if (owned.error) return owned.error;
831
- } else {
832
- return json({ error: 'namespace mismatch', owner: handle || uid }, 403);
833
- }
834
- }
835
- // PHNX-3542: capture the object's size BEFORE deleting so a managed
836
- // Phoenix owner's quota can be refunded. A canonical page (2 segments, not
837
- // a .png cover) refunds both bytes and the object count; a retained
838
- // revision (3+ segments) refunds bytes only; a server-generated .png cover
839
- // was never charged, so it is skipped entirely.
840
- let doomed = null;
841
- const isCover = path.endsWith('.png');
842
- if (auth.kind === 'phoenix' && !isCover && !firstSeg.startsWith('__')) {
843
- doomed = typeof env.BUCKET.head === 'function' ? await env.BUCKET.head(path) : await env.BUCKET.get(path);
844
- }
845
- await env.BUCKET.delete(path);
846
- if (doomed) {
847
- const delSegs = path.split('/').filter(Boolean);
848
- await refundShareWrite(env, auth.owner, {
849
- refund: typeof doomed.size === 'number' ? doomed.size : 0,
850
- freeCanonical: delSegs.length === 2,
851
- });
852
- }
853
- // PHNX-3835: best-effort purge of the deleted share's collaboration
854
- // threads. Object-first gating already makes comments inaccessible the
855
- // instant the R2 object is gone (every /__collab request 404s on the
856
- // missing object), so this is durable cleanup on the Prix side, never the
857
- // security boundary. Fire-and-forget so a purge failure never fails the
858
- // delete. Only a managed (Phoenix) canonical page (2 segments, not a
859
- // cover, not an internal __-key) can carry collaboration.
860
- const collabCfgDel = collabConfig(env);
861
- if (
862
- collabCfgDel &&
863
- auth.kind === 'phoenix' &&
864
- !isCover &&
865
- !firstSeg.startsWith('__') &&
866
- path.split('/').filter(Boolean).length === 2
867
- ) {
868
- const purgeOwner = (doomed && doomed.customMetadata && doomed.customMetadata.owner) || auth.owner;
869
- const purge = purgeCollab(env, collabCfgDel, purgeOwner, path);
870
- if (ctx && typeof ctx.waitUntil === 'function') ctx.waitUntil(purge);
871
- else await purge;
872
- }
873
- return json({ ok: true, deleted: path }, 200);
874
- }
875
-
876
- return new Response('method not allowed', { status: 405 });
877
- },
878
- };
879
-
880
- // A canonical share object's key is exactly 2 segments (<user>/<slug>, or the
881
- // sibling <user>/<slug>.png OG cover). A retained revision lives a 3rd segment
882
- // deeper (<user>/<slug>/rev-<ts>-<rand>) — history, not a page in its own
883
- // right, so every gallery/listing view hides it. A raw external PUT to an
884
- // already-3-segment-deep path is treated the same way for the same reason:
885
- // there is no ambiguity to resolve, and the CLI never produces such a key.
886
- function isRevisionKey(key) {
887
- return key.split('/').length > 2;
888
- }
889
-
890
- function publishedAtOf(object) {
891
- return (object.customMetadata && object.customMetadata['published-at']) || object.uploaded;
892
- }
893
-
894
- async function renderGallery(bucket, origin, user, method) {
895
- const objects = [];
896
- let cursor;
897
- do {
898
- const list = await bucket.list({ prefix: user + '/', limit: 1000, cursor, include: ['customMetadata'] });
899
- objects.push(...(list.objects || []));
900
- cursor = list.truncated ? list.cursor : undefined;
901
- } while (cursor);
902
-
903
- const activeObjects = objects.filter(o => {
904
- if (isRevisionKey(o.key)) return false;
905
- if (o.key.endsWith('.png')) return false;
906
- // Unlisted / me / org pages are reachable by direct URL only — never on the gallery.
907
- if (isHiddenFromGallery(o.customMetadata && o.customMetadata.visibility)) return false;
908
- const expiresAt = o.customMetadata && o.customMetadata['expires-at'];
909
- return !(expiresAt && Date.now() > Date.parse(expiresAt));
910
- });
911
- const items = activeObjects.map(o => {
912
- const slug = o.key.slice(o.key.indexOf('/') + 1);
913
- const url = origin + '/' + o.key;
914
- const label = (o.customMetadata && o.customMetadata['label']) || '';
915
- const agent = (o.customMetadata && o.customMetadata['agent']) || '';
916
- return { slug, url, updated: publishedAtOf(o), label, agent };
917
- });
918
-
919
- if (method === 'HEAD') {
920
- return new Response(null, { status: 200, headers: { 'content-type': 'text/html; charset=utf-8' } });
921
- }
922
-
923
- const html =
924
- '<!doctype html><html><head>' +
925
- '<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">' +
926
- '<title>' + escapeHtml(user) + ' — agents share</title>' +
927
- '<style>' +
928
- 'body{font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;max-width:720px;margin:48px auto;padding:0 24px;color:#111;background:#fafafa}' +
929
- 'h1{font-size:28px;margin-bottom:8px}a{color:#0a0a0a;text-decoration:none;border-bottom:1px solid #999}' +
930
- 'a:hover{border-color:#111}ul{list-style:none;padding:0}li{margin:16px 0}' +
931
- '.slug{font-weight:600}.title{font-weight:600}.meta{color:#666;font-size:14px}' +
932
- '</style></head><body>' +
933
- '<h1>@' + escapeHtml(user) + '</h1>' +
934
- '<p class="meta">' + items.length + ' shared ' + (items.length === 1 ? 'page' : 'pages') + '</p>' +
935
- '<ul>' +
936
- items.map(i =>
937
- '<li>' +
938
- (i.label ? '<span class="title">' + escapeHtml(i.label) + '</span><br>' : '') +
939
- '<a class="slug" href="' + escapeHtml(i.url) + '">' + escapeHtml(i.slug) + '</a>' +
940
- '<br><span class="meta">' + new Date(i.updated).toISOString().slice(0, 10) +
941
- (i.agent ? ' · ' + escapeHtml(i.agent) : '') + '</span></li>'
942
- ).join('') +
943
- '</ul></body></html>';
944
- return new Response(html, { status: 200, headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'public, max-age=60' } });
945
- }
946
-
947
- async function renderListing(bucket, origin, user, method, includeHidden) {
948
- const objects = [];
949
- let cursor;
950
- do {
951
- const list = await bucket.list({ prefix: user + '/', limit: 1000, cursor, include: ['httpMetadata', 'customMetadata'] });
952
- objects.push(...(list.objects || []));
953
- cursor = list.truncated ? list.cursor : undefined;
954
- } while (cursor);
955
-
956
- // Revisions never appear as their own listing row, but they count toward
957
- // their canonical object's revisionCount.
958
- const revisionCounts = {};
959
- for (const o of objects) {
960
- if (!isRevisionKey(o.key)) continue;
961
- const canonicalKey = o.key.split('/').slice(0, 2).join('/');
962
- revisionCounts[canonicalKey] = (revisionCounts[canonicalKey] || 0) + 1;
963
- }
964
-
965
- const now = Date.now();
966
- const items = objects
967
- .filter(o => {
968
- // Revisions and sibling .png OG covers are always hidden. Hidden-visibility
969
- // pages are included ONLY when an authenticated owner requested scope=mine.
970
- if (isRevisionKey(o.key)) return false;
971
- if (o.key.endsWith('.png')) return false;
972
- if (isHiddenFromGallery(o.customMetadata && o.customMetadata.visibility) && !includeHidden) return false;
973
- const expiresAt = o.customMetadata && o.customMetadata['expires-at'];
974
- return !(expiresAt && now > Date.parse(expiresAt));
975
- })
976
- .map(o => ({
977
- slug: o.key.slice(o.key.indexOf('/') + 1),
978
- url: origin + '/' + o.key,
979
- size: typeof o.size === 'number' ? o.size : 0,
980
- contentType: (o.httpMetadata && o.httpMetadata.contentType) || null,
981
- publishedAt: new Date(publishedAtOf(o)).toISOString(),
982
- expiresAt: (o.customMetadata && o.customMetadata['expires-at']) || null,
983
- label: (o.customMetadata && o.customMetadata['label']) || null,
984
- visibility: (o.customMetadata && o.customMetadata.visibility) || 'public',
985
- agent: (o.customMetadata && o.customMetadata['agent']) || null,
986
- session: (o.customMetadata && o.customMetadata['session']) || null,
987
- host: (o.customMetadata && o.customMetadata['host']) || null,
988
- repo: (o.customMetadata && o.customMetadata['repo']) || null,
989
- revisionCount: revisionCounts[o.key] || 0,
990
- meta: extraMetaOf(o.customMetadata),
991
- }));
992
- // Newest first, so the human table and any script reads the freshest share top.
993
- items.sort((a, b) => (a.publishedAt < b.publishedAt ? 1 : a.publishedAt > b.publishedAt ? -1 : 0));
994
-
995
- const cacheHeaders = includeHidden
996
- ? { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'private, no-store', 'X-Robots-Tag': 'noindex' }
997
- : { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'public, max-age=30' };
998
- if (method === 'HEAD') {
999
- return new Response(null, { status: 200, headers: cacheHeaders });
1000
- }
1001
- return new Response(JSON.stringify({ user, count: items.length, objects: items }), {
1002
- status: 200,
1003
- headers: cacheHeaders,
1004
- });
1005
- }
1006
-
1007
- // The machine-readable listing is public by default. A request with
1008
- // '?format=json&scope=mine' asks to include the owner's hidden pages
1009
- // (unlisted / me / org). We honor it ONLY after a valid owner bearer whose
1010
- // namespace matches the requested handle, so one user cannot list another's
1011
- // hidden shares. Anonymous or mismatched requests fail loud. A BYO WRITE_TOKEN
1012
- // bearer is the worker's owner, so namespace enforcement is skipped for BYO.
1013
- async function resolveListingScope(request, env, user) {
1014
- const scope = new URL(request.url).searchParams.get('scope');
1015
- if (scope !== 'mine') return { includeHidden: false };
1016
- const auth = await authorizeWrite(request, env);
1017
- if (auth.error) return { error: auth.error };
1018
- if (auth.kind === 'byo') {
1019
- return { includeHidden: true };
1020
- }
1021
- const owner = phoenixHandle(auth);
1022
- if (owner !== user) {
1023
- return { error: json({ error: 'namespace mismatch' }, 403) };
1024
- }
1025
- return { includeHidden: true };
1026
- }
1027
-
1028
- async function renderRevisions(bucket, origin, key, method, identityGated) {
1029
- const objects = [];
1030
- let cursor;
1031
- do {
1032
- const list = await bucket.list({ prefix: key + '/rev-', limit: 1000, cursor, include: ['httpMetadata', 'customMetadata'] });
1033
- objects.push(...(list.objects || []));
1034
- cursor = list.truncated ? list.cursor : undefined;
1035
- } while (cursor);
1036
-
1037
- const items = objects.map(o => ({
1038
- key: o.key,
1039
- url: origin + '/' + o.key,
1040
- size: typeof o.size === 'number' ? o.size : 0,
1041
- contentType: (o.httpMetadata && o.httpMetadata.contentType) || null,
1042
- uploadedAt: new Date(o.uploaded).toISOString(),
1043
- expiresAt: (o.customMetadata && o.customMetadata['expires-at']) || null,
1044
- label: (o.customMetadata && o.customMetadata['label']) || null,
1045
- agent: (o.customMetadata && o.customMetadata['agent']) || null,
1046
- session: (o.customMetadata && o.customMetadata['session']) || null,
1047
- host: (o.customMetadata && o.customMetadata['host']) || null,
1048
- repo: (o.customMetadata && o.customMetadata['repo']) || null,
1049
- meta: extraMetaOf(o.customMetadata),
1050
- }));
1051
- // Newest first — the most recently replaced version leads.
1052
- items.sort((a, b) => (a.uploadedAt < b.uploadedAt ? 1 : a.uploadedAt > b.uploadedAt ? -1 : 0));
1053
-
1054
- const cacheHeaders = identityGated
1055
- ? { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'private, no-store', 'X-Robots-Tag': 'noindex' }
1056
- : { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'public, max-age=30' };
1057
- if (method === 'HEAD') {
1058
- return new Response(null, { status: 200, headers: cacheHeaders });
1059
- }
1060
- return new Response(JSON.stringify({ key, count: items.length, revisions: items }), {
1061
- status: 200,
1062
- headers: cacheHeaders,
1063
- });
1064
- }
1065
-
1066
- // The customMetadata keys the CLI sets automatically (provenance + label) —
1067
- // never a real \`--meta key=value\` entry (the CLI rejects a colliding key
1068
- // before it ever reaches this Worker; see RESERVED_META_KEYS in publish.ts).
1069
- // One list, reused both to strip a same-named --meta collision on write and
1070
- // to split arbitrary --meta entries back out on read.
1071
- var RESERVED_METADATA_KEYS = ['expires-at', 'published-at', 'visibility', 'viewer-token-hash', 'owner', 'org_domain', 'agent', 'session', 'host', 'repo', 'date', 'avatar', 'label', 'label-source', 'og-title', 'og-description', 'og-generated', 'og-source-etag'];
1072
- var PUBLIC_INBOX_DOMAINS = ['gmail.com', 'googlemail.com', 'outlook.com', 'hotmail.com', 'live.com', 'icloud.com', 'me.com'];
1073
- var SHARE_COOKIE = '__Host-phoenix_share';
1074
- var SHARE_COOKIE_MAX_AGE = 604800;
1075
-
1076
- // PHNX-3542 — per-user storage limits for the MANAGED share endpoint. The plan
1077
- // map is the seam for future paid tiers: only 'free' is defined today, and a
1078
- // ledger with no plan (or an unknown one) resolves to it via planLimits(). Paid
1079
- // tiers and the write path that sets a user's plan arrive with billing (follow-up
1080
- // PHNX-3569); this is a legitimately-deferred seam, not a stub — 'free' IS
1081
- // enforced. Covers/views are server-generated overhead and excluded from the
1082
- // quota, which counts canonical pages + their retained revisions only.
1083
- var MiB = 1024 * 1024;
1084
- var SHARE_PLANS = {
1085
- free: { maxBytes: 200 * MiB, maxObjects: 150, maxFileBytes: 20 * MiB, ratePerHour: 60 },
1086
- };
1087
- var DEFAULT_SHARE_PLAN = 'free';
1088
- var RATE_WINDOW_MS = 3600 * 1000; // fixed 1h publish-rate window
1089
- function planLimits(plan) { return SHARE_PLANS[plan] || SHARE_PLANS[DEFAULT_SHARE_PLAN]; }
1090
- function freshUsage() { return { bytes: 0, count: 0, plan: DEFAULT_SHARE_PLAN, rlStart: 0, rlUsed: 0 }; }
1091
- function usageNumber(v, fallback) { return typeof v === 'number' && Number.isFinite(v) && v >= 0 ? v : fallback; }
1092
-
1093
- // Everything in customMetadata that ISN'T one of the reserved provenance/label
1094
- // keys above — i.e. the caller's own \`--meta key=value\` entries. Surfaced on
1095
- // every read route (listing, revisions) so a value stored with \`--meta
1096
- // kind=plan --meta ticket=RUSH-2683\` is actually visible again, not just
1097
- // write-only (RUSH-2683 review fix).
1098
- function extraMetaOf(customMetadata) {
1099
- var out = {};
1100
- if (!customMetadata) return out;
1101
- for (var k in customMetadata) {
1102
- if (RESERVED_METADATA_KEYS.indexOf(k) === -1) out[k] = customMetadata[k];
1103
- }
1104
- return out;
1105
- }
1106
-
1107
- function escapeHtml(s) {
1108
- return String(s)
1109
- .replace(/&/g, '&amp;')
1110
- .replace(/</g, '&lt;')
1111
- .replace(/>/g, '&gt;')
1112
- .replace(/"/g, '&quot;');
1113
- }
1114
-
1115
- // The attribution bar: a slim strip prepended to every served HTML page that
1116
- // makes the object's stamped metadata visible — who shared it, what made it,
1117
- // when, and (the whole point) a color-coded VISIBILITY cue Google-Drive style.
1118
- // All values come from customMetadata; nothing new is stamped at publish time,
1119
- // so this is a pure Worker change deployable with 'agents artifacts share update'.
1120
- var VIS_ICON = {
1121
- me: '<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="11" width="16" height="9" rx="2"/><path d="M8 11V8a4 4 0 0 1 8 0v3"/></svg>',
1122
- org: '<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 21h18M6 21V4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v17M14 9h4a1 1 0 0 1 1 1v11"/></svg>',
1123
- unlisted: '<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 13a5 5 0 0 0 7 0l3-3a5 5 0 0 0-7-7l-1 1"/><path d="M14 11a5 5 0 0 0-7 0l-3 3a5 5 0 0 0 7 7l1-1"/></svg>',
1124
- public: '<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3a15 15 0 0 1 0 18M12 3a15 15 0 0 0 0 18"/></svg>',
1125
- };
1126
-
1127
- // Eye glyph for the view-count stat; caret for the owner's interactive chip.
1128
- var STAT_EYE_ICON = '<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2"><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/></svg>';
1129
- var CARET_ICON = '<svg class="ash-caret" viewBox="0 0 24 24" width="11" height="11" fill="none" stroke="currentColor" stroke-width="2.4"><path d="m6 9 6 6 6-6"/></svg>';
1130
-
1131
- // The owner-only browser script that turns the visibility chip into a live
1132
- // control. Self-contained IIFE, namespaced under .agents-share-bar; injected only
1133
- // when the viewer owns the namespace. It reads the current level and each level's
1134
- // icon/color/label straight from the rendered menu DOM (no embedded config), then
1135
- // PATCHes the SAME in-place edit route the CLI uses — method PATCH, JSON body
1136
- // { visibility } — with credentials:'include' so the viewer's __share cookie /
1137
- // Phoenix identity rides. Optimistic: flip the chip + spinner, then a green check
1138
- // on 2xx or revert + the server's error text on failure (an org-on-public-inbox
1139
- // 400 surfaces verbatim). The share page sets no CSP, so inline JS is fine.
1140
- var ASH_OWNER_JS = \`(function(){
1141
- var root = document.querySelector('.agents-share-bar');
1142
- if(!root) return;
1143
- var chip = root.querySelector('[data-ash-chip]');
1144
- var menu = root.querySelector('[data-ash-menu]');
1145
- var toast = root.querySelector('[data-ash-toast]');
1146
- if(!chip || !menu || !toast) return;
1147
- var labelEl = chip.querySelector('[data-ash-label]');
1148
- var chipIc = chip.querySelector('[data-ash-chip-ic]');
1149
- var busy = false;
1150
- function opts(){ return menu.querySelectorAll('[data-ash-opt]'); }
1151
- function currentKey(){ var s = menu.querySelector('.ash-opt.ash-sel'); return s ? s.getAttribute('data-ash-opt') : ''; }
1152
- function labelFor(key){ var a = opts(); for(var i=0;i<a.length;i++){ if(a[i].getAttribute('data-ash-opt')===key){ var b = a[i].querySelector('b'); return b ? b.textContent : key; } } return key; }
1153
- function open(){ menu.classList.add('ash-open'); menu.setAttribute('aria-hidden','false'); }
1154
- function close(){ menu.classList.remove('ash-open'); menu.setAttribute('aria-hidden','true'); }
1155
- chip.addEventListener('click', function(e){ e.stopPropagation(); if(menu.classList.contains('ash-open')) close(); else open(); });
1156
- menu.addEventListener('click', function(e){ e.stopPropagation(); });
1157
- document.addEventListener('click', close);
1158
- document.addEventListener('keydown', function(e){ if(e.key==='Escape') close(); });
1159
- function draw(key){
1160
- var sel = null, a = opts();
1161
- for(var i=0;i<a.length;i++){ var on = a[i].getAttribute('data-ash-opt')===key; a[i].classList.toggle('ash-sel', on); if(on) sel = a[i]; }
1162
- if(!sel) return;
1163
- var ic = sel.querySelector('.ash-opt-ic');
1164
- var color = ic ? ic.style.color : '';
1165
- if(color){ chip.style.color = color; chip.style.borderColor = color + '66'; }
1166
- if(chipIc && ic) chipIc.innerHTML = ic.innerHTML;
1167
- var b = sel.querySelector('b');
1168
- if(labelEl && b) labelEl.textContent = b.textContent;
1169
- }
1170
- function say(inner, cls){ toast.innerHTML = inner; toast.className = 'ash-toast ash-show' + (cls ? ' ' + cls : ''); }
1171
- function fade(ms){ setTimeout(function(){ toast.className = 'ash-toast'; }, ms); }
1172
- function pick(key){
1173
- close();
1174
- var prev = currentKey();
1175
- if(busy || !key || key===prev) return;
1176
- busy = true;
1177
- draw(key);
1178
- var name = labelFor(key);
1179
- say('<span class="ash-spin"></span> Saving…');
1180
- fetch(location.pathname, { method:'PATCH', credentials:'include', headers:{'content-type':'application/json'}, body: JSON.stringify({ visibility: key }) })
1181
- .then(function(res){ return res.json().then(function(j){ return { ok: res.ok, status: res.status, body: j }; }, function(){ return { ok: res.ok, status: res.status, body: {} }; }); })
1182
- .then(function(r){
1183
- busy = false;
1184
- if(r.ok){ say('<span class="ash-ok">\\u2713</span> Now ' + name, 'ash-good'); fade(1600); }
1185
- else {
1186
- draw(prev);
1187
- var msg = (r.body && r.body.error) ? String(r.body.error) : ('Could not update (HTTP ' + r.status + ')');
1188
- say('<span class="ash-err">\\u2715</span> ' + msg.replace(/[<>&]/g, ' '), 'ash-bad');
1189
- fade(4000);
1190
- }
1191
- }, function(){ busy = false; draw(prev); say('<span class="ash-err">\\u2715</span> Network error \\u2014 not saved', 'ash-bad'); fade(4000); });
1192
- }
1193
- var a = opts();
1194
- for(var i=0;i<a.length;i++){ (function(el){ el.addEventListener('click', function(e){ e.stopPropagation(); pick(el.getAttribute('data-ash-opt')); }); })(a[i]); }
1195
- })();\`;
1196
-
1197
- function visibilityChip(visibility, orgDomain) {
1198
- if (visibility === 'me') return { icon: VIS_ICON.me, label: 'Only you', color: '#f59e0b' };
1199
- if (visibility === 'org') return { icon: VIS_ICON.org, label: 'Anyone at ' + escapeHtml(orgDomain || 'your organization'), color: '#5b9dff' };
1200
- if (visibility === 'private') return { icon: VIS_ICON.me, label: 'Protected (link + key)', color: '#f59e0b' };
1201
- if (visibility === 'unlisted') return { icon: VIS_ICON.unlisted, label: 'Unlisted', color: '#9aa0a6' };
1202
- return { icon: VIS_ICON.public, label: 'Public', color: '#30a46c' };
1203
- }
1204
-
1205
- // A deterministic hue (0-359) from the handle, so a given sharer always gets the
1206
- // same initials-circle colour without any stored palette.
1207
- function avatarHue(s) {
1208
- var h = 0;
1209
- for (var i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) % 360;
1210
- return h;
1211
- }
1212
-
1213
- // The avatar slot is ALWAYS rendered: a coloured initials circle derived from the
1214
- // handle. When the object carries an avatar URL (a Gravatar the CLI stamps at
1215
- // publish, or any future hosted photo), a real img is layered on top; onerror
1216
- // removes it so the initials underneath show through when the photo 404s. There
1217
- // is no CSP on the share page, so the inline onerror fallback is honoured.
1218
- function renderAvatar(handle, avatarUrl) {
1219
- var h = (handle || '').trim();
1220
- var initial = escapeHtml((h.charAt(0) || '?').toUpperCase());
1221
- var bg = 'hsl(' + avatarHue(h) + ',48%,42%)';
1222
- var inner = '<span class="ash-av-i">' + initial + '</span>';
1223
- if (avatarUrl) {
1224
- inner += '<img class="ash-av-img" src="' + escapeHtml(avatarUrl) + '" alt="" loading="lazy" ' +
1225
- 'referrerpolicy="no-referrer" onerror="this.remove()">';
1226
- }
1227
- return '<span class="ash-avatar" style="background:' + bg + ' !important" aria-hidden="true">' + inner + '</span>';
1228
- }
1229
-
1230
- // A compact relative time for the "last updated" stat: "just now", "2h ago",
1231
- // "3d ago", or an ISO date once past ~30 days. Accepts a Date or a parseable
1232
- // string (R2's obj.uploaded is a Date).
1233
- function relativeTime(when) {
1234
- var then = when instanceof Date ? when.getTime() : Date.parse(when);
1235
- if (!then || isNaN(then)) return '';
1236
- var diff = Date.now() - then;
1237
- if (diff < 0) diff = 0;
1238
- var minute = 60000, hour = 3600000, day = 86400000;
1239
- if (diff < minute) return 'just now';
1240
- if (diff < hour) return Math.floor(diff / minute) + 'm ago';
1241
- if (diff < day) return Math.floor(diff / hour) + 'h ago';
1242
- if (diff < 30 * day) return Math.floor(diff / day) + 'd ago';
1243
- return new Date(then).toISOString().slice(0, 10);
1244
- }
1245
-
1246
- // The four visibility levels the owner can pick, in menu order. Descriptions
1247
- // mirror the approved mockup. org always appears — a public-inbox owner still
1248
- // sees it and lets the server's 400 drive the failure message (never a hidden
1249
- // client-side rule). orgDomain is the owner's own email domain when known.
1250
- function visibilityLevels(orgDomain) {
1251
- return [
1252
- { key: 'public', label: 'Public', color: '#30a46c', icon: VIS_ICON.public, desc: 'Anyone with the link · preview card · listed in your gallery' },
1253
- { key: 'unlisted', label: 'Unlisted', color: '#9aa0a6', icon: VIS_ICON.unlisted, desc: 'Anyone with the link · no card · hidden from your gallery' },
1254
- { key: 'me', label: 'Only you', color: '#f59e0b', icon: VIS_ICON.me, desc: 'Sign-in required · only your account can open it' },
1255
- { key: 'org', label: 'Anyone at ' + escapeHtml(orgDomain || 'your org'), color: '#5b9dff', icon: VIS_ICON.org, desc: 'Anyone signed in with a ' + escapeHtml(orgDomain || 'workspace-domain') + ' account' },
1256
- ];
1257
- }
1258
-
1259
- function renderVisibilityMenu(current, orgDomain) {
1260
- var rows = visibilityLevels(orgDomain).map(function (l) {
1261
- return '<button type="button" class="ash-opt' + (l.key === current ? ' ash-sel' : '') + '" data-ash-opt="' + l.key + '" role="menuitemradio" aria-checked="' + (l.key === current ? 'true' : 'false') + '">' +
1262
- '<span class="ash-opt-ic" style="color:' + l.color + '">' + l.icon + '</span>' +
1263
- '<span class="ash-opt-t"><b>' + l.label + '</b><span>' + l.desc + '</span></span>' +
1264
- '<span class="ash-rd"></span></button>';
1265
- }).join('');
1266
- return '<div class="ash-menu" data-ash-menu role="menu" aria-hidden="true"><h4>Who can see this</h4>' + rows + '</div>';
1267
- }
1268
-
1269
- function renderOwnerScript() {
1270
- return '<script>' + ASH_OWNER_JS + '</script>';
1271
- }
1272
-
1273
- // opts: { isOwner, ownerDomain, views, uploaded } — all optional. isOwner turns
1274
- // the visibility chip into a live control (inline dropdown, Variation A) and
1275
- // injects the owner-only CSS/JS; views/uploaded render the right-side stats
1276
- // cluster. With no opts the bar is exactly today's static strip.
1277
- function renderAttributionBar(meta, handle, opts) {
1278
- var o = opts || {};
1279
- var cm = meta || {};
1280
- var visibility = cm.visibility || 'public';
1281
- var chip = visibilityChip(visibility, cm.org_domain);
1282
- var avatar = renderAvatar(handle, cm.avatar);
1283
- var left = '';
1284
- // The handle is already the public URL namespace, so surfacing it leaks nothing new.
1285
- if (handle) left += 'Shared by <strong>' + escapeHtml(handle) + '</strong>';
1286
- if (cm.agent) left += (left ? '<span class="ash-dot">·</span>' : '') + 'Made with ' + escapeHtml(cm.agent);
1287
-
1288
- // Stats cluster (👁 <n> views · updated <rel>) — right side, left of the chip.
1289
- var stats = '';
1290
- var statBits = '';
1291
- if (o.views != null) {
1292
- statBits += STAT_EYE_ICON + '<b>' + escapeHtml(String(o.views)) + '</b> ' + (Number(o.views) === 1 ? 'view' : 'views');
1293
- }
1294
- if (o.uploaded) {
1295
- var rel = relativeTime(o.uploaded);
1296
- if (rel) statBits += (statBits ? '<span class="ash-statsep">·</span>' : '') + 'updated <b>' + escapeHtml(rel) + '</b>';
1297
- }
1298
- if (statBits) stats = '<span class="ash-stats" title="page stats">' + statBits + '</span>';
1299
-
1300
- // The visibility chip: an interactive control for the owner, a static cue for
1301
- // everyone else (unchanged from today).
1302
- var chipHtml;
1303
- var menu = '';
1304
- var script = '';
1305
- if (o.isOwner) {
1306
- chipHtml = '<span class="ash-chip ash-chip-own" data-ash-chip tabindex="0" role="button" aria-haspopup="menu" ' +
1307
- 'style="color:' + chip.color + ';border-color:' + chip.color + '66">' +
1308
- '<span data-ash-chip-ic>' + chip.icon + '</span><span data-ash-label>' + chip.label + '</span>' + CARET_ICON + '</span>';
1309
- menu = renderVisibilityMenu(visibility, o.ownerDomain);
1310
- script = renderOwnerScript();
1311
- } else {
1312
- chipHtml = '<span class="ash-chip" style="color:' + chip.color + ';border-color:' + chip.color + '66">' + chip.icon + '<span>' + chip.label + '</span></span>';
1313
- }
1314
-
1315
- var right = stats + chipHtml;
1316
- if (cm.date) right += '<span class="ash-date">' + escapeHtml(cm.date) + '</span>';
1317
- // The load-bearing background + base colour ride an INLINE style so the host
1318
- // page's own CSS can never wash the bar out (inline beats a page stylesheet);
1319
- // the rest is a namespaced style block with !important on every colour so a
1320
- // broad page rule (span selector to black, etc.) cannot make the text vanish.
1321
- // FIXED, full-viewport-width chrome (not sticky-inside-body — that inherited the
1322
- // page's max-width/margins and rendered as a floating box). html padding-top
1323
- // pushes the whole page down by the bar's height; flex-wrap:nowrap + ellipsis on
1324
- // the left keeps the bar exactly one line tall so the push height stays correct.
1325
- return '<div class="agents-share-bar" role="contentinfo" aria-label="Sharing details" ' +
1326
- 'style="background:#0b0b0c !important;color:#e8e8e8 !important">' +
1327
- '<style>' +
1328
- 'html{padding-top:38px !important}' +
1329
- '.agents-share-bar{all:initial;position:fixed !important;top:0 !important;left:0 !important;right:0 !important;width:100% !important;z-index:2147483647 !important;box-sizing:border-box;display:flex !important;align-items:center;gap:12px;flex-wrap:nowrap;' +
1330
- 'padding:8px 16px !important;background:#0b0b0c !important;color:#e8e8e8 !important;border-bottom:1px solid #23232a !important;box-shadow:0 1px 3px rgba(0,0,0,.35) !important;' +
1331
- 'font:13px/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,system-ui,sans-serif !important}' +
1332
- '.agents-share-bar *{box-sizing:border-box;font-family:inherit}' +
1333
- '.agents-share-bar .ash-avatar{flex:none;position:relative;width:26px;height:26px;border-radius:50% !important;overflow:hidden;display:inline-flex;align-items:center;justify-content:center;color:#fff !important;font-weight:600;font-size:12px;line-height:1;box-shadow:inset 0 0 0 1px rgba(255,255,255,.14)}' +
1334
- '.agents-share-bar .ash-av-i{color:#fff !important}' +
1335
- '.agents-share-bar .ash-av-img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;border-radius:50% !important}' +
1336
- '.agents-share-bar .ash-left{flex:1 1 auto;min-width:0;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;color:#cfd3d9 !important}' +
1337
- '.agents-share-bar strong{color:#fff !important;font-weight:600}' +
1338
- '.agents-share-bar .ash-dot{opacity:.4;margin:0 4px}' +
1339
- '.agents-share-bar .ash-right{flex:none;display:flex;align-items:center;gap:12px}' +
1340
- '.agents-share-bar .ash-date{color:#9096a0 !important}' +
1341
- '.agents-share-bar .ash-chip{display:inline-flex;align-items:center;gap:6px;padding:3px 10px;border-radius:999px;border:1px solid;font-weight:600;font-size:12px;white-space:nowrap}' +
1342
- '.agents-share-bar .ash-chip svg{flex:none;vertical-align:middle}' +
1343
- '.agents-share-bar .ash-stats{display:inline-flex;align-items:center;gap:5px;color:#9aa0a6 !important;font-size:12px;white-space:nowrap}' +
1344
- '.agents-share-bar .ash-stats b{color:#c9ced6 !important;font-weight:600}' +
1345
- '.agents-share-bar .ash-stats svg{flex:none;opacity:.85}' +
1346
- '.agents-share-bar .ash-statsep{opacity:.35;margin:0 3px}' +
1347
- (o.isOwner ? OWNER_BAR_CSS : '') +
1348
- '</style>' +
1349
- avatar +
1350
- '<span class="ash-left">' + left + '</span>' +
1351
- '<span class="ash-right">' + right + '</span>' +
1352
- menu +
1353
- (o.isOwner ? '<div class="ash-toast" data-ash-toast></div>' : '') +
1354
- '</div>' +
1355
- script;
1356
- }
1357
-
1358
- // Owner-only chip/menu/toast styling, added to the bar's <style> only when the
1359
- // viewer owns the namespace. Namespaced under .agents-share-bar with !important
1360
- // so the host page's CSS can't wash it out; z-index rides the bar's own stacking.
1361
- var OWNER_BAR_CSS =
1362
- '.agents-share-bar .ash-chip-own{cursor:pointer;transition:background .12s}' +
1363
- '.agents-share-bar .ash-chip-own:hover{background:rgba(255,255,255,.06)}' +
1364
- '.agents-share-bar .ash-caret{opacity:.6;margin-left:1px;flex:none}' +
1365
- '.agents-share-bar .ash-menu{position:absolute !important;top:calc(100% + 6px);right:12px;min-width:286px;max-width:calc(100vw - 24px);background:#151517 !important;border:1px solid #2a2a30 !important;border-radius:12px;box-shadow:0 12px 34px rgba(0,0,0,.5);padding:6px;z-index:2147483647;display:none;text-align:left}' +
1366
- '.agents-share-bar .ash-menu.ash-open{display:block}' +
1367
- '.agents-share-bar .ash-menu h4{margin:6px 8px 8px;font-size:11px;text-transform:uppercase;letter-spacing:.07em;color:#7c8290 !important;font-weight:700}' +
1368
- '.agents-share-bar .ash-opt{display:flex;width:100%;text-align:left;align-items:flex-start;gap:10px;padding:8px;border:0;background:none;border-radius:8px;cursor:pointer;color:#e8e8e8 !important;font:inherit;font-size:13px}' +
1369
- '.agents-share-bar .ash-opt:hover{background:rgba(255,255,255,.05)}' +
1370
- '.agents-share-bar .ash-opt-ic{flex:none;width:26px;height:26px;border-radius:7px;display:inline-flex;align-items:center;justify-content:center;background:rgba(255,255,255,.06)}' +
1371
- '.agents-share-bar .ash-opt-t{flex:1;min-width:0}' +
1372
- '.agents-share-bar .ash-opt-t b{display:block;font-size:13px;font-weight:600;color:#f0f1f3 !important}' +
1373
- '.agents-share-bar .ash-opt-t span{display:block;font-size:11.5px;color:#9096a0 !important;line-height:1.35;margin-top:1px;white-space:normal}' +
1374
- '.agents-share-bar .ash-rd{flex:none;width:16px;height:16px;border-radius:50%;border:2px solid #4a4f59;margin-top:3px}' +
1375
- '.agents-share-bar .ash-opt.ash-sel .ash-rd{border-color:#5b9dff;background:radial-gradient(circle at center,#5b9dff 0 5px,transparent 6px)}' +
1376
- '.agents-share-bar .ash-toast{position:absolute !important;top:calc(100% + 6px);right:12px;max-width:calc(100vw - 24px);background:#151517 !important;border:1px solid #2a2a30 !important;color:#e8e8e8 !important;font-size:12px;padding:7px 12px;border-radius:9px;display:none;align-items:center;gap:7px;z-index:2147483647}' +
1377
- '.agents-share-bar .ash-toast.ash-show{display:inline-flex}' +
1378
- '.agents-share-bar .ash-spin{width:12px;height:12px;border:2px solid rgba(255,255,255,.25);border-top-color:#fff;border-radius:50%;animation:ash-sp .6s linear infinite;display:inline-block;flex:none}' +
1379
- '@keyframes ash-sp{to{transform:rotate(360deg)}}' +
1380
- '.agents-share-bar .ash-ok{color:#30a46c;font-weight:700}' +
1381
- '.agents-share-bar .ash-err{color:#ff6b6b;font-weight:700}';
1382
-
1383
- function injectAttributionBar(html, meta, handle, opts) {
1384
- var bar = renderAttributionBar(meta, handle, opts);
1385
- var m = /<body[^>]*>/i.exec(html);
1386
- if (m) {
1387
- var at = m.index + m[0].length;
1388
- return html.slice(0, at) + bar + html.slice(at);
1389
- }
1390
- return bar + html;
1391
- }
1392
-
1393
- function acceptsHtml(request) {
1394
- return (request.headers.get('accept') || '').indexOf('text/html') !== -1;
1395
- }
1396
-
1397
- // Assets we can embed in a viewer page. Everything else (JSON, .txt, arbitrary
1398
- // downloads) is served raw — there is nothing useful to wrap it in.
1399
- function viewableAssetKind(contentType) {
1400
- var ct = (contentType || '').toLowerCase();
1401
- if (ct.indexOf('image/') === 0) return 'image';
1402
- if (ct.indexOf('video/') === 0) return 'video';
1403
- if (ct.indexOf('audio/') === 0) return 'audio';
1404
- if (ct.indexOf('application/pdf') === 0) return 'pdf';
1405
- return '';
1406
- }
1407
-
1408
- // A minimal dark viewer page: the attribution bar on top, the asset centered
1409
- // below. The media element points back at THIS url with ?raw so it loads the
1410
- // stored bytes (not the viewer recursively) — and that ?raw fetch re-runs the
1411
- // same me/org gate, so a private asset stays private inside its own viewer.
1412
- function renderAssetViewer(pathname, kind, meta, handle, opts) {
1413
- var rawUrl = escapeHtml(pathname + '?raw=1');
1414
- var name = '';
1415
- try { name = decodeURIComponent(pathname.split('/').pop() || 'file'); } catch (e) { name = pathname.split('/').pop() || 'file'; }
1416
- var bar = renderAttributionBar(meta, handle, opts);
1417
- var media = '';
1418
- if (kind === 'image') media = '<img src="' + rawUrl + '" alt="' + escapeHtml(name) + '">';
1419
- else if (kind === 'video') media = '<video src="' + rawUrl + '" controls playsinline></video>';
1420
- else if (kind === 'audio') media = '<audio src="' + rawUrl + '" controls></audio>';
1421
- else if (kind === 'pdf') media = '<iframe src="' + rawUrl + '" title="' + escapeHtml(name) + '"></iframe>';
1422
- return '<!doctype html><html><head><meta charset="utf-8">' +
1423
- '<meta name="viewport" content="width=device-width,initial-scale=1">' +
1424
- '<title>' + escapeHtml(name) + '</title>' +
1425
- '<style>html,body{margin:0;background:#0f0f11}' +
1426
- 'body{min-height:100vh;display:flex;align-items:center;justify-content:center;padding:16px;box-sizing:border-box}' +
1427
- '.ashv{max-width:100%;max-height:calc(100vh - 90px);display:flex;align-items:center;justify-content:center}' +
1428
- '.ashv img,.ashv video{max-width:100%;max-height:calc(100vh - 90px);display:block;border-radius:8px;box-shadow:0 10px 40px rgba(0,0,0,.5)}' +
1429
- '.ashv audio{width:min(90vw,560px)}' +
1430
- '.ashv iframe{width:min(94vw,1000px);height:calc(100vh - 90px);border:0;border-radius:8px;background:#fff}' +
1431
- '</style></head><body>' + bar + '<div class="ashv">' + media + '</div></body></html>';
1432
- }
1433
-
1434
- function json(body, status) {
1435
- return new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
1436
- }
1437
-
1438
- // Length-independent constant-time-ish compare (Workers has no timingSafeEqual).
1439
- function safeEqual(a, b) {
1440
- if (typeof a !== 'string' || typeof b !== 'string') return false;
1441
- const la = a.length, lb = b.length;
1442
- let out = la ^ lb;
1443
- for (let i = 0; i < la; i++) out |= a.charCodeAt(i) ^ b.charCodeAt(i % lb || 0);
1444
- return out === 0 && la === lb;
1445
- }
1446
-
1447
- // URL-safety, not collision-resistance. Two values that differ only in
1448
- // case/punctuation share a prefix.
1449
- function sanitizeNamespace(s) {
1450
- return String(s || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
1451
- }
1452
-
1453
- // Public handle: email local-part, plus-tag stripped. Must match CLI handleFromEmail.
1454
- function handleFromEmail(email) {
1455
- if (!email) return '';
1456
- const local = String(email).split('@')[0] || '';
1457
- const beforePlus = local.split('+')[0] || local;
1458
- return sanitizeNamespace(beforePlus);
1459
- }
1460
-
1461
- function phoenixHandle(auth) {
1462
- return handleFromEmail(auth.email) || sanitizeNamespace(auth.owner);
1463
- }
1464
-
1465
- // First writer of a handle owns it. Later PUTs from the same userId are fine;
1466
- // a different userId whose email local-part collides gets 409, not a silent
1467
- // overwrite — EXCEPT when the verified Phoenix email matches the claim's
1468
- // recorded email exactly: that is the same human re-authenticated under a new
1469
- // userId (an account move), and the claim transfers to them instead of
1470
- // dead-ending (PHNX-3547). The transfer also re-stamps owner on the old
1471
- // account's objects so PATCH/DELETE keep working.
1472
- async function assertHandleOwner(bucket, handle, userId, email) {
1473
- // The __handles/<handle> claim object is the authoritative first-writer record
1474
- // of ownership. When it exists it decides ownership OUTRIGHT: the recorded
1475
- // userId may write, anyone else is refused. Consult it FIRST — a stray page
1476
- // under the namespace stamped with a different owner (e.g. a BYO WRITE_TOKEN
1477
- // publish, which stamps owner = SHARE_NAMESPACE rather than a userId, or a page
1478
- // published under the same human's earlier userId before a re-auth) must not
1479
- // lock the rightful claim holder out of their own handle (PHNX-3291).
1480
- const key = '__handles/' + handle;
1481
- const existing = await bucket.get(key);
1482
- if (existing) {
1483
- const meta = existing.customMetadata || {};
1484
- const claimed = meta.userId;
1485
- if (claimed && claimed !== userId) {
1486
- // Same verified email, different userId → account move, not a rival:
1487
- // rebind the claim and migrate the old owner's objects. Legacy claims
1488
- // written before the claim recorded an email cannot prove this and keep
1489
- // the permanent 409.
1490
- if (email && meta.email && String(meta.email).toLowerCase() === String(email).toLowerCase()) {
1491
- await transferHandle(bucket, handle, claimed, userId, email);
1492
- return {};
1493
- }
1494
- return { error: json({ error: 'handle taken', handle: handle }, 409) };
1495
- }
1496
- return {};
1497
- }
1498
- // No claim object yet — fall back to the page-owner scan so a colliding email
1499
- // local-part cannot PUT/DELETE another account's objects in the window before
1500
- // the claim exists. (claimHandle writes the claim on the first Phoenix PUT.)
1501
- // Only a DIFFERENT Phoenix userId blocks: a BYO WRITE_TOKEN publish stamps
1502
- // owner = the namespace (=== handle) rather than a userId, so it is not a rival
1503
- // identity and must not lock the handle's first Phoenix claimant out (PHNX-3291).
1504
- const list = await bucket.list({ prefix: handle + '/', include: ['customMetadata'] });
1505
- for (const o of list.objects || []) {
1506
- const owner = o.customMetadata && o.customMetadata.owner;
1507
- if (owner && owner !== userId && owner !== handle) {
1508
- return { error: json({ error: 'handle taken', handle: handle }, 409) };
1509
- }
1510
- }
1511
- return {};
1512
- }
1513
-
1514
- // Account-move recovery (PHNX-3547): the claim's recorded userId held the handle;
1515
- // the caller proves the SAME verified email under a NEW userId. Rebind the claim
1516
- // and re-stamp owner on every object the old userId owned under this prefix, so
1517
- // the moved account keeps full control of its shares. Objects owned by anyone
1518
- // else (BYO namespace stamps, a pre-claim stray) are left untouched.
1519
- async function transferHandle(bucket, handle, oldUserId, newUserId, email) {
1520
- await bucket.put('__handles/' + handle, JSON.stringify({ userId: newUserId, email: email }), {
1521
- httpMetadata: { contentType: 'application/json' },
1522
- customMetadata: { userId: newUserId, email: email, visibility: 'unlisted' },
1523
- });
1524
- let cursor;
1525
- do {
1526
- const list = await bucket.list({ prefix: handle + '/', cursor: cursor, include: ['customMetadata'] });
1527
- for (const o of list.objects || []) {
1528
- const owner = o.customMetadata && o.customMetadata.owner;
1529
- if (!owner || owner !== oldUserId) continue;
1530
- const obj = await bucket.get(o.key);
1531
- if (!obj) continue;
1532
- const headers = new Headers();
1533
- if (typeof obj.writeHttpMetadata === 'function') obj.writeHttpMetadata(headers);
1534
- const customMetadata = { ...(obj.customMetadata || {}), owner: newUserId };
1535
- await bucket.put(o.key, obj.body, {
1536
- httpMetadata: headers.get('content-type') ? { contentType: headers.get('content-type') } : undefined,
1537
- customMetadata: customMetadata,
1538
- });
1539
- }
1540
- cursor = list.truncated ? list.cursor : undefined;
1541
- } while (cursor);
1542
- }
1543
-
1544
- async function claimHandle(bucket, handle, userId, email) {
1545
- const owned = await assertHandleOwner(bucket, handle, userId, email);
1546
- if (owned.error) return owned;
1547
- const key = '__handles/' + handle;
1548
- // Write (or rewrite) so a same-user republish resets object Age against the
1549
- // bucket's 366-day lifecycle — otherwise the claim can expire while pages stay
1550
- // live. The claim also records the verified email: it is what lets a future
1551
- // same-email/different-userId request prove an account move and recover the
1552
- // handle instead of hitting the permanent 409.
1553
- await bucket.put(key, JSON.stringify({ userId: userId, email: email }), {
1554
- httpMetadata: { contentType: 'application/json' },
1555
- customMetadata: { userId: userId, email: email || '', visibility: 'unlisted' },
1556
- });
1557
- return {};
1558
- }
1559
-
1560
- function normalizeVisibility(raw) {
1561
- const v = (raw || '').trim().toLowerCase();
1562
- if (!v || v === 'public') return { value: 'public' };
1563
- if (v === 'unlisted' || v === 'private' || v === 'me' || v === 'org') return { value: v };
1564
- return { error: json({ error: 'visibility must be public, unlisted, private, me, or org' }, 400) };
1565
- }
1566
-
1567
- function emailDomain(email) {
1568
- if (!email) return '';
1569
- const at = String(email).lastIndexOf('@');
1570
- if (at < 0) return '';
1571
- return String(email).slice(at + 1).toLowerCase();
1572
- }
1573
-
1574
- function isHiddenFromGallery(visibility) {
1575
- return visibility === 'unlisted' || visibility === 'private' || visibility === 'me' || visibility === 'org';
1576
- }
1577
-
1578
- function isIdentityGated(visibility) {
1579
- return visibility === 'me' || visibility === 'org';
1580
- }
1581
-
1582
- function managedCoverHeaders(visibility) {
1583
- const headers = new Headers({ 'content-type': 'image/png' });
1584
- // Token-gated (private) + identity-gated (me/org) covers must never be
1585
- // cached by a shared proxy, and never indexed. RFC 9111: public on an
1586
- // Authorization response authorizes reuse for later unauthenticated
1587
- // requests keyed on /user/slug.png (PHNX-3676).
1588
- if (isIdentityGated(visibility) || visibility === 'private') {
1589
- headers.set('cache-control', 'private, no-store');
1590
- headers.set('X-Robots-Tag', 'noindex');
1591
- } else {
1592
- headers.set('cache-control', 'public, max-age=31536000, immutable');
1593
- if (visibility === 'unlisted') headers.set('X-Robots-Tag', 'noindex');
1594
- }
1595
- return headers;
1596
- }
1597
-
1598
- // Gate a me/org read given the ALREADY-RESOLVED viewer identity. Pure/sync: the
1599
- // caller resolves the viewer once (it also needs the identity for the ownership
1600
- // check) and both the page GET and the ?revisions=json path share this gate.
1601
- async function gateVisibility(url, env, obj, identity) {
1602
- const visibility = (obj.customMetadata && obj.customMetadata.visibility) || 'public';
1603
- if (!isIdentityGated(visibility)) return null;
1604
- if (!identity) return bounceToLogin(url, env);
1605
- if (!viewerMayRead(visibility, obj.customMetadata, identity)) {
1606
- // A 'me' page reads for its stamped owner (the fast path above) OR for the
1607
- // holder of the namespace's handle claim — the same authority PATCH and
1608
- // DELETE use. Without this, the claim holder who takes a fleet/BYO-stamped
1609
- // or pre-stamp page to 'me' (owner = namespace, or none) would be locked
1610
- // out of her own page: PATCH says 200, GET says 404. The stamp itself stays
1611
- // untouched (expiry refunds credit the stamped ledger), so the read gate
1612
- // has to consult the claim rather than the stamp.
1613
- const handle = decodeURIComponent(url.pathname).split('/').filter(Boolean)[0] || '';
1614
- if (visibility === 'me' && handle && (await holdsHandleClaim(env.BUCKET, handle, identity.userId))) return null;
1615
- return new Response('not found', { status: 404, headers: { 'content-type': 'text/plain' } });
1616
- }
1617
- return null;
1618
- }
1619
-
1620
- // True when userId is the recorded holder of the __handles/<handle> claim.
1621
- // Read-only: never transfers or writes a claim (that is claimHandle /
1622
- // assertHandleOwner's job on the write paths).
1623
- async function holdsHandleClaim(bucket, handle, userId) {
1624
- if (!handle || !userId) return false;
1625
- const claim = await bucket.get('__handles/' + handle);
1626
- if (!claim) return false;
1627
- const claimed = claim.customMetadata && claim.customMetadata.userId;
1628
- return !!claimed && claimed === userId;
1629
- }
1630
-
1631
- // SHA-256 hex of a string — the form a 'private' object's stored
1632
- // 'viewer-token-hash' takes. Both the PUT (hash-on-store) and the read gate use
1633
- // this, so a token minted by the CLI matches byte-for-byte (PHNX-3654).
1634
- async function sha256Hex(text) {
1635
- const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(String(text)));
1636
- return bufToHex(digest);
1637
- }
1638
-
1639
- // The viewer key a reader presents for a token-gated ('private') page: the ?k=
1640
- // query param first (the shape the CLI emits — https://host/<u>/<s>?k=<token>),
1641
- // then an Authorization: Bearer token as an alternative for scripted callers.
1642
- function readViewerKey(request, url) {
1643
- const q = url.searchParams.get('k');
1644
- if (q) return q;
1645
- const bearer = (request.headers.get('authorization') || '').replace(/^Bearer\\s+/i, '');
1646
- return bearer || '';
1647
- }
1648
-
1649
- // Gate a 'private' (token-gated) read (PHNX-3654). Returns a 404 Response when
1650
- // the request may NOT read, or null when it may. A miss ALWAYS 404s — never a
1651
- // 401 and never a distinct "wrong key" — so a token-gated page never even leaks
1652
- // that it exists, exactly like a wrong me/org viewer. The namespace owner
1653
- // (resolved identity whose userId stamped the object) is let through without the
1654
- // key so 'share open' and the owner's own browsing still work. Constant-time
1655
- // compare (safeEqual) over the stored SHA-256 hash keeps the check timing-safe.
1656
- async function gateTokenRead(request, url, obj, identity) {
1657
- const meta = obj.customMetadata || {};
1658
- const visibility = meta.visibility || 'public';
1659
- if (visibility !== 'private') return null;
1660
- const notFound = function () {
1661
- return new Response('not found', { status: 404, headers: { 'content-type': 'text/plain' } });
1662
- };
1663
- // Owner bypass: the signed-in owner of the page reads their own private page
1664
- // without the key (mirrors 'me').
1665
- if (identity && meta.owner && meta.owner === identity.userId) return null;
1666
- const stored = meta['viewer-token-hash'] || '';
1667
- // Fail closed: a private object with no stored hash can never be matched, so it
1668
- // is unreadable rather than accidentally public.
1669
- if (!stored) return notFound();
1670
- const presented = readViewerKey(request, url);
1671
- if (!presented) return notFound();
1672
- const presentedHash = await sha256Hex(presented);
1673
- if (!safeEqual(presentedHash, stored)) return notFound();
1674
- return null;
1675
- }
1676
-
1677
- // Per-slug view counter, stored as a SEPARATE R2 object under __views/<path> so
1678
- // counting a view never rewrites the page object (which would reset its uploaded
1679
- // timestamp and corrupt "last updated"). Best-effort telemetry: a read/write
1680
- // failure must never break page serving, so both degrade to a no-op rather than
1681
- // throwing. R2 has no atomic increment, so two simultaneous views can race and
1682
- // lose a count — acceptable for an approximate visitor count.
1683
- async function readViews(env, path) {
1684
- try {
1685
- const obj = await env.BUCKET.get('__views/' + path);
1686
- if (!obj) return 0;
1687
- const raw = typeof obj.text === 'function' ? await obj.text() : '';
1688
- const n = parseInt(raw, 10);
1689
- return Number.isFinite(n) && n >= 0 ? n : 0;
1690
- } catch {
1691
- return 0;
1692
- }
1693
- }
1694
-
1695
- async function writeViews(env, path, count) {
1696
- try {
1697
- await env.BUCKET.put('__views/' + path, String(count), {
1698
- httpMetadata: { contentType: 'text/plain; charset=utf-8' },
1699
- });
1700
- } catch {
1701
- // best-effort: a failed counter write must never surface to the visitor
1702
- }
1703
- }
1704
-
1705
- // --- PHNX-3542 per-user usage ledger (R2 conditional-put CAS) ---------------
1706
- // The ledger is a single R2 object at __usage/<owner> holding the running
1707
- // { bytes, count, plan, rlStart, rlUsed } for one user. It mirrors the __views /
1708
- // __handles precedent: a __-prefixed key, GET-blocked and outside every gallery/
1709
- // listing/revision prefix. R2 has no atomic increment, so mutations use a
1710
- // read → mutate → conditional-put loop (onlyIf.etagMatches), the same primitive
1711
- // the PATCH metadata-edit path already relies on.
1712
- function usageKey(owner) { return '__usage/' + sanitizeNamespace(owner); }
1713
-
1714
- async function readUsage(env, owner) {
1715
- const obj = await env.BUCKET.get(usageKey(owner));
1716
- if (!obj) return { etag: null, usage: freshUsage() };
1717
- let usage = freshUsage();
1718
- try {
1719
- const raw = typeof obj.text === 'function' ? await obj.text() : '';
1720
- const parsed = JSON.parse(raw);
1721
- if (parsed && typeof parsed === 'object') {
1722
- usage = {
1723
- bytes: usageNumber(parsed.bytes, 0),
1724
- count: usageNumber(parsed.count, 0),
1725
- plan: typeof parsed.plan === 'string' ? parsed.plan : DEFAULT_SHARE_PLAN,
1726
- rlStart: usageNumber(parsed.rlStart, 0),
1727
- rlUsed: usageNumber(parsed.rlUsed, 0),
1728
- };
1729
- }
1730
- } catch (e) {
1731
- // Malformed ledger — treat as fresh zero but KEEP the etag so the next CAS
1732
- // put overwrites the corrupt object rather than looping against it forever.
1733
- }
1734
- return { etag: obj.etag || null, usage: usage };
1735
- }
1736
-
1737
- async function writeUsageCas(env, owner, prevEtag, usage) {
1738
- const body = JSON.stringify(usage);
1739
- const opts = { httpMetadata: { contentType: 'application/json' } };
1740
- if (prevEtag) {
1741
- // Existing object: conditional put. R2 returns null when the etag no longer
1742
- // matches (a concurrent writer won the race) → report failure so the caller
1743
- // re-reads and retries.
1744
- opts.onlyIf = { etagMatches: prevEtag };
1745
- const res = await env.BUCKET.put(usageKey(owner), body, opts);
1746
- return res !== null;
1747
- }
1748
- // Fresh key: plain create. Neither R2 nor the test harness exposes a
1749
- // conditional-create predicate, so the only race is two simultaneous
1750
- // first-creates of the same owner's ledger — a benign, one-time bounded loss.
1751
- await env.BUCKET.put(usageKey(owner), body, opts);
1752
- return true;
1753
- }
1754
-
1755
- // CAS retry loop. fn(usage) returns { reject: Response } to fail loud without
1756
- // committing, or { commit: usage, result } to persist and return result. On CAS
1757
- // contention it re-reads and retries; exhausting the retries fails loud with 503.
1758
- async function withUsage(env, owner, fn) {
1759
- for (let attempt = 0; attempt < 6; attempt++) {
1760
- const state = await readUsage(env, owner);
1761
- const outcome = fn(state.usage);
1762
- if (outcome.reject) return outcome.reject;
1763
- const ok = await writeUsageCas(env, owner, state.etag, outcome.commit);
1764
- if (ok) return outcome.result;
1765
- }
1766
- return json({ error: 'usage ledger contended, retry' }, 503);
1767
- }
1768
-
1769
- function rateLimited(retryAfterSec, ratePerHour) {
1770
- return new Response(
1771
- JSON.stringify({ error: 'rate limit: too many publishes, retry later', retryAfterSec: retryAfterSec, ratePerHour: ratePerHour }),
1772
- { status: 429, headers: { 'content-type': 'application/json', 'retry-after': String(retryAfterSec) } },
1773
- );
1774
- }
1775
-
1776
- // Charge one authed PUT against the owner's ledger. Rate → per-file cap → object
1777
- // count → byte quota, each failing loud (429 / 413) before anything is written.
1778
- // Returns { error: Response } on any rejection, else { limits } (the resolved
1779
- // plan limits, reused by the post-write real-size reconcile). Managed only: the
1780
- // caller guards on auth.kind === 'phoenix'.
1781
- async function chargeShareWrite(env, auth, params) {
1782
- const now = Date.now();
1783
- const out = await withUsage(env, auth.owner, function (usage) {
1784
- const limits = planLimits(usage.plan);
1785
- // Rate limit — only a user-initiated page PUT counts (countRate). A fixed 1h
1786
- // window: reset when the window has rolled over, otherwise reject at the cap.
1787
- if (params.countRate) {
1788
- if (now - usage.rlStart >= RATE_WINDOW_MS) { usage.rlStart = now; usage.rlUsed = 0; }
1789
- if (usage.rlUsed >= limits.ratePerHour) {
1790
- const retryAfterSec = Math.max(1, Math.ceil((usage.rlStart + RATE_WINDOW_MS - now) / 1000));
1791
- return { reject: rateLimited(retryAfterSec, limits.ratePerHour) };
1792
- }
1793
- usage.rlUsed += 1;
1794
- }
1795
- // Per-file size cap.
1796
- if (typeof params.fileBytes === 'number' && params.fileBytes > limits.maxFileBytes) {
1797
- return { reject: json({ error: 'file too large', maxBytes: limits.maxFileBytes, gotBytes: params.fileBytes }, 413) };
1798
- }
1799
- // Object (canonical page) count.
1800
- if (params.newCanonical) {
1801
- if (usage.count + 1 > limits.maxObjects) {
1802
- return { reject: json({ error: 'artifact limit reached', maxObjects: limits.maxObjects }, 413) };
1803
- }
1804
- usage.count += 1;
1805
- }
1806
- // Total byte quota. charge may be negative on a shrink — clamp at >= 0.
1807
- if (usage.bytes + params.charge > limits.maxBytes) {
1808
- return { reject: json({ error: 'storage limit reached', maxBytes: limits.maxBytes, usedBytes: usage.bytes }, 413) };
1809
- }
1810
- usage.bytes = Math.max(0, usage.bytes + params.charge);
1811
- return { commit: usage, result: { limits: limits } };
1812
- });
1813
- if (out instanceof Response) return { error: out };
1814
- return { limits: out.limits };
1815
- }
1816
-
1817
- // Read a request body fully into memory, BOUNDED: abort the moment it exceeds
1818
- // maxBytes, so a chunked/streaming body can never buffer more than the cap (plus
1819
- // one in-flight chunk) and OOM the Worker. Returns { oversize: true, size } once
1820
- // the cap is passed (size is a lower bound, >= cap), else { bytes, size } with
1821
- // the exact bytes. A body-less request yields an empty buffer. This is what lets
1822
- // enforcement key on the REAL size instead of a spoofable declared header.
1823
- async function readBodyBounded(request, maxBytes) {
1824
- if (!request.body || typeof request.body.getReader !== 'function') {
1825
- const buf = await request.arrayBuffer();
1826
- const bytes = new Uint8Array(buf);
1827
- if (bytes.byteLength > maxBytes) return { oversize: true, size: bytes.byteLength };
1828
- return { bytes: bytes, size: bytes.byteLength };
1829
- }
1830
- const reader = request.body.getReader();
1831
- const chunks = [];
1832
- let size = 0;
1833
- while (true) {
1834
- const step = await reader.read();
1835
- if (step.done) break;
1836
- const chunk = step.value;
1837
- size += chunk.byteLength;
1838
- if (size > maxBytes) {
1839
- try { await reader.cancel(); } catch (e) { /* best-effort */ }
1840
- return { oversize: true, size: size };
1841
- }
1842
- chunks.push(chunk);
1843
- }
1844
- const out = new Uint8Array(size);
1845
- let offset = 0;
1846
- for (let i = 0; i < chunks.length; i++) { out.set(chunks[i], offset); offset += chunks[i].byteLength; }
1847
- return { bytes: out, size: size };
1848
- }
1849
-
1850
- // Refund on DELETE / expiry. Never rejects, never creates a ledger: if the owner
1851
- // was never charged (no __usage object) it is a pure no-op.
1852
- async function refundShareWrite(env, owner, params) {
1853
- for (let attempt = 0; attempt < 6; attempt++) {
1854
- const state = await readUsage(env, owner);
1855
- if (!state.etag) return; // no ledger — owner never charged, nothing to refund
1856
- state.usage.bytes = Math.max(0, state.usage.bytes - (params.refund || 0));
1857
- if (params.freeCanonical) state.usage.count = Math.max(0, state.usage.count - 1);
1858
- if (await writeUsageCas(env, owner, state.etag, state.usage)) return;
1859
- }
1860
- }
1861
-
1862
- function viewerMayRead(visibility, meta, identity) {
1863
- if (visibility === 'me') {
1864
- const owner = meta && meta.owner;
1865
- return !!owner && owner === identity.userId;
1866
- }
1867
- if (visibility === 'org') {
1868
- const stamped = meta && meta.org_domain;
1869
- if (!stamped) return false;
1870
- return emailDomain(identity.email) === String(stamped).toLowerCase();
1871
- }
1872
- return true;
1873
- }
1874
-
1875
- function bounceToLogin(url, env) {
1876
- const base = typeof env.PHOENIX_ID_BASE === 'string' ? env.PHOENIX_ID_BASE.replace(/\\/+$/, '') : '';
1877
- if (!base) return json({ error: 'phoenix login is not configured' }, 401);
1878
- return new Response(null, {
1879
- status: 302,
1880
- headers: { Location: base + '/login?return=' + encodeURIComponent(url.toString()) },
1881
- });
1882
- }
1883
-
1884
- function readCookie(request, name) {
1885
- const header = request.headers.get('Cookie') || '';
1886
- const parts = header.split(';');
1887
- for (let i = 0; i < parts.length; i++) {
1888
- const p = parts[i];
1889
- const eq = p.indexOf('=');
1890
- if (eq < 0) continue;
1891
- if (p.slice(0, eq).trim() === name) return p.slice(eq + 1).trim();
1892
- }
1893
- return '';
1894
- }
1895
-
1896
- function toB64Url(s) {
1897
- const bytes = new TextEncoder().encode(s);
1898
- let bin = '';
1899
- for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
1900
- return btoa(bin).replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '');
1901
- }
1902
-
1903
- function fromB64Url(s) {
1904
- const pad = s.length % 4 === 0 ? '' : '='.repeat(4 - (s.length % 4));
1905
- const b64 = s.replace(/-/g, '+').replace(/_/g, '/') + pad;
1906
- const bin = atob(b64);
1907
- const bytes = new Uint8Array(bin.length);
1908
- for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
1909
- return new TextDecoder().decode(bytes);
1910
- }
1911
-
1912
- function bufToHex(buf) {
1913
- return [...new Uint8Array(buf)].map(b => b.toString(16).padStart(2, '0')).join('');
1914
- }
1915
-
1916
- async function hmacHex(secret, data) {
1917
- const key = await crypto.subtle.importKey(
1918
- 'raw',
1919
- new TextEncoder().encode(secret),
1920
- { name: 'HMAC', hash: 'SHA-256' },
1921
- false,
1922
- ['sign'],
1923
- );
1924
- const sig = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(data));
1925
- return bufToHex(sig);
1926
- }
1927
-
1928
- async function signShareCookie(identity, env) {
1929
- const secret = typeof env.WRITE_TOKEN === 'string' ? env.WRITE_TOKEN : '';
1930
- if (!secret) return '';
1931
- const exp = Math.floor(Date.now() / 1000) + SHARE_COOKIE_MAX_AGE;
1932
- const payload = identity.userId + '|' + (identity.email || '') + '|' + exp;
1933
- const sig = await hmacHex(secret, payload);
1934
- const value = toB64Url(payload) + '.' + sig;
1935
- return SHARE_COOKIE + '=' + value + '; HttpOnly; Secure; Path=/; SameSite=Lax; Max-Age=' + SHARE_COOKIE_MAX_AGE;
1936
- }
1937
-
1938
- // A short-lived login ticket, self-signed with the same HMAC secret as the cookie
1939
- // but DOMAIN-SEPARATED (the signature covers a 'ticket:'-prefixed payload), so a
1940
- // ticket value can never be replayed as a cookie value or vice versa. It rides in
1941
- // the ?phoenix_ticket= param for exactly one navigation; resolveViewer verifies it
1942
- // locally and exchanges it for the __share cookie. TTL is deliberately tiny — it
1943
- // only has to survive the round-trip from CLI mint to the browser opening the URL.
1944
- var SELF_TICKET_MAX_AGE = 120;
1945
-
1946
- async function signSelfTicket(identity, env) {
1947
- const secret = typeof env.WRITE_TOKEN === 'string' ? env.WRITE_TOKEN : '';
1948
- if (!secret || !identity || !identity.userId) return '';
1949
- const exp = Math.floor(Date.now() / 1000) + SELF_TICKET_MAX_AGE;
1950
- const payload = identity.userId + '|' + (identity.email || '') + '|' + exp;
1951
- const sig = await hmacHex(secret, 'ticket:' + payload);
1952
- return toB64Url(payload) + '.' + sig;
1953
- }
1954
-
1955
- async function verifySelfTicket(ticket, env) {
1956
- const secret = typeof env.WRITE_TOKEN === 'string' ? env.WRITE_TOKEN : '';
1957
- if (!secret || !ticket) return null;
1958
- const dot = ticket.lastIndexOf('.');
1959
- if (dot < 1) return null;
1960
- let payload;
1961
- try {
1962
- payload = fromB64Url(ticket.slice(0, dot));
1963
- } catch {
1964
- return null;
1965
- }
1966
- const sig = ticket.slice(dot + 1);
1967
- const expected = await hmacHex(secret, 'ticket:' + payload);
1968
- if (!safeEqual(sig, expected)) return null;
1969
- const first = payload.indexOf('|');
1970
- const last = payload.lastIndexOf('|');
1971
- if (first < 0 || last <= first) return null;
1972
- const userId = payload.slice(0, first);
1973
- const email = payload.slice(first + 1, last);
1974
- const exp = Number(payload.slice(last + 1));
1975
- if (!userId || !Number.isFinite(exp) || Math.floor(Date.now() / 1000) > exp) return null;
1976
- return { userId: userId, email: email };
1977
- }
1978
-
1979
- // Single-use enforcement for a self-signed ticket: claim its signature the first
1980
- // time it is redeemed, and reject any later presentation of the same ticket. The
1981
- // HMAC + short TTL stop forgery and bound the window, but without this a ticket the
1982
- // CLI printed (--json / --no-open / a browser-open failure) could be replayed by
1983
- // anyone who captured that output before it expired, minting the owner's 7-day
1984
- // cookie a second time. Get-then-put mirrors claimHandle's one-time-claim pattern;
1985
- // the marker rides a __-prefixed key (GET-blocked, excluded from every listing).
1986
- async function consumeSelfTicket(env, sig) {
1987
- if (!sig) return false;
1988
- const key = '__ticket-used/' + sig;
1989
- const existing = await env.BUCKET.get(key);
1990
- if (existing) return false;
1991
- await env.BUCKET.put(key, '1', {
1992
- httpMetadata: { contentType: 'text/plain' },
1993
- customMetadata: { visibility: 'unlisted' },
1994
- });
1995
- return true;
1996
- }
1997
-
1998
- async function identityFromCookie(request, env) {
1999
- const secret = typeof env.WRITE_TOKEN === 'string' ? env.WRITE_TOKEN : '';
2000
- if (!secret) return null;
2001
- const raw = readCookie(request, SHARE_COOKIE);
2002
- if (!raw) return null;
2003
- const dot = raw.lastIndexOf('.');
2004
- if (dot < 1) return null;
2005
- let payload;
2006
- try {
2007
- payload = fromB64Url(raw.slice(0, dot));
2008
- } catch {
2009
- return null;
2010
- }
2011
- const sig = raw.slice(dot + 1);
2012
- const expected = await hmacHex(secret, payload);
2013
- if (!safeEqual(sig, expected)) return null;
2014
- const first = payload.indexOf('|');
2015
- const last = payload.lastIndexOf('|');
2016
- if (first < 0 || last <= first) return null;
2017
- const userId = payload.slice(0, first);
2018
- const email = payload.slice(first + 1, last);
2019
- const cookieExp = Number(payload.slice(last + 1));
2020
- if (!userId || !Number.isFinite(cookieExp) || Math.floor(Date.now() / 1000) > cookieExp) return null;
2021
- return { userId: userId, email: email };
2022
- }
2023
-
2024
- async function redeemTicket(ticket, env) {
2025
- const base = typeof env.PHOENIX_ID_BASE === 'string' ? env.PHOENIX_ID_BASE.replace(/\\/+$/, '') : '';
2026
- if (!base || !ticket) return null;
2027
- let res;
2028
- try {
2029
- res = await fetch(base + '/api/v1/auth/ticket', {
2030
- method: 'POST',
2031
- headers: { 'content-type': 'application/json' },
2032
- body: JSON.stringify({ ticket: ticket }),
2033
- });
2034
- } catch {
2035
- return null;
2036
- }
2037
- if (!res.ok) return null;
2038
- let body;
2039
- try {
2040
- body = await res.json();
2041
- } catch {
2042
- return null;
2043
- }
2044
- if (!body || typeof body.userId !== 'string' || !body.userId) return null;
2045
- return { userId: body.userId, email: typeof body.email === 'string' ? body.email : '' };
2046
- }
2047
-
2048
- async function resolveViewer(request, env, url) {
2049
- const claims = await hooks.verifyPhoenixToken(request, env);
2050
- if (claims && typeof claims.userId === 'string' && claims.userId) {
2051
- return { identity: { userId: claims.userId, email: typeof claims.email === 'string' ? claims.email : '' } };
2052
- }
2053
- const cookieId = await identityFromCookie(request, env);
2054
- if (cookieId) return { identity: cookieId };
2055
- const ticket = url.searchParams.get('phoenix_ticket');
2056
- if (!ticket) return {};
2057
- // Prefer the locally-verifiable self-signed ticket (PHNX-3370, no network), and
2058
- // enforce single use by claiming its signature on first redemption — a captured
2059
- // ticket replayed within its TTL finds it already spent. Fall back to the
2060
- // external identity-server redeem for tickets it issued.
2061
- let redeemed = await verifySelfTicket(ticket, env);
2062
- if (redeemed) {
2063
- const sig = ticket.slice(ticket.lastIndexOf('.') + 1);
2064
- if (!(await consumeSelfTicket(env, sig))) redeemed = null;
2065
- } else {
2066
- redeemed = await redeemTicket(ticket, env);
2067
- }
2068
- if (!redeemed) return { error: json({ error: 'invalid ticket' }, 401) };
2069
- const cookie = await signShareCookie(redeemed, env);
2070
- if (!cookie) return { error: json({ error: 'phoenix login is not configured' }, 401) };
2071
- url.searchParams.delete('phoenix_ticket');
2072
- const headers = new Headers();
2073
- headers.set('Location', url.toString());
2074
- headers.append('Set-Cookie', cookie);
2075
- return { redirect: new Response(null, { status: 302, headers: headers }) };
2076
- }
2077
-
2078
- function extractHtmlMeta(html, field) {
2079
- const pattern = field === 'title'
2080
- ? /<title[^>]*>([\\s\\S]*?)<\\/title>/i
2081
- : /<meta[^>]+(?:name|property)=["'](?:description|og:description)["'][^>]+content=["']([^"']*)["'][^>]*>/i;
2082
- const match = pattern.exec(html);
2083
- return match ? match[1].replace(/<[^>]*>/g, '').replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').trim() : '';
2084
- }
2085
-
2086
- let rendererReady;
2087
- async function renderOgCard(input) {
2088
- if (!rendererReady) {
2089
- rendererReady = Promise.all([
2090
- initYoga(yogaWasm).then(function (yoga) { initSatori(yoga); }),
2091
- initResvg(resvgWasm),
2092
- ]);
2093
- }
2094
- await rendererReady;
2095
- const visibilityLabels = {
2096
- public: 'PUBLIC',
2097
- unlisted: 'UNLISTED',
2098
- private: 'PROTECTED',
2099
- me: 'ONLY YOU',
2100
- org: input.orgDomain ? 'ANYONE AT ' + input.orgDomain.toUpperCase() : 'ORGANIZATION',
2101
- };
2102
- const node = {
2103
- type: 'div',
2104
- props: {
2105
- style: { width: '100%', height: '100%', display: 'flex', flexDirection: 'column', background: '#0a0a0a', color: '#f5f5f5', padding: '68px 76px 58px', fontFamily: 'Inter' },
2106
- children: [
2107
- { type: 'div', props: { style: { display: 'flex', color: '#a3e635', fontFamily: 'JetBrains Mono', fontSize: 25, fontWeight: 600, letterSpacing: '-0.5px' }, children: 'share.getrush.ai' } },
2108
- { type: 'div', props: { style: { display: 'flex', flexDirection: 'column', flexGrow: 1, justifyContent: 'center', maxWidth: 1050 }, children: [
2109
- { type: 'div', props: { style: { display: 'flex', fontSize: 66, lineHeight: 1.06, fontWeight: 700, letterSpacing: '-2.8px', maxHeight: 218, overflow: 'hidden' }, children: input.title || 'Shared artifact' } },
2110
- input.description ? { type: 'div', props: { style: { display: 'flex', marginTop: 24, color: '#a3a3a3', fontSize: 27, lineHeight: 1.35, maxHeight: 74, overflow: 'hidden' }, children: input.description } } : null,
2111
- ].filter(Boolean) } },
2112
- { type: 'div', props: { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', borderTop: '1px solid #262626', paddingTop: 25, fontSize: 21, color: '#a3a3a3' }, children: [
2113
- { type: 'div', props: { style: { display: 'flex' }, children: [
2114
- { type: 'span', props: { style: { color: '#737373' }, children: 'shared by ' } },
2115
- { type: 'span', props: { style: { marginLeft: 7, color: '#f5f5f5', fontWeight: 700 }, children: '@' + input.handle } },
2116
- ] } },
2117
- { type: 'div', props: { style: { display: 'flex', border: '1px solid #3f3f46', borderRadius: 999, padding: '9px 17px', color: '#d4d4d8', fontFamily: 'JetBrains Mono', fontSize: 16, letterSpacing: '1px' }, children: visibilityLabels[input.visibility] || 'PUBLIC' } },
2118
- ] } },
2119
- ],
2120
- },
2121
- };
2122
- const svg = await satori(node, {
2123
- width: 1200,
2124
- height: 630,
2125
- fonts: [
2126
- { name: 'Inter', data: interRegular, weight: 400, style: 'normal' },
2127
- { name: 'Inter', data: interBold, weight: 700, style: 'normal' },
2128
- { name: 'JetBrains Mono', data: jetbrainsMono, weight: 600, style: 'normal' },
2129
- ],
2130
- });
2131
- return new Resvg(svg, { fitTo: { mode: 'width', value: 1200 } }).render().asPng();
2132
- }
2133
-
2134
- async function defaultVerifyPhoenixToken(request, env) {
2135
- const presented = (request.headers.get('authorization') || '').replace(/^Bearer\\s+/i, '');
2136
- if (!presented) return null;
2137
- const base = typeof env.PHOENIX_ID_BASE === 'string' ? env.PHOENIX_ID_BASE.replace(/\\/+$/, '') : '';
2138
- if (!base) return null;
2139
- let res;
2140
- try {
2141
- res = await fetch(base + '/api/v1/auth/me', {
2142
- method: 'GET',
2143
- headers: { Authorization: 'Bearer ' + presented },
2144
- });
2145
- } catch {
2146
- return null;
2147
- }
2148
- if (!res.ok) return null;
2149
- let body;
2150
- try {
2151
- body = await res.json();
2152
- } catch {
2153
- return null;
2154
- }
2155
- if (!body || typeof body.userId !== 'string' || !body.userId) return null;
2156
- return { userId: body.userId, email: typeof body.email === 'string' ? body.email : '' };
2157
- }
2158
-
2159
- // Write principals, in order: WRITE_TOKEN equality (admin/BYO), then a Phoenix
2160
- // bearer, then the signed-in viewer's __share cookie. The cookie is an
2161
- // HMAC-signed {userId,email} proof (identityFromCookie verifies it), which lets
2162
- // the shared page's inline visibility control PATCH with credentials:'include'
2163
- // and no bearer — the browser has the identity cookie, not a token. It cannot be
2164
- // abused cross-site: the cookie is SameSite=Lax, which blocks it from riding a
2165
- // cross-origin non-GET (CSRF), and namespace/owner enforcement on PUT/PATCH/
2166
- // DELETE still confines a cookie holder to their own pages. 401 when none apply.
2167
- async function authorizeWrite(request, env) {
2168
- const presented = (request.headers.get('authorization') || '').replace(/^Bearer\\s+/i, '');
2169
- if (presented) {
2170
- if (env.WRITE_TOKEN && safeEqual(presented, env.WRITE_TOKEN)) {
2171
- return { kind: 'byo', owner: env.SHARE_NAMESPACE || 'byo' };
2172
- }
2173
- const claims = await hooks.verifyPhoenixToken(request, env);
2174
- if (claims && typeof claims.userId === 'string' && claims.userId) {
2175
- return { kind: 'phoenix', owner: claims.userId, email: typeof claims.email === 'string' ? claims.email : '' };
2176
- }
2177
- }
2178
- const cookieId = await identityFromCookie(request, env);
2179
- if (cookieId && cookieId.userId) {
2180
- return { kind: 'phoenix', owner: cookieId.userId, email: typeof cookieId.email === 'string' ? cookieId.email : '' };
2181
- }
2182
- return { error: json({ error: 'unauthorized' }, 401) };
2183
- }
2184
-
2185
- // ---- Same-origin human collaboration transport (PHNX-3835) ----
2186
- //
2187
- // The Worker is the trust boundary for collaboration exactly as it is for the
2188
- // page GET. For every /__collab/* request it:
2189
- // 1. loads the R2 share object FIRST (object-first) and 404s if absent — share
2190
- // existence is never enumerable, and a deleted share drops its comments the
2191
- // instant the object is gone (no separate revocation step);
2192
- // 2. re-derives share identity, current revision (etag), owner, visibility,
2193
- // org domain, and producer provenance SERVER-SIDE from R2 metadata, ignoring
2194
- // anything the browser supplied (the 'share' field is a LOOKUP key only,
2195
- // never trusted as identity);
2196
- // 3. reuses the EXACT page read gate (me/org identity gate + private token
2197
- // gate), mapping any denial to 404 so an out-of-scope viewer cannot tell the
2198
- // share exists; every WRITE additionally requires a verified signed-in
2199
- // Phoenix human;
2200
- // 4. proxies to the Prix artifact-collaboration API with the service token in
2201
- // Authorization (never surfaced to the browser) plus trusted X-Artifact-* /
2202
- // X-Phoenix-* headers, streaming SSE through without buffering.
2203
- //
2204
- // Missing PRIX_ARTIFACT_COLLAB_BASE or ARTIFACT_COLLAB_SERVICE_TOKEN disables the
2205
- // whole surface (404) without touching artifact page GET.
2206
-
2207
- var COLLAB_VISIBILITIES = ['public', 'unlisted', 'private', 'me', 'org'];
2208
-
2209
- // The managed collaboration backend, or null when unconfigured. Both the base
2210
- // URL and the service token must be present; either missing fails the whole
2211
- // surface closed.
2212
- function collabConfig(env) {
2213
- const base = typeof env.PRIX_ARTIFACT_COLLAB_BASE === 'string' ? env.PRIX_ARTIFACT_COLLAB_BASE.replace(/\\/+$/, '').trim() : '';
2214
- const token = typeof env.ARTIFACT_COLLAB_SERVICE_TOKEN === 'string' ? env.ARTIFACT_COLLAB_SERVICE_TOKEN : '';
2215
- if (!base || !token) return null;
2216
- return { base: base, token: token };
2217
- }
2218
-
2219
- // No collaboration response may be cached (visibility can downgrade on any
2220
- // request), and every collab body is JSON unless overridden (SSE).
2221
- function collabHeaders(extra) {
2222
- const h = new Headers(extra || {});
2223
- if (!h.has('content-type')) h.set('content-type', 'application/json');
2224
- h.set('cache-control', 'no-store');
2225
- return h;
2226
- }
2227
-
2228
- function collabError(message, status) {
2229
- return new Response(JSON.stringify({ error: message }), { status: status, headers: collabHeaders() });
2230
- }
2231
-
2232
- // A collab denial is ALWAYS a 404 — never a 302 login bounce or a 401 for a read
2233
- // — so an out-of-scope viewer cannot enumerate share existence.
2234
- function collabNotFound() {
2235
- return collabError('not found', 404);
2236
- }
2237
-
2238
- // The page READ gate, reused verbatim for collaboration reads/subscriptions but
2239
- // with every denial normalized to 404. Returns null when the read is allowed.
2240
- async function collabReadGate(request, url, obj, identity) {
2241
- const visibility = (obj.customMetadata && obj.customMetadata.visibility) || 'public';
2242
- // Unknown visibility fails closed.
2243
- if (COLLAB_VISIBILITIES.indexOf(visibility) === -1) return collabNotFound();
2244
- if (isIdentityGated(visibility)) {
2245
- // me/org: require a matching identity; a missing/wrong viewer 404s (never the
2246
- // page GET's 302 login bounce, which is meaningless to a fetch()).
2247
- if (!identity || !viewerMayRead(visibility, obj.customMetadata, identity)) return collabNotFound();
2248
- return null;
2249
- }
2250
- if (visibility === 'private') {
2251
- // private: require the viewer token, exactly as the page GET does.
2252
- const denied = await gateTokenRead(request, url, obj, identity);
2253
- if (denied) return collabNotFound();
2254
- return null;
2255
- }
2256
- return null; // public | unlisted — readable when the object exists
2257
- }
2258
-
2259
- // Map a browser /__collab sub-route to the Prix artifact-collaboration route +
2260
- // method. Returns null for any unsupported shape (fail loud, never a wrong path).
2261
- // Only the cursor rides through as a query param; the browser's own \`share\`
2262
- // param is dropped (Prix derives identity from the trusted header, not the path).
2263
- function collabTarget(rest, method, url) {
2264
- if (rest.length === 1 && rest[0] === 'context' && method === 'GET') {
2265
- return { path: '/context', search: '', sse: false };
2266
- }
2267
- if (rest.length === 1 && rest[0] === 'threads') {
2268
- if (method === 'GET') {
2269
- const after = url.searchParams.get('after');
2270
- return { path: '/threads', search: after ? '?after=' + encodeURIComponent(after) : '', sse: false };
2271
- }
2272
- if (method === 'POST') return { path: '/threads', search: '', sse: false };
2273
- }
2274
- if (rest.length === 2 && rest[0] === 'threads' && method === 'PATCH') {
2275
- return { path: '/threads/' + encodeURIComponent(rest[1]), search: '', sse: false };
2276
- }
2277
- if (rest.length === 3 && rest[0] === 'threads' && rest[2] === 'replies' && method === 'POST') {
2278
- return { path: '/threads/' + encodeURIComponent(rest[1]) + '/replies', search: '', sse: false };
2279
- }
2280
- if (rest.length === 2 && rest[0] === 'comments' && method === 'PATCH') {
2281
- return { path: '/comments/' + encodeURIComponent(rest[1]), search: '', sse: false };
2282
- }
2283
- if (rest.length === 1 && rest[0] === 'events' && method === 'GET') {
2284
- return { path: '/events', search: '', sse: true };
2285
- }
2286
- return null;
2287
- }
2288
-
2289
- async function handleCollab(request, env, url, path, ctx) {
2290
- const cfg = collabConfig(env);
2291
- // Fail closed: no managed backend → the surface does not exist.
2292
- if (!cfg) return collabNotFound();
2293
-
2294
- const segs = path.split('/').filter(Boolean); // ['__collab', ...]
2295
- const rest = segs.slice(1);
2296
- const method = request.method;
2297
-
2298
- // Resolve the share path. GET carries it in ?share=, mutations in the JSON
2299
- // body — read that body ONCE here so \`share\` extraction and verbatim upstream
2300
- // forwarding share a single read.
2301
- const isMutation = method === 'POST' || method === 'PATCH';
2302
- let sharePath = '';
2303
- let bodyText = '';
2304
- if (isMutation) {
2305
- bodyText = await request.text();
2306
- let parsed;
2307
- try { parsed = JSON.parse(bodyText || '{}'); } catch { return collabError('body must be JSON', 400); }
2308
- if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return collabError('body must be a JSON object', 400);
2309
- sharePath = typeof parsed.share === 'string' ? parsed.share : '';
2310
- } else {
2311
- sharePath = url.searchParams.get('share') || '';
2312
- }
2313
- sharePath = String(sharePath).replace(/^\\/+/, '');
2314
- const shareFirst = sharePath.split('/').filter(Boolean)[0] || '';
2315
- // Never let a collab lookup reach an internal __-key or an empty share.
2316
- if (!sharePath || shareFirst.startsWith('__')) return collabNotFound();
2317
-
2318
- // OBJECT-FIRST: load the share before deriving anything. A missing share
2319
- // (never provisioned, or just deleted) 404s.
2320
- const obj = await env.BUCKET.get(sharePath);
2321
- if (!obj) return collabNotFound();
2322
-
2323
- // Re-derive identity SERVER-SIDE and apply the page read gate. Collab clients
2324
- // authenticate with the Bearer or the __share cookie, never a ?phoenix_ticket=
2325
- // (that is a page-navigation param the CLI appends when OPENING a page). So we
2326
- // use only the resolved identity and ignore any redirect outcome — a 302 login
2327
- // bounce is meaningless to a fetch(). If a ticket were present it would be
2328
- // redeemed (and, being single-use, consumed) here but its cookie discarded, so
2329
- // the request falls through as anonymous — the fail-safe (more restrictive)
2330
- // direction, never an escalation.
2331
- const viewer = await resolveViewer(request, env, url);
2332
- const identity = viewer.identity || null;
2333
- const readDenied = await collabReadGate(request, url, obj, identity);
2334
- if (readDenied) return readDenied;
2335
-
2336
- // Every write requires a verified signed-in Phoenix human, ON TOP of the read
2337
- // gate (public/unlisted readers may read anonymously but never write).
2338
- if (isMutation && (!identity || !identity.userId)) {
2339
- return collabError('sign in with Phoenix to comment', 401);
2340
- }
2341
-
2342
- const target = collabTarget(rest, method, url);
2343
- if (!target) return collabError('unsupported collaboration route', 404);
2344
-
2345
- // Trusted, server-derived values. owner + NUL + normalized path → a stable
2346
- // share id; the raw private access token is NEVER used as identity.
2347
- const meta = obj.customMetadata || {};
2348
- const visibility = meta.visibility || 'public';
2349
- const owner = meta.owner || '';
2350
- const shareId = await sha256Hex(owner + '\\u0000' + sharePath);
2351
- const revision = obj.etag || '';
2352
-
2353
- const headers = new Headers();
2354
- headers.set('authorization', 'Bearer ' + cfg.token);
2355
- headers.set('x-artifact-share-id', shareId);
2356
- if (revision) headers.set('x-artifact-revision', revision);
2357
- headers.set('x-artifact-visibility', visibility);
2358
- if (owner) headers.set('x-artifact-owner-id', owner);
2359
- if (meta.org_domain) headers.set('x-artifact-org-domain', meta.org_domain);
2360
- if (identity && identity.userId) headers.set('x-phoenix-actor-id', identity.userId);
2361
- if (identity && identity.email) headers.set('x-phoenix-actor-email', identity.email);
2362
- // Preserve existing R2 producer provenance (RUSH-2683) so Prix can attribute a
2363
- // comment thread to the run that made the artifact.
2364
- if (meta.agent) headers.set('x-artifact-agent', meta.agent);
2365
- if (meta.session) headers.set('x-artifact-session', meta.session);
2366
- if (meta.host) headers.set('x-artifact-host', meta.host);
2367
- if (meta.repo) headers.set('x-artifact-repo', meta.repo);
2368
- // Idempotency + resumable-SSE headers ride through unchanged.
2369
- const idem = request.headers.get('idempotency-key');
2370
- if (idem) headers.set('idempotency-key', idem);
2371
- const lastEvent = request.headers.get('last-event-id');
2372
- if (lastEvent) headers.set('last-event-id', lastEvent);
2373
- if (target.sse) headers.set('accept', 'text/event-stream');
2374
- else if (isMutation) headers.set('content-type', 'application/json');
2375
-
2376
- const init = { method: method, headers: headers };
2377
- if (isMutation) init.body = bodyText;
2378
- // Propagate a client disconnect to the upstream fetch (SSE cancel).
2379
- if (request.signal) init.signal = request.signal;
2380
-
2381
- let upstream;
2382
- try {
2383
- upstream = await hooks.collabFetch(new Request(cfg.base + '/v1/artifact-collaboration' + target.path + target.search, init));
2384
- } catch (e) {
2385
- return collabError('collaboration backend unavailable', 502);
2386
- }
2387
-
2388
- // Forward Prix status + body. Never echo the service token; every collab
2389
- // response is no-store. SSE streams through unbuffered — event IDs and content
2390
- // type ride the bytes, and disabling proxy buffering keeps the stream live.
2391
- const ct = upstream.headers.get('content-type');
2392
- const outHeaders = collabHeaders();
2393
- if (target.sse) {
2394
- outHeaders.set('content-type', ct || 'text/event-stream');
2395
- outHeaders.set('x-accel-buffering', 'no');
2396
- } else if (ct) {
2397
- outHeaders.set('content-type', ct);
2398
- }
2399
- return new Response(upstream.body, { status: upstream.status, headers: outHeaders });
2400
- }
2401
-
2402
- // Best-effort authenticated purge of a deleted share's collaboration threads.
2403
- // Swallows every error — a purge failure must never fail the share delete, and
2404
- // object-first gating already made the comments inaccessible.
2405
- async function purgeCollab(env, cfg, owner, sharePath) {
2406
- try {
2407
- const shareId = await sha256Hex((owner || '') + '\\u0000' + sharePath);
2408
- const headers = new Headers();
2409
- headers.set('authorization', 'Bearer ' + cfg.token);
2410
- headers.set('x-artifact-share-id', shareId);
2411
- await hooks.collabFetch(new Request(cfg.base + '/v1/artifact-collaboration/purge', { method: 'POST', headers: headers }));
2412
- } catch (e) {
2413
- // best-effort: a purge failure must never fail the share delete
2414
- }
2415
- }
2416
-
2417
- // The real upstream hop to Prix. Overridable in tests (like verifyPhoenixToken)
2418
- // so the generated Worker's derivation/gating is exercised against a simulated
2419
- // backend without a live network.
2420
- async function defaultCollabFetch(request) {
2421
- return fetch(request);
2422
- }
2423
- `;
2424
- }