@phnx-labs/agents-cli 1.22.53 → 1.22.55

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 (166) hide show
  1. package/CHANGELOG.md +208 -0
  2. package/README.md +59 -9
  3. package/dist/bootstrap.js +55 -154
  4. package/dist/cli/command-registry.d.ts +5 -0
  5. package/dist/cli/command-registry.js +8 -1
  6. package/dist/commands/accounts.js +219 -173
  7. package/dist/commands/apply.js +6 -3
  8. package/dist/commands/auth-mint.d.ts +8 -0
  9. package/dist/commands/auth-mint.js +96 -0
  10. package/dist/commands/auth.js +5 -1
  11. package/dist/commands/browser.js +1 -1
  12. package/dist/commands/cost.js +8 -2
  13. package/dist/commands/daemon.js +2 -2
  14. package/dist/commands/doctor.js +6 -1
  15. package/dist/commands/exec.js +10 -8
  16. package/dist/commands/focus.d.ts +1 -0
  17. package/dist/commands/focus.js +2 -2
  18. package/dist/commands/go.d.ts +5 -4
  19. package/dist/commands/go.js +7 -7
  20. package/dist/commands/insights.js +9 -0
  21. package/dist/commands/monitors.js +85 -30
  22. package/dist/commands/output.js +8 -2
  23. package/dist/commands/repo.js +18 -0
  24. package/dist/commands/routines.js +31 -2
  25. package/dist/commands/secrets.js +33 -14
  26. package/dist/commands/sessions.d.ts +20 -12
  27. package/dist/commands/sessions.js +64 -20
  28. package/dist/commands/setup-accounts.d.ts +8 -0
  29. package/dist/commands/setup-accounts.js +47 -0
  30. package/dist/commands/setup.d.ts +1 -1
  31. package/dist/commands/setup.js +11 -2
  32. package/dist/commands/share.d.ts +79 -3
  33. package/dist/commands/share.js +347 -18
  34. package/dist/commands/ssh.d.ts +7 -0
  35. package/dist/commands/ssh.js +18 -2
  36. package/dist/commands/status.js +14 -0
  37. package/dist/commands/view.d.ts +11 -1
  38. package/dist/commands/view.js +35 -7
  39. package/dist/lib/account-registry.js +15 -3
  40. package/dist/lib/accounting/rotate.d.ts +20 -6
  41. package/dist/lib/accounting/rotate.js +38 -7
  42. package/dist/lib/accounting/usage.d.ts +68 -1
  43. package/dist/lib/accounting/usage.js +116 -10
  44. package/dist/lib/agent-spec/agents.d.ts +5 -2
  45. package/dist/lib/agent-spec/agents.js +25 -7
  46. package/dist/lib/analytics/mix-commands.js +12 -6
  47. package/dist/lib/auth-mint.d.ts +150 -0
  48. package/dist/lib/auth-mint.js +434 -0
  49. package/dist/lib/browser/cdp.d.ts +1 -1
  50. package/dist/lib/browser/cdp.js +1 -1
  51. package/dist/lib/browser/ffmpeg.d.ts +12 -0
  52. package/dist/lib/browser/ffmpeg.js +184 -0
  53. package/dist/lib/browser/remote-control.d.ts +9 -7
  54. package/dist/lib/browser/remote-control.js +9 -7
  55. package/dist/lib/browser/service.js +119 -25
  56. package/dist/lib/claude-account-token.d.ts +10 -0
  57. package/dist/lib/claude-account-token.js +14 -4
  58. package/dist/lib/config-drift.d.ts +37 -0
  59. package/dist/lib/config-drift.js +72 -0
  60. package/dist/lib/daemon/auth-sync-service.d.ts +19 -0
  61. package/dist/lib/daemon/auth-sync-service.js +34 -0
  62. package/dist/lib/daemon/browser-task-reap-service.d.ts +14 -0
  63. package/dist/lib/daemon/browser-task-reap-service.js +26 -0
  64. package/dist/lib/daemon/daemon.js +87 -176
  65. package/dist/lib/daemon/heartbeat-service.d.ts +13 -0
  66. package/dist/lib/daemon/heartbeat-service.js +26 -0
  67. package/dist/lib/daemon/monitor-engine-service.d.ts +9 -5
  68. package/dist/lib/daemon/monitor-engine-service.js +15 -7
  69. package/dist/lib/daemon/runner.d.ts +15 -0
  70. package/dist/lib/daemon/runner.js +23 -0
  71. package/dist/lib/daemon/secrets-broker-service.d.ts +5 -4
  72. package/dist/lib/daemon/secrets-broker-service.js +17 -32
  73. package/dist/lib/daemon/service.d.ts +2 -2
  74. package/dist/lib/daemon/service.js +1 -1
  75. package/dist/lib/daemon/session-state-service.d.ts +21 -0
  76. package/dist/lib/daemon/session-state-service.js +34 -0
  77. package/dist/lib/daemon/supervisor.d.ts +17 -7
  78. package/dist/lib/daemon/supervisor.js +87 -14
  79. package/dist/lib/daemon/tmux-reap-service.d.ts +11 -0
  80. package/dist/lib/daemon/tmux-reap-service.js +28 -0
  81. package/dist/lib/daemon/webhook-receiver-service.d.ts +9 -0
  82. package/dist/lib/daemon/webhook-receiver-service.js +17 -0
  83. package/dist/lib/daemon-services.d.ts +1 -1
  84. package/dist/lib/daemon-services.js +25 -0
  85. package/dist/lib/device-config.d.ts +3 -3
  86. package/dist/lib/device-config.js +5 -5
  87. package/dist/lib/devices/connect.d.ts +26 -0
  88. package/dist/lib/devices/connect.js +48 -1
  89. package/dist/lib/devices/doctor-findings.d.ts +5 -1
  90. package/dist/lib/devices/doctor-findings.js +19 -1
  91. package/dist/lib/devices/harness-inventory.js +5 -2
  92. package/dist/lib/exec.d.ts +28 -0
  93. package/dist/lib/exec.js +73 -7
  94. package/dist/lib/feed/feed.d.ts +1 -1
  95. package/dist/lib/feed/feed.js +23 -1
  96. package/dist/lib/feed-broadcast.js +1 -1
  97. package/dist/lib/fleet/apply.d.ts +11 -0
  98. package/dist/lib/fleet/apply.js +23 -3
  99. package/dist/lib/fleet/auth-sync.js +5 -3
  100. package/dist/lib/help.d.ts +9 -0
  101. package/dist/lib/help.js +29 -1
  102. package/dist/lib/hosts/passthrough.d.ts +1 -10
  103. package/dist/lib/hosts/passthrough.js +1 -13
  104. package/dist/lib/installations/versions.js +9 -1
  105. package/dist/lib/linux-userns.d.ts +58 -0
  106. package/dist/lib/linux-userns.js +116 -0
  107. package/dist/lib/memory.d.ts +26 -0
  108. package/dist/lib/memory.js +80 -1
  109. package/dist/lib/monitors/config.d.ts +11 -0
  110. package/dist/lib/monitors/config.js +8 -0
  111. package/dist/lib/monitors/engine.d.ts +5 -1
  112. package/dist/lib/monitors/engine.js +13 -4
  113. package/dist/lib/monitors/state.d.ts +37 -1
  114. package/dist/lib/monitors/state.js +79 -4
  115. package/dist/lib/permissions-registry.d.ts +2 -0
  116. package/dist/lib/permissions-registry.js +116 -14
  117. package/dist/lib/permissions.d.ts +5 -3
  118. package/dist/lib/permissions.js +25 -27
  119. package/dist/lib/profiles.d.ts +8 -7
  120. package/dist/lib/profiles.js +12 -0
  121. package/dist/lib/project-key.d.ts +9 -0
  122. package/dist/lib/project-key.js +11 -0
  123. package/dist/lib/scheduling/routines.d.ts +47 -0
  124. package/dist/lib/scheduling/routines.js +70 -1
  125. package/dist/lib/secrets/bundles.d.ts +35 -0
  126. package/dist/lib/secrets/bundles.js +78 -1
  127. package/dist/lib/secrets/push.d.ts +3 -8
  128. package/dist/lib/secrets/push.js +18 -14
  129. package/dist/lib/secrets/remote.d.ts +9 -18
  130. package/dist/lib/secrets/remote.js +11 -26
  131. package/dist/lib/secrets/reserved-sync.d.ts +65 -0
  132. package/dist/lib/secrets/reserved-sync.js +129 -0
  133. package/dist/lib/self-heal/checks/hook-manifest.d.ts +2 -0
  134. package/dist/lib/self-heal/checks/hook-manifest.js +56 -0
  135. package/dist/lib/self-heal/registry.js +4 -0
  136. package/dist/lib/self-heal/types.d.ts +1 -1
  137. package/dist/lib/session/active.js +1 -4
  138. package/dist/lib/session/db.d.ts +41 -5
  139. package/dist/lib/session/db.js +132 -30
  140. package/dist/lib/session/discover.d.ts +32 -4
  141. package/dist/lib/session/discover.js +119 -25
  142. package/dist/lib/session/insights.d.ts +14 -0
  143. package/dist/lib/session/insights.js +25 -2
  144. package/dist/lib/session/linear.js +1 -1
  145. package/dist/lib/session/shell-programs.d.ts +17 -0
  146. package/dist/lib/session/shell-programs.js +21 -0
  147. package/dist/lib/session/state.js +2 -1
  148. package/dist/lib/session/stream-render.js +2 -1
  149. package/dist/lib/session/tool-calls.js +2 -5
  150. package/dist/lib/session/trajectory-html.js +2 -1
  151. package/dist/lib/session/trajectory.js +3 -12
  152. package/dist/lib/session/types.d.ts +8 -0
  153. package/dist/lib/share/capture.js +11 -2
  154. package/dist/lib/share/publish.d.ts +56 -5
  155. package/dist/lib/share/publish.js +126 -18
  156. package/dist/lib/share/worker-template.d.ts +3 -12
  157. package/dist/lib/share/worker-template.js +860 -59
  158. package/dist/lib/startup/root-command.js +2 -1
  159. package/dist/lib/state.d.ts +16 -0
  160. package/dist/lib/state.js +178 -46
  161. package/dist/lib/sync-status.d.ts +4 -0
  162. package/dist/lib/sync-status.js +3 -0
  163. package/dist/lib/traces/classify.js +24 -19
  164. package/dist/lib/usage-refresh.js +2 -1
  165. package/dist/lib/view-types.d.ts +7 -0
  166. package/package.json +9 -2
@@ -1,7 +1,11 @@
1
+ import { buildSync } from 'esbuild';
2
+ import { dirname } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
1
4
  // The Cloudflare Worker that fronts the R2 share bucket.
2
5
  //
3
6
  // One tiny Worker does both sides:
4
- // - PUT /<username>/<slug> — bearer-gated by TWO principals (not a fallback):
7
+ // - PUT /<username>/<slug> — write-gated by authorizeWrite's THREE principals
8
+ // (not a fallback chain — each is a distinct legitimate identity):
5
9
  // 1. Static WRITE_TOKEN (BYO Cloudflare) — checked first when the presented
6
10
  // bearer equals env.WRITE_TOKEN. owner = env.SHARE_NAMESPACE or the
7
11
  // path's first segment.
@@ -12,8 +16,14 @@
12
16
  // so one user cannot write another's prefix (403 namespace mismatch).
13
17
  // A __handles/<handle> claim binds the handle to the first userId that
14
18
  // writes it; a different userId gets 409 handle taken.
19
+ // 3. __share HMAC cookie — the signed-in viewer's identity cookie (same
20
+ // {userId,email} identityFromCookie verifies for GET). Lets the shared
21
+ // page's inline visibility control PATCH with credentials:'include' and
22
+ // no bearer; SameSite=Lax blocks it cross-site, and the same namespace/
23
+ // owner checks confine it to the holder's own pages. Applies to PUT,
24
+ // PATCH, and DELETE alike, since all three share authorizeWrite.
15
25
  // A managed deployment sets PHOENIX_ID_BASE; BYO sets WRITE_TOKEN; the
16
- // platform endpoint may set both. Fail loud (401) when neither authenticates.
26
+ // platform endpoint may set both. Fail loud (401) when none authenticates.
17
27
  // Writes the body to R2 via the BUCKET binding, storing visibility
18
28
  // (public|unlisted|me|org), owner, org_domain (org only), an optional
19
29
  // expires-at, plus provenance (agent/session/host/repo/date), a label, and
@@ -22,6 +32,13 @@
22
32
  // domain is 400. Overwriting an existing slug first copies the current
23
33
  // object to <slug>/rev-<ts>-<rand> (revision history) unless
24
34
  // x-share-no-revision is set.
35
+ // - PATCH /<username>/<slug> — authenticated metadata-only edit. Rewrites the
36
+ // exact existing body with all HTTP/custom metadata preserved except the
37
+ // explicitly requested label/arbitrary metadata changes; never revisions.
38
+ // Conditional put (onlyIf etagMatches) so a concurrent republish 409s
39
+ // instead of rolling the body back. Phoenix requires customMetadata.owner
40
+ // === auth.owner (fail closed when the stamp is missing); WRITE_TOKEN is
41
+ // the admin repair path.
25
42
  // - GET /<username>/<slug> — public|unlisted are anonymous; me requires the
26
43
  // Phoenix owner, org requires a same-domain Phoenix identity (Bearer, then
27
44
  // HMAC cookie, then phoenix_ticket). Unauthenticated me/org 302s to
@@ -54,15 +71,52 @@
54
71
  * until its owner re-runs `agents artifacts share update`. Cosmetic renames are not
55
72
  * worth that; change this text only alongside a real Worker behavior change.
56
73
  */
74
+ let bundledWorkerScript;
75
+ /** Bundle the Worker and its renderer into one uploadable ES module. */
57
76
  export function renderWorkerScript() {
58
- return `// GENERATED by agents-cli agents share setup — do not edit here; edit
77
+ if (bundledWorkerScript)
78
+ return bundledWorkerScript;
79
+ const result = buildSync({
80
+ stdin: {
81
+ contents: renderWorkerSource(),
82
+ loader: 'js',
83
+ resolveDir: dirname(fileURLToPath(import.meta.url)),
84
+ sourcefile: 'agents-share-worker.js',
85
+ },
86
+ bundle: true,
87
+ format: 'esm',
88
+ platform: 'browser',
89
+ target: 'es2022',
90
+ write: false,
91
+ minify: true,
92
+ loader: { '.wasm': 'binary', '.woff': 'binary' },
93
+ });
94
+ const output = result.outputFiles[0];
95
+ if (!output)
96
+ throw new Error('Worker bundling produced no JavaScript output.');
97
+ bundledWorkerScript = output.text;
98
+ return bundledWorkerScript;
99
+ }
100
+ /** Unbundled Worker source. Kept separate so esbuild can resolve npm modules. */
101
+ export function renderWorkerSource() {
102
+ return `import satori, { init as initSatori } from 'satori/wasm';
103
+ import initYoga from 'yoga-wasm-web';
104
+ import { Resvg, initWasm as initResvg } from '@resvg/resvg-wasm';
105
+ import yogaWasm from 'yoga-wasm-web/dist/yoga.wasm';
106
+ import resvgWasm from '@resvg/resvg-wasm/index_bg.wasm';
107
+ import interRegular from '@fontsource/inter/files/inter-latin-400-normal.woff';
108
+ import interBold from '@fontsource/inter/files/inter-latin-700-normal.woff';
109
+ import jetbrainsMono from '@fontsource/jetbrains-mono/files/jetbrains-mono-latin-600-normal.woff';
110
+
111
+ // GENERATED by agents-cli agents share setup — do not edit here; edit
59
112
  // src/lib/share/worker-template.ts and re-run setup.
60
113
  export const hooks = {
61
114
  verifyPhoenixToken: defaultVerifyPhoenixToken,
115
+ renderOgCard: renderOgCard,
62
116
  };
63
117
 
64
118
  export default {
65
- async fetch(request, env) {
119
+ async fetch(request, env, ctx) {
66
120
  const url = new URL(request.url);
67
121
  const path = decodeURIComponent(url.pathname.replace(/^\\/+/, ''));
68
122
 
@@ -77,6 +131,21 @@ export default {
77
131
  return new Response('not found', { status: 404, headers: { 'content-type': 'text/plain' } });
78
132
  }
79
133
 
134
+ // POST /__ticket — mint a short-lived, single-navigation login ticket for the
135
+ // authenticated owner (PHNX-3370). The CLI (which holds the Phoenix bearer)
136
+ // calls this, then opens the owner's page with ?phoenix_ticket=<t> so the
137
+ // browser trades it for the __share cookie and the inline visibility control
138
+ // goes live. The ticket is SELF-signed with the Worker's own HMAC secret — no
139
+ // external ticket service — and carries the SAME identity authorizeWrite
140
+ // already proved, so it grants nothing the caller didn't already have.
141
+ if (request.method === 'POST' && firstSeg === '__ticket') {
142
+ const auth = await authorizeWrite(request, env);
143
+ if (auth.error) return auth.error;
144
+ const ticket = await signSelfTicket({ userId: auth.owner, email: auth.email || '' }, env);
145
+ if (!ticket) return json({ error: 'ticket minting is not configured' }, 501);
146
+ return json({ ticket: ticket }, 200);
147
+ }
148
+
80
149
  if (request.method === 'PUT') {
81
150
  const auth = await authorizeWrite(request, env);
82
151
  if (auth.error) return auth.error;
@@ -119,21 +188,60 @@ export default {
119
188
  // never smuggle through on a publish that carries no agent/session/host/
120
189
  // repo/date at all — e.g. a human publishing outside an agent session, or
121
190
  // outside a git checkout.
122
- const agent = request.headers.get('x-share-agent') || '';
123
- const session = request.headers.get('x-share-session') || '';
124
- const host = request.headers.get('x-share-host') || '';
125
- const repo = request.headers.get('x-share-repo') || '';
126
- const date = request.headers.get('x-share-date') || '';
127
- const label = request.headers.get('x-share-label') || '';
191
+ // Full-Unicode headers (PHNX-2786): the CLI always sends the latin1-safe
192
+ // x-share-<field> (what a pre-Unicode Worker read), and when the fold was
193
+ // lossy ALSO a percent-encoded x-share-<field>-u companion plus an
194
+ // x-share-encoding: percent opt-in. Prefer the decoded companion when the
195
+ // opt-in is present, so a Japanese/emoji title/label/--meta renders in full
196
+ // instead of (unnamed) or a dropped glyph; a malformed companion falls
197
+ // back to the folded value rather than failing the publish.
198
+ const usePercent = (request.headers.get('x-share-encoding') || '') === 'percent';
199
+ const readText = (name) => {
200
+ const folded = request.headers.get('x-share-' + name) || '';
201
+ if (usePercent) {
202
+ const encoded = request.headers.get('x-share-' + name + '-u');
203
+ if (encoded) {
204
+ try {
205
+ return decodeURIComponent(encoded);
206
+ } catch (e) {
207
+ return folded;
208
+ }
209
+ }
210
+ }
211
+ return folded;
212
+ };
213
+ const agent = readText('agent');
214
+ const session = readText('session');
215
+ const host = readText('host');
216
+ const repo = readText('repo');
217
+ const date = readText('date');
218
+ const avatar = readText('avatar');
219
+ const label = readText('label');
220
+ const ogTitle = readText('og-title');
221
+ const ogDescription = readText('og-description');
128
222
  const labelSource = request.headers.get('x-share-label-source') || '';
129
223
  let extraMeta = {};
130
- const metaHeader = request.headers.get('x-share-meta');
131
- if (metaHeader) {
224
+ // The -u meta companion carries the whole raw object percent-encoded, so a
225
+ // single decode+parse recovers full-Unicode keys and values; fall back to
226
+ // the per-value-folded x-share-meta when it is absent or malformed.
227
+ const metaEncoded = usePercent ? request.headers.get('x-share-meta-u') : null;
228
+ if (metaEncoded) {
132
229
  try {
133
- const parsed = JSON.parse(metaHeader);
230
+ const parsed = JSON.parse(decodeURIComponent(metaEncoded));
134
231
  if (parsed && typeof parsed === 'object') extraMeta = parsed;
135
- } catch {
136
- // malformed --meta header — ignore rather than fail the whole publish
232
+ } catch (e) {
233
+ // malformed unicode meta companion — fall through to the folded header
234
+ }
235
+ }
236
+ if (!Object.keys(extraMeta).length) {
237
+ const metaHeader = request.headers.get('x-share-meta');
238
+ if (metaHeader) {
239
+ try {
240
+ const parsed = JSON.parse(metaHeader);
241
+ if (parsed && typeof parsed === 'object') extraMeta = parsed;
242
+ } catch {
243
+ // malformed --meta header — ignore rather than fail the whole publish
244
+ }
137
245
  }
138
246
  }
139
247
  const customMetadata = { ...extraMeta };
@@ -156,8 +264,11 @@ export default {
156
264
  if (host) customMetadata['host'] = host;
157
265
  if (repo) customMetadata['repo'] = repo;
158
266
  if (date) customMetadata['date'] = date;
267
+ if (avatar) customMetadata['avatar'] = avatar;
159
268
  if (label) customMetadata['label'] = label;
160
269
  if (labelSource) customMetadata['label-source'] = labelSource;
270
+ if (ogTitle) customMetadata['og-title'] = ogTitle;
271
+ if (ogDescription) customMetadata['og-description'] = ogDescription;
161
272
 
162
273
  // Revision retention (RUSH-2683): R2 has no native object versioning, so a
163
274
  // republish over an EXISTING key first copies the current object to
@@ -186,9 +297,114 @@ export default {
186
297
  httpMetadata: { contentType },
187
298
  customMetadata,
188
299
  });
300
+ // A managed republish may change the title/description. Invalidate only
301
+ // its generated sibling so the next crawler receives a fresh card; BYO
302
+ // publishes send no OG metadata and keep their explicitly uploaded cover.
303
+ if (ogTitle) await env.BUCKET.delete(path + '.png');
189
304
  return json({ ok: true, url: url.origin + '/' + path, expiresAt: expiresAt || null, unlisted: visibility === 'unlisted', visibility }, 200);
190
305
  }
191
306
 
307
+ if (request.method === 'PATCH') {
308
+ const auth = await authorizeWrite(request, env);
309
+ if (auth.error) return auth.error;
310
+ const segments = path.split('/').filter(Boolean);
311
+ if (segments.length < 2) return json({ error: 'metadata edit requires /<username>/<slug>' }, 400);
312
+ if (auth.kind === 'phoenix') {
313
+ const expected = phoenixHandle(auth);
314
+ if (!expected || segments[0] !== expected) return json({ error: 'namespace mismatch', owner: expected }, 403);
315
+ }
316
+ const existing = await env.BUCKET.get(path);
317
+ if (!existing) return json({ error: 'share not found', key: path }, 404);
318
+ const owner = existing.customMetadata && existing.customMetadata.owner;
319
+ // WRITE_TOKEN is the endpoint owner/admin credential (the same authority
320
+ // the DELETE path grants it). Phoenix must prove ownership — fail closed
321
+ // when the object has no owner stamp (handles collide after sanitization).
322
+ if (auth.kind === 'phoenix' && (!owner || owner !== auth.owner)) return json({ error: 'forbidden' }, 403);
323
+
324
+ let edit;
325
+ try { edit = await request.json(); } catch { return json({ error: 'PATCH body must be JSON' }, 400); }
326
+ if (!edit || typeof edit !== 'object') return json({ error: 'PATCH body must be an object' }, 400);
327
+ const metadata = { ...(existing.customMetadata || {}) };
328
+ const previousVisibility = metadata.visibility || 'public';
329
+ let visibilityChanged = false;
330
+ if (Object.prototype.hasOwnProperty.call(edit, 'label')) {
331
+ if (edit.label !== null && typeof edit.label !== 'string') return json({ error: 'label must be a string or null' }, 400);
332
+ 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);
333
+ if (edit.label === null) { delete metadata.label; delete metadata['label-source']; }
334
+ else { metadata.label = edit.label; metadata['label-source'] = 'explicit'; }
335
+ }
336
+ // Visibility is a first-class edit field (like label), not an arbitrary
337
+ // --meta entry — 'visibility' is a RESERVED key, so it can only be changed
338
+ // here, never smuggled through edit.meta. me/org require a Phoenix identity
339
+ // and org additionally requires a private (non-public-inbox) email domain,
340
+ // the SAME gate PUT enforces. This is a metadata-only rewrite: the body is
341
+ // untouched, so no revision is created (identical to a label/meta edit).
342
+ if (Object.prototype.hasOwnProperty.call(edit, 'visibility')) {
343
+ const vis = normalizeVisibility(edit.visibility);
344
+ if (vis.error) return vis.error;
345
+ const visibility = vis.value;
346
+ if (visibility === 'me' || visibility === 'org') {
347
+ const phoenixBase = typeof env.PHOENIX_ID_BASE === 'string' ? env.PHOENIX_ID_BASE.replace(/\\/+$/, '') : '';
348
+ if (auth.kind !== 'phoenix' || !phoenixBase) return json({ error: 'visibility me/org requires Phoenix identity' }, 400);
349
+ if (visibility === 'org') {
350
+ const domain = emailDomain(auth.email);
351
+ if (!domain) return json({ error: 'org visibility requires a verified email domain' }, 400);
352
+ if (PUBLIC_INBOX_DOMAINS.indexOf(domain) !== -1) return json({ error: 'org visibility cannot use a public email domain', domain: domain }, 400);
353
+ }
354
+ }
355
+ metadata.visibility = visibility;
356
+ visibilityChanged = visibility !== previousVisibility;
357
+ // org_domain is meaningful only for org — set it there, drop the stale
358
+ // value on any move away from org so it can never gate a later read.
359
+ if (visibility === 'org') metadata.org_domain = emailDomain(auth.email);
360
+ else delete metadata.org_domain;
361
+ }
362
+ const mode = edit.metaMode || 'merge';
363
+ if (mode !== 'merge' && mode !== 'replace') return json({ error: 'metaMode must be merge or replace' }, 400);
364
+ const incoming = edit.meta || {};
365
+ if (!incoming || typeof incoming !== 'object' || Array.isArray(incoming)) return json({ error: 'meta must be an object' }, 400);
366
+ for (const key in incoming) {
367
+ if (RESERVED_METADATA_KEYS.indexOf(key) !== -1) return json({ error: 'reserved metadata key', key: key }, 400);
368
+ if (!/^[a-z0-9-]{1,64}$/.test(key) || typeof incoming[key] !== 'string') return json({ error: 'invalid metadata entry', key: key }, 400);
369
+ }
370
+ if (mode === 'replace') {
371
+ for (const key in metadata) if (RESERVED_METADATA_KEYS.indexOf(key) === -1) delete metadata[key];
372
+ }
373
+ for (const key in incoming) metadata[key] = incoming[key];
374
+ const remove = edit.removeMeta || [];
375
+ if (!Array.isArray(remove)) return json({ error: 'removeMeta must be an array' }, 400);
376
+ for (const key of remove) {
377
+ 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);
378
+ delete metadata[key];
379
+ }
380
+ if (new TextEncoder().encode(JSON.stringify(extraMetaOf(metadata))).length > 2048) return json({ error: 'metadata exceeds 2048 bytes' }, 400);
381
+ // R2 assigns a fresh uploaded timestamp to every put. Preserve the
382
+ // canonical publication time before this metadata-only rewrite so gallery
383
+ // ordering and list JSON do not pretend the page was republished today.
384
+ if (!metadata['published-at']) metadata['published-at'] = new Date(existing.uploaded).toISOString();
385
+ const httpHeaders = new Headers();
386
+ if (typeof existing.writeHttpMetadata === 'function') existing.writeHttpMetadata(httpHeaders);
387
+ const putOpts = { httpMetadata: httpHeaders, customMetadata: metadata };
388
+ // onlyIf.etagMatches wants the bare hash (R2Object#etag), not the
389
+ // quoted HTTP header form (R2Object#httpEtag) — passing httpEtag 500s
390
+ // on a real Workers R2 binding ("Conditional ETag should not be
391
+ // wrapped in quotes"), a defect only a real R2 backend surfaces.
392
+ if (existing.etag) putOpts.onlyIf = { etagMatches: existing.etag };
393
+ const putResult = await env.BUCKET.put(path, existing.body, putOpts);
394
+ if (putResult === null) return json({ error: 'conflict', key: path }, 409);
395
+ // A generated cover copies the canonical visibility gate into its own R2
396
+ // metadata. Invalidate it after a successful visibility rewrite so the
397
+ // next cover GET re-gates and renders from the canonical page. Explicitly
398
+ // uploaded BYO siblings carry no marker and remain independent assets.
399
+ if (visibilityChanged) {
400
+ const cover = await env.BUCKET.get(path + '.png');
401
+ if (cover && cover.customMetadata && cover.customMetadata['og-generated'] === 'true') {
402
+ await env.BUCKET.delete(path + '.png');
403
+ }
404
+ }
405
+ return json({ ok: true, url: url.origin + '/' + path, label: metadata.label || null, meta: extraMetaOf(metadata), visibility: metadata.visibility || 'public', previousVisibility: previousVisibility }, 200);
406
+ }
407
+
192
408
  if (request.method === 'GET' || request.method === 'HEAD') {
193
409
  // Single-segment path may be a user gallery/listing OR a legacy flat slug.
194
410
  // The disambiguator is whether the <seg>/ prefix holds any object: only a
@@ -202,9 +418,12 @@ export default {
202
418
  const list = await env.BUCKET.list({ prefix: segments[0] + '/', limit: 1 });
203
419
  const isNamespace = list.objects && list.objects.length > 0;
204
420
  if (isNamespace) {
205
- return url.searchParams.get('format') === 'json'
206
- ? renderListing(env.BUCKET, url.origin, segments[0], request.method)
207
- : renderGallery(env.BUCKET, url.origin, segments[0], request.method);
421
+ if (url.searchParams.get('format') === 'json') {
422
+ const scope = await resolveListingScope(request, env, segments[0]);
423
+ if (scope.error) return scope.error;
424
+ return renderListing(env.BUCKET, url.origin, segments[0], request.method, scope.includeHidden);
425
+ }
426
+ return renderGallery(env.BUCKET, url.origin, segments[0], request.method);
208
427
  }
209
428
  // Not a namespace: fall through. A legacy flat-slug object resolves to its
210
429
  // real content; anything else 404s (an empty/nonexistent namespace, which
@@ -219,8 +438,15 @@ export default {
219
438
  // ?revisions=json must not leak that the page exists.
220
439
  if (segments.length === 2 && url.searchParams.get('revisions') === 'json') {
221
440
  const canonical = await env.BUCKET.get(path);
222
- if (canonical) {
223
- const denied = await gateRestrictedGet(request, env, url, canonical);
441
+ // Only a me/org canonical needs the viewer resolved — and only there does
442
+ // a phoenix_ticket failure gate the response, exactly as before this route
443
+ // shared resolveViewer with the page GET. A public/unlisted revision list
444
+ // never invoked ticket redemption, so a stale ticket must not 401 it.
445
+ if (canonical && isIdentityGated((canonical.customMetadata && canonical.customMetadata.visibility) || 'public')) {
446
+ const viewer = await resolveViewer(request, env, url);
447
+ if (viewer.redirect) return viewer.redirect;
448
+ if (viewer.error) return viewer.error;
449
+ const denied = gateVisibility(url, env, canonical, viewer.identity || null);
224
450
  if (denied) return denied;
225
451
  }
226
452
  return renderRevisions(
@@ -232,6 +458,70 @@ export default {
232
458
  );
233
459
  }
234
460
 
461
+ // Managed OG cover: generated siblings are caches, never visibility
462
+ // authorities. Gate every request against the current canonical page and
463
+ // bind each cached render to that page's etag. Re-read after rendering and
464
+ // after storing so a concurrent PATCH cannot publish a card from the old
465
+ // visibility/title/description snapshot. Explicit BYO siblings carry no
466
+ // generated marker and continue through the ordinary object route below.
467
+ if (segments.length === 2 && path.endsWith('.png')) {
468
+ let existingCover = await env.BUCKET.get(path);
469
+ if (!existingCover || (existingCover.customMetadata && existingCover.customMetadata['og-generated'] === 'true')) {
470
+ const pagePath = path.slice(0, -4);
471
+ const viewer = await resolveViewer(request, env, url);
472
+ if (viewer.redirect) return viewer.redirect;
473
+ for (let attempt = 0; attempt < 3; attempt += 1) {
474
+ const page = await env.BUCKET.get(pagePath);
475
+ if (!page) return new Response('not found', { status: 404, headers: { 'content-type': 'text/plain' } });
476
+ const pageVisibility = (page.customMetadata && page.customMetadata.visibility) || 'public';
477
+ if (viewer.error && isIdentityGated(pageVisibility)) return viewer.error;
478
+ const denied = gateVisibility(url, env, page, viewer.identity || null);
479
+ if (denied) return denied;
480
+
481
+ if (existingCover && existingCover.customMetadata['og-source-etag'] === page.etag) {
482
+ const current = await env.BUCKET.get(pagePath);
483
+ if (!current || current.etag !== page.etag) { existingCover = null; continue; }
484
+ const currentDenied = gateVisibility(url, env, current, viewer.identity || null);
485
+ if (currentDenied) return currentDenied;
486
+ return new Response(request.method === 'HEAD' ? null : existingCover.body, {
487
+ status: 200,
488
+ headers: managedCoverHeaders(pageVisibility),
489
+ });
490
+ }
491
+
492
+ const pageHtml = await page.text();
493
+ const meta = page.customMetadata || {};
494
+ const png = await hooks.renderOgCard({
495
+ title: meta['og-title'] || extractHtmlMeta(pageHtml, 'title') || meta.label || segments[1],
496
+ description: meta['og-description'] || extractHtmlMeta(pageHtml, 'description') || '',
497
+ handle: segments[0],
498
+ visibility: pageVisibility,
499
+ orgDomain: meta.org_domain || '',
500
+ });
501
+ const beforeStore = await env.BUCKET.get(pagePath);
502
+ if (!beforeStore || beforeStore.etag !== page.etag) { existingCover = null; continue; }
503
+ await env.BUCKET.put(path, png, {
504
+ httpMetadata: { contentType: 'image/png' },
505
+ customMetadata: { ...meta, 'og-generated': 'true', 'og-source-etag': page.etag },
506
+ });
507
+ const beforeServe = await env.BUCKET.get(pagePath);
508
+ if (!beforeServe || beforeServe.etag !== page.etag) {
509
+ const justStored = await env.BUCKET.get(path);
510
+ if (justStored && justStored.customMetadata && justStored.customMetadata['og-source-etag'] === page.etag) {
511
+ await env.BUCKET.delete(path);
512
+ }
513
+ existingCover = null;
514
+ continue;
515
+ }
516
+ return new Response(request.method === 'HEAD' ? null : png, {
517
+ status: 200,
518
+ headers: managedCoverHeaders(pageVisibility),
519
+ });
520
+ }
521
+ return new Response('cover changed during rendering; retry', { status: 503, headers: { 'content-type': 'text/plain', 'retry-after': '1' } });
522
+ }
523
+ }
524
+
235
525
  const obj = await env.BUCKET.get(path);
236
526
  if (!obj) return new Response('not found', { status: 404, headers: { 'content-type': 'text/plain' } });
237
527
  const expiresAt = obj.customMetadata && obj.customMetadata['expires-at'];
@@ -239,9 +529,24 @@ export default {
239
529
  await env.BUCKET.delete(path);
240
530
  return new Response('gone — this link has expired', { status: 410, headers: { 'content-type': 'text/plain' } });
241
531
  }
242
- const denied = await gateRestrictedGet(request, env, url, obj);
243
- if (denied) return denied;
532
+ // Resolve the viewer ONCE — needed both to gate me/org reads AND to decide
533
+ // whether THIS viewer OWNS this namespace (the interactive visibility
534
+ // control below). A successful phoenix_ticket redemption (redirect: set the
535
+ // identity cookie, strip the ticket) is honored on ANY page. But a ticket
536
+ // FAILURE (expired/consumed/unreachable) must only gate a page that needs
537
+ // identity — a public/unlisted page never invoked ticket redemption before
538
+ // this refactor, so a stale ticket there must serve anonymously, not 401.
244
539
  const visibility = (obj.customMetadata && obj.customMetadata.visibility) || 'public';
540
+ const viewer = await resolveViewer(request, env, url);
541
+ if (viewer.redirect) return viewer.redirect;
542
+ if (viewer.error && isIdentityGated(visibility)) return viewer.error;
543
+ const identity = viewer.identity || null;
544
+ const denied = gateVisibility(url, env, obj, identity);
545
+ if (denied) return denied;
546
+ // The owner of the namespace (their handle === the first path segment) gets
547
+ // an interactive visibility control; everyone else keeps the static cue.
548
+ const isOwner = !!identity && handleFromEmail(identity.email) === firstSeg;
549
+ const ownerDomain = identity ? emailDomain(identity.email) : '';
245
550
  const headers = new Headers();
246
551
  obj.writeHttpMetadata(headers);
247
552
  headers.set('etag', obj.httpEtag);
@@ -254,18 +559,58 @@ export default {
254
559
  if (visibility === 'unlisted') headers.set('X-Robots-Tag', 'noindex');
255
560
  }
256
561
  if (request.method === 'HEAD') return new Response(null, { status: 200, headers });
257
- // HTML pages get an attribution bar injected at serve time (who shared it,
258
- // what made it, when, and — the point — a visual VISIBILITY cue). All read
259
- // from the object's stamped metadata; non-HTML assets (images, JSON, the OG
260
- // cover) are served byte-for-byte. The body changes, so the R2 etag no
562
+ const ctype = headers.get('content-type') || '';
563
+ // HTML pages ALWAYS get the attribution bar injected at serve time (who
564
+ // shared it, what made it, when, and — the point — a visual VISIBILITY cue),
565
+ // all from stamped metadata. This is unconditional: ?raw does NOT strip the
566
+ // bar from a shared page (the always-on cue from #3122/#3140 is not
567
+ // defeatable with a query param). The body changes, so the R2 etag no
261
568
  // longer matches — drop it rather than serve a lying validator.
262
- const isHtmlPage = (headers.get('content-type') || '').indexOf('text/html') !== -1;
263
- if (isHtmlPage) {
569
+ if (ctype.indexOf('text/html') !== -1) {
264
570
  const rawHtml = await obj.text();
265
- const withBar = injectAttributionBar(rawHtml, obj.customMetadata || {}, firstSeg);
571
+ // Per-slug view counter (RUSH view stats). Stored as a SEPARATE R2 object
572
+ // (__views/<path>) so it never rewrites the page object — a rewrite would
573
+ // reset obj.uploaded and corrupt "last updated". The __-prefix key is GET-
574
+ // blocked for direct requests and lives outside every <user>/ list prefix,
575
+ // so it never leaks into the gallery/listing/revisions. Count only a real
576
+ // visitor's page view: not ?raw (an embed/OG fetch), not the owner's own
577
+ // view, and never HEAD (which returned above). The write rides
578
+ // ctx.waitUntil so it never blocks the response; the displayed count folds
579
+ // in the current view so the visitor sees themselves included.
580
+ const wantsRaw = url.searchParams.get('raw') != null;
581
+ const counting = !wantsRaw && !isOwner;
582
+ const storedViews = await readViews(env, path);
583
+ const views = storedViews + (counting ? 1 : 0);
584
+ if (counting && ctx && typeof ctx.waitUntil === 'function') {
585
+ ctx.waitUntil(writeViews(env, path, views));
586
+ }
587
+ const withBar = injectAttributionBar(rawHtml, obj.customMetadata || {}, firstSeg, {
588
+ isOwner,
589
+ ownerDomain,
590
+ views,
591
+ uploaded: obj.uploaded,
592
+ });
266
593
  headers.delete('etag');
267
594
  return new Response(withBar, { status: 200, headers });
268
595
  }
596
+ // Non-HTML asset. ?raw returns the stored bytes untouched — the escape hatch
597
+ // for <img src>, OG crawlers, iframes, and anyone embedding the asset (the
598
+ // viewer's own media element points back at ?raw). Otherwise a BROWSER
599
+ // navigating directly to a viewable asset (image/video/audio/pdf) gets a
600
+ // lightweight viewer page carrying the same bar; a non-browser fetch (Accept
601
+ // without text/html) falls through to raw bytes so embedding is never broken.
602
+ const wantsRaw = url.searchParams.get('raw') != null;
603
+ const kind = viewableAssetKind(ctype);
604
+ if (!wantsRaw && kind && acceptsHtml(request)) {
605
+ const viewerPage = renderAssetViewer(url.pathname, kind, obj.customMetadata || {}, firstSeg, {
606
+ isOwner,
607
+ ownerDomain,
608
+ });
609
+ const vheaders = new Headers(headers);
610
+ vheaders.set('content-type', 'text/html; charset=utf-8');
611
+ vheaders.delete('etag');
612
+ return new Response(viewerPage, { status: 200, headers: vheaders });
613
+ }
269
614
  return new Response(obj.body, { status: 200, headers });
270
615
  }
271
616
 
@@ -307,6 +652,10 @@ function isRevisionKey(key) {
307
652
  return key.split('/').length > 2;
308
653
  }
309
654
 
655
+ function publishedAtOf(object) {
656
+ return (object.customMetadata && object.customMetadata['published-at']) || object.uploaded;
657
+ }
658
+
310
659
  async function renderGallery(bucket, origin, user, method) {
311
660
  const objects = [];
312
661
  let cursor;
@@ -329,7 +678,7 @@ async function renderGallery(bucket, origin, user, method) {
329
678
  const url = origin + '/' + o.key;
330
679
  const label = (o.customMetadata && o.customMetadata['label']) || '';
331
680
  const agent = (o.customMetadata && o.customMetadata['agent']) || '';
332
- return { slug, url, updated: o.uploaded, label, agent };
681
+ return { slug, url, updated: publishedAtOf(o), label, agent };
333
682
  });
334
683
 
335
684
  if (method === 'HEAD') {
@@ -360,7 +709,7 @@ async function renderGallery(bucket, origin, user, method) {
360
709
  return new Response(html, { status: 200, headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'public, max-age=60' } });
361
710
  }
362
711
 
363
- async function renderListing(bucket, origin, user, method) {
712
+ async function renderListing(bucket, origin, user, method, includeHidden) {
364
713
  const objects = [];
365
714
  let cursor;
366
715
  do {
@@ -381,11 +730,11 @@ async function renderListing(bucket, origin, user, method) {
381
730
  const now = Date.now();
382
731
  const items = objects
383
732
  .filter(o => {
384
- // Mirror the gallery: hide revisions, sibling .png OG covers, expired
385
- // pages, and unlisted/me/org pages — the listing is the machine-readable public surface.
733
+ // Revisions and sibling .png OG covers are always hidden. Hidden-visibility
734
+ // pages are included ONLY when an authenticated owner requested scope=mine.
386
735
  if (isRevisionKey(o.key)) return false;
387
736
  if (o.key.endsWith('.png')) return false;
388
- if (isHiddenFromGallery(o.customMetadata && o.customMetadata.visibility)) return false;
737
+ if (isHiddenFromGallery(o.customMetadata && o.customMetadata.visibility) && !includeHidden) return false;
389
738
  const expiresAt = o.customMetadata && o.customMetadata['expires-at'];
390
739
  return !(expiresAt && now > Date.parse(expiresAt));
391
740
  })
@@ -394,9 +743,10 @@ async function renderListing(bucket, origin, user, method) {
394
743
  url: origin + '/' + o.key,
395
744
  size: typeof o.size === 'number' ? o.size : 0,
396
745
  contentType: (o.httpMetadata && o.httpMetadata.contentType) || null,
397
- publishedAt: new Date(o.uploaded).toISOString(),
746
+ publishedAt: new Date(publishedAtOf(o)).toISOString(),
398
747
  expiresAt: (o.customMetadata && o.customMetadata['expires-at']) || null,
399
748
  label: (o.customMetadata && o.customMetadata['label']) || null,
749
+ visibility: (o.customMetadata && o.customMetadata.visibility) || 'public',
400
750
  agent: (o.customMetadata && o.customMetadata['agent']) || null,
401
751
  session: (o.customMetadata && o.customMetadata['session']) || null,
402
752
  host: (o.customMetadata && o.customMetadata['host']) || null,
@@ -407,15 +757,39 @@ async function renderListing(bucket, origin, user, method) {
407
757
  // Newest first, so the human table and any script reads the freshest share top.
408
758
  items.sort((a, b) => (a.publishedAt < b.publishedAt ? 1 : a.publishedAt > b.publishedAt ? -1 : 0));
409
759
 
760
+ const cacheHeaders = includeHidden
761
+ ? { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'private, no-store', 'X-Robots-Tag': 'noindex' }
762
+ : { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'public, max-age=30' };
410
763
  if (method === 'HEAD') {
411
- return new Response(null, { status: 200, headers: { 'content-type': 'application/json; charset=utf-8' } });
764
+ return new Response(null, { status: 200, headers: cacheHeaders });
412
765
  }
413
766
  return new Response(JSON.stringify({ user, count: items.length, objects: items }), {
414
767
  status: 200,
415
- headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'public, max-age=30' },
768
+ headers: cacheHeaders,
416
769
  });
417
770
  }
418
771
 
772
+ // The machine-readable listing is public by default. A request with
773
+ // '?format=json&scope=mine' asks to include the owner's hidden pages
774
+ // (unlisted / me / org). We honor it ONLY after a valid owner bearer whose
775
+ // namespace matches the requested handle, so one user cannot list another's
776
+ // hidden shares. Anonymous or mismatched requests fail loud. A BYO WRITE_TOKEN
777
+ // bearer is the worker's owner, so namespace enforcement is skipped for BYO.
778
+ async function resolveListingScope(request, env, user) {
779
+ const scope = new URL(request.url).searchParams.get('scope');
780
+ if (scope !== 'mine') return { includeHidden: false };
781
+ const auth = await authorizeWrite(request, env);
782
+ if (auth.error) return { error: auth.error };
783
+ if (auth.kind === 'byo') {
784
+ return { includeHidden: true };
785
+ }
786
+ const owner = phoenixHandle(auth);
787
+ if (owner !== user) {
788
+ return { error: json({ error: 'namespace mismatch' }, 403) };
789
+ }
790
+ return { includeHidden: true };
791
+ }
792
+
419
793
  async function renderRevisions(bucket, origin, key, method, identityGated) {
420
794
  const objects = [];
421
795
  let cursor;
@@ -459,7 +833,7 @@ async function renderRevisions(bucket, origin, key, method, identityGated) {
459
833
  // before it ever reaches this Worker; see RESERVED_META_KEYS in publish.ts).
460
834
  // One list, reused both to strip a same-named --meta collision on write and
461
835
  // to split arbitrary --meta entries back out on read.
462
- var RESERVED_METADATA_KEYS = ['expires-at', 'visibility', 'owner', 'org_domain', 'agent', 'session', 'host', 'repo', 'date', 'label', 'label-source'];
836
+ var RESERVED_METADATA_KEYS = ['expires-at', 'published-at', 'visibility', 'owner', 'org_domain', 'agent', 'session', 'host', 'repo', 'date', 'avatar', 'label', 'label-source', 'og-title', 'og-description', 'og-generated', 'og-source-etag'];
463
837
  var PUBLIC_INBOX_DOMAINS = ['gmail.com', 'googlemail.com', 'outlook.com', 'hotmail.com', 'live.com', 'icloud.com', 'me.com'];
464
838
  var SHARE_COOKIE = '__Host-phoenix_share';
465
839
  var SHARE_COOKIE_MAX_AGE = 604800;
@@ -498,6 +872,76 @@ var VIS_ICON = {
498
872
  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>',
499
873
  };
500
874
 
875
+ // Eye glyph for the view-count stat; caret for the owner's interactive chip.
876
+ 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>';
877
+ 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>';
878
+
879
+ // The owner-only browser script that turns the visibility chip into a live
880
+ // control. Self-contained IIFE, namespaced under .agents-share-bar; injected only
881
+ // when the viewer owns the namespace. It reads the current level and each level's
882
+ // icon/color/label straight from the rendered menu DOM (no embedded config), then
883
+ // PATCHes the SAME in-place edit route the CLI uses — method PATCH, JSON body
884
+ // { visibility } — with credentials:'include' so the viewer's __share cookie /
885
+ // Phoenix identity rides. Optimistic: flip the chip + spinner, then a green check
886
+ // on 2xx or revert + the server's error text on failure (an org-on-public-inbox
887
+ // 400 surfaces verbatim). The share page sets no CSP, so inline JS is fine.
888
+ var ASH_OWNER_JS = \`(function(){
889
+ var root = document.querySelector('.agents-share-bar');
890
+ if(!root) return;
891
+ var chip = root.querySelector('[data-ash-chip]');
892
+ var menu = root.querySelector('[data-ash-menu]');
893
+ var toast = root.querySelector('[data-ash-toast]');
894
+ if(!chip || !menu || !toast) return;
895
+ var labelEl = chip.querySelector('[data-ash-label]');
896
+ var chipIc = chip.querySelector('[data-ash-chip-ic]');
897
+ var busy = false;
898
+ function opts(){ return menu.querySelectorAll('[data-ash-opt]'); }
899
+ function currentKey(){ var s = menu.querySelector('.ash-opt.ash-sel'); return s ? s.getAttribute('data-ash-opt') : ''; }
900
+ 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; }
901
+ function open(){ menu.classList.add('ash-open'); menu.setAttribute('aria-hidden','false'); }
902
+ function close(){ menu.classList.remove('ash-open'); menu.setAttribute('aria-hidden','true'); }
903
+ chip.addEventListener('click', function(e){ e.stopPropagation(); if(menu.classList.contains('ash-open')) close(); else open(); });
904
+ menu.addEventListener('click', function(e){ e.stopPropagation(); });
905
+ document.addEventListener('click', close);
906
+ document.addEventListener('keydown', function(e){ if(e.key==='Escape') close(); });
907
+ function draw(key){
908
+ var sel = null, a = opts();
909
+ 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]; }
910
+ if(!sel) return;
911
+ var ic = sel.querySelector('.ash-opt-ic');
912
+ var color = ic ? ic.style.color : '';
913
+ if(color){ chip.style.color = color; chip.style.borderColor = color + '66'; }
914
+ if(chipIc && ic) chipIc.innerHTML = ic.innerHTML;
915
+ var b = sel.querySelector('b');
916
+ if(labelEl && b) labelEl.textContent = b.textContent;
917
+ }
918
+ function say(inner, cls){ toast.innerHTML = inner; toast.className = 'ash-toast ash-show' + (cls ? ' ' + cls : ''); }
919
+ function fade(ms){ setTimeout(function(){ toast.className = 'ash-toast'; }, ms); }
920
+ function pick(key){
921
+ close();
922
+ var prev = currentKey();
923
+ if(busy || !key || key===prev) return;
924
+ busy = true;
925
+ draw(key);
926
+ var name = labelFor(key);
927
+ say('<span class="ash-spin"></span> Saving…');
928
+ fetch(location.pathname, { method:'PATCH', credentials:'include', headers:{'content-type':'application/json'}, body: JSON.stringify({ visibility: key }) })
929
+ .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: {} }; }); })
930
+ .then(function(r){
931
+ busy = false;
932
+ if(r.ok){ say('<span class="ash-ok">\\u2713</span> Now ' + name, 'ash-good'); fade(1600); }
933
+ else {
934
+ draw(prev);
935
+ var msg = (r.body && r.body.error) ? String(r.body.error) : ('Could not update (HTTP ' + r.status + ')');
936
+ say('<span class="ash-err">\\u2715</span> ' + msg.replace(/[<>&]/g, ' '), 'ash-bad');
937
+ fade(4000);
938
+ }
939
+ }, function(){ busy = false; draw(prev); say('<span class="ash-err">\\u2715</span> Network error \\u2014 not saved', 'ash-bad'); fade(4000); });
940
+ }
941
+ var a = opts();
942
+ 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]); }
943
+ })();\`;
944
+
501
945
  function visibilityChip(visibility, orgDomain) {
502
946
  if (visibility === 'me') return { icon: VIS_ICON.me, label: 'Only you', color: '#f59e0b' };
503
947
  if (visibility === 'org') return { icon: VIS_ICON.org, label: 'Anyone at ' + escapeHtml(orgDomain || 'your organization'), color: '#5b9dff' };
@@ -505,14 +949,117 @@ function visibilityChip(visibility, orgDomain) {
505
949
  return { icon: VIS_ICON.public, label: 'Public', color: '#30a46c' };
506
950
  }
507
951
 
508
- function renderAttributionBar(meta, handle) {
952
+ // A deterministic hue (0-359) from the handle, so a given sharer always gets the
953
+ // same initials-circle colour without any stored palette.
954
+ function avatarHue(s) {
955
+ var h = 0;
956
+ for (var i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) % 360;
957
+ return h;
958
+ }
959
+
960
+ // The avatar slot is ALWAYS rendered: a coloured initials circle derived from the
961
+ // handle. When the object carries an avatar URL (a Gravatar the CLI stamps at
962
+ // publish, or any future hosted photo), a real img is layered on top; onerror
963
+ // removes it so the initials underneath show through when the photo 404s. There
964
+ // is no CSP on the share page, so the inline onerror fallback is honoured.
965
+ function renderAvatar(handle, avatarUrl) {
966
+ var h = (handle || '').trim();
967
+ var initial = escapeHtml((h.charAt(0) || '?').toUpperCase());
968
+ var bg = 'hsl(' + avatarHue(h) + ',48%,42%)';
969
+ var inner = '<span class="ash-av-i">' + initial + '</span>';
970
+ if (avatarUrl) {
971
+ inner += '<img class="ash-av-img" src="' + escapeHtml(avatarUrl) + '" alt="" loading="lazy" ' +
972
+ 'referrerpolicy="no-referrer" onerror="this.remove()">';
973
+ }
974
+ return '<span class="ash-avatar" style="background:' + bg + ' !important" aria-hidden="true">' + inner + '</span>';
975
+ }
976
+
977
+ // A compact relative time for the "last updated" stat: "just now", "2h ago",
978
+ // "3d ago", or an ISO date once past ~30 days. Accepts a Date or a parseable
979
+ // string (R2's obj.uploaded is a Date).
980
+ function relativeTime(when) {
981
+ var then = when instanceof Date ? when.getTime() : Date.parse(when);
982
+ if (!then || isNaN(then)) return '';
983
+ var diff = Date.now() - then;
984
+ if (diff < 0) diff = 0;
985
+ var minute = 60000, hour = 3600000, day = 86400000;
986
+ if (diff < minute) return 'just now';
987
+ if (diff < hour) return Math.floor(diff / minute) + 'm ago';
988
+ if (diff < day) return Math.floor(diff / hour) + 'h ago';
989
+ if (diff < 30 * day) return Math.floor(diff / day) + 'd ago';
990
+ return new Date(then).toISOString().slice(0, 10);
991
+ }
992
+
993
+ // The four visibility levels the owner can pick, in menu order. Descriptions
994
+ // mirror the approved mockup. org always appears — a public-inbox owner still
995
+ // sees it and lets the server's 400 drive the failure message (never a hidden
996
+ // client-side rule). orgDomain is the owner's own email domain when known.
997
+ function visibilityLevels(orgDomain) {
998
+ return [
999
+ { key: 'public', label: 'Public', color: '#30a46c', icon: VIS_ICON.public, desc: 'Anyone with the link · preview card · listed in your gallery' },
1000
+ { key: 'unlisted', label: 'Unlisted', color: '#9aa0a6', icon: VIS_ICON.unlisted, desc: 'Anyone with the link · no card · hidden from your gallery' },
1001
+ { key: 'me', label: 'Only you', color: '#f59e0b', icon: VIS_ICON.me, desc: 'Sign-in required · only your account can open it' },
1002
+ { 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' },
1003
+ ];
1004
+ }
1005
+
1006
+ function renderVisibilityMenu(current, orgDomain) {
1007
+ var rows = visibilityLevels(orgDomain).map(function (l) {
1008
+ 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') + '">' +
1009
+ '<span class="ash-opt-ic" style="color:' + l.color + '">' + l.icon + '</span>' +
1010
+ '<span class="ash-opt-t"><b>' + l.label + '</b><span>' + l.desc + '</span></span>' +
1011
+ '<span class="ash-rd"></span></button>';
1012
+ }).join('');
1013
+ return '<div class="ash-menu" data-ash-menu role="menu" aria-hidden="true"><h4>Who can see this</h4>' + rows + '</div>';
1014
+ }
1015
+
1016
+ function renderOwnerScript() {
1017
+ return '<script>' + ASH_OWNER_JS + '</script>';
1018
+ }
1019
+
1020
+ // opts: { isOwner, ownerDomain, views, uploaded } — all optional. isOwner turns
1021
+ // the visibility chip into a live control (inline dropdown, Variation A) and
1022
+ // injects the owner-only CSS/JS; views/uploaded render the right-side stats
1023
+ // cluster. With no opts the bar is exactly today's static strip.
1024
+ function renderAttributionBar(meta, handle, opts) {
1025
+ var o = opts || {};
509
1026
  var cm = meta || {};
510
- var chip = visibilityChip(cm.visibility || 'public', cm.org_domain);
1027
+ var visibility = cm.visibility || 'public';
1028
+ var chip = visibilityChip(visibility, cm.org_domain);
1029
+ var avatar = renderAvatar(handle, cm.avatar);
511
1030
  var left = '';
512
1031
  // The handle is already the public URL namespace, so surfacing it leaks nothing new.
513
1032
  if (handle) left += 'Shared by <strong>' + escapeHtml(handle) + '</strong>';
514
1033
  if (cm.agent) left += (left ? '<span class="ash-dot">·</span>' : '') + 'Made with ' + escapeHtml(cm.agent);
515
- var right = '<span class="ash-chip" style="color:' + chip.color + ';border-color:' + chip.color + '66">' + chip.icon + '<span>' + chip.label + '</span></span>';
1034
+
1035
+ // Stats cluster (👁 <n> views · updated <rel>) — right side, left of the chip.
1036
+ var stats = '';
1037
+ var statBits = '';
1038
+ if (o.views != null) {
1039
+ statBits += STAT_EYE_ICON + '<b>' + escapeHtml(String(o.views)) + '</b> ' + (Number(o.views) === 1 ? 'view' : 'views');
1040
+ }
1041
+ if (o.uploaded) {
1042
+ var rel = relativeTime(o.uploaded);
1043
+ if (rel) statBits += (statBits ? '<span class="ash-statsep">·</span>' : '') + 'updated <b>' + escapeHtml(rel) + '</b>';
1044
+ }
1045
+ if (statBits) stats = '<span class="ash-stats" title="page stats">' + statBits + '</span>';
1046
+
1047
+ // The visibility chip: an interactive control for the owner, a static cue for
1048
+ // everyone else (unchanged from today).
1049
+ var chipHtml;
1050
+ var menu = '';
1051
+ var script = '';
1052
+ if (o.isOwner) {
1053
+ chipHtml = '<span class="ash-chip ash-chip-own" data-ash-chip tabindex="0" role="button" aria-haspopup="menu" ' +
1054
+ 'style="color:' + chip.color + ';border-color:' + chip.color + '66">' +
1055
+ '<span data-ash-chip-ic>' + chip.icon + '</span><span data-ash-label>' + chip.label + '</span>' + CARET_ICON + '</span>';
1056
+ menu = renderVisibilityMenu(visibility, o.ownerDomain);
1057
+ script = renderOwnerScript();
1058
+ } else {
1059
+ chipHtml = '<span class="ash-chip" style="color:' + chip.color + ';border-color:' + chip.color + '66">' + chip.icon + '<span>' + chip.label + '</span></span>';
1060
+ }
1061
+
1062
+ var right = stats + chipHtml;
516
1063
  if (cm.date) right += '<span class="ash-date">' + escapeHtml(cm.date) + '</span>';
517
1064
  // The load-bearing background + base colour ride an INLINE style so the host
518
1065
  // page's own CSS can never wash the bar out (inline beats a page stylesheet);
@@ -530,6 +1077,9 @@ function renderAttributionBar(meta, handle) {
530
1077
  '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;' +
531
1078
  'font:13px/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,system-ui,sans-serif !important}' +
532
1079
  '.agents-share-bar *{box-sizing:border-box;font-family:inherit}' +
1080
+ '.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)}' +
1081
+ '.agents-share-bar .ash-av-i{color:#fff !important}' +
1082
+ '.agents-share-bar .ash-av-img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;border-radius:50% !important}' +
533
1083
  '.agents-share-bar .ash-left{flex:1 1 auto;min-width:0;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;color:#cfd3d9 !important}' +
534
1084
  '.agents-share-bar strong{color:#fff !important;font-weight:600}' +
535
1085
  '.agents-share-bar .ash-dot{opacity:.4;margin:0 4px}' +
@@ -537,14 +1087,48 @@ function renderAttributionBar(meta, handle) {
537
1087
  '.agents-share-bar .ash-date{color:#9096a0 !important}' +
538
1088
  '.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}' +
539
1089
  '.agents-share-bar .ash-chip svg{flex:none;vertical-align:middle}' +
1090
+ '.agents-share-bar .ash-stats{display:inline-flex;align-items:center;gap:5px;color:#9aa0a6 !important;font-size:12px;white-space:nowrap}' +
1091
+ '.agents-share-bar .ash-stats b{color:#c9ced6 !important;font-weight:600}' +
1092
+ '.agents-share-bar .ash-stats svg{flex:none;opacity:.85}' +
1093
+ '.agents-share-bar .ash-statsep{opacity:.35;margin:0 3px}' +
1094
+ (o.isOwner ? OWNER_BAR_CSS : '') +
540
1095
  '</style>' +
1096
+ avatar +
541
1097
  '<span class="ash-left">' + left + '</span>' +
542
1098
  '<span class="ash-right">' + right + '</span>' +
543
- '</div>';
1099
+ menu +
1100
+ (o.isOwner ? '<div class="ash-toast" data-ash-toast></div>' : '') +
1101
+ '</div>' +
1102
+ script;
544
1103
  }
545
1104
 
546
- function injectAttributionBar(html, meta, handle) {
547
- var bar = renderAttributionBar(meta, handle);
1105
+ // Owner-only chip/menu/toast styling, added to the bar's <style> only when the
1106
+ // viewer owns the namespace. Namespaced under .agents-share-bar with !important
1107
+ // so the host page's CSS can't wash it out; z-index rides the bar's own stacking.
1108
+ var OWNER_BAR_CSS =
1109
+ '.agents-share-bar .ash-chip-own{cursor:pointer;transition:background .12s}' +
1110
+ '.agents-share-bar .ash-chip-own:hover{background:rgba(255,255,255,.06)}' +
1111
+ '.agents-share-bar .ash-caret{opacity:.6;margin-left:1px;flex:none}' +
1112
+ '.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}' +
1113
+ '.agents-share-bar .ash-menu.ash-open{display:block}' +
1114
+ '.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}' +
1115
+ '.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}' +
1116
+ '.agents-share-bar .ash-opt:hover{background:rgba(255,255,255,.05)}' +
1117
+ '.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)}' +
1118
+ '.agents-share-bar .ash-opt-t{flex:1;min-width:0}' +
1119
+ '.agents-share-bar .ash-opt-t b{display:block;font-size:13px;font-weight:600;color:#f0f1f3 !important}' +
1120
+ '.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}' +
1121
+ '.agents-share-bar .ash-rd{flex:none;width:16px;height:16px;border-radius:50%;border:2px solid #4a4f59;margin-top:3px}' +
1122
+ '.agents-share-bar .ash-opt.ash-sel .ash-rd{border-color:#5b9dff;background:radial-gradient(circle at center,#5b9dff 0 5px,transparent 6px)}' +
1123
+ '.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}' +
1124
+ '.agents-share-bar .ash-toast.ash-show{display:inline-flex}' +
1125
+ '.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}' +
1126
+ '@keyframes ash-sp{to{transform:rotate(360deg)}}' +
1127
+ '.agents-share-bar .ash-ok{color:#30a46c;font-weight:700}' +
1128
+ '.agents-share-bar .ash-err{color:#ff6b6b;font-weight:700}';
1129
+
1130
+ function injectAttributionBar(html, meta, handle, opts) {
1131
+ var bar = renderAttributionBar(meta, handle, opts);
548
1132
  var m = /<body[^>]*>/i.exec(html);
549
1133
  if (m) {
550
1134
  var at = m.index + m[0].length;
@@ -553,6 +1137,47 @@ function injectAttributionBar(html, meta, handle) {
553
1137
  return bar + html;
554
1138
  }
555
1139
 
1140
+ function acceptsHtml(request) {
1141
+ return (request.headers.get('accept') || '').indexOf('text/html') !== -1;
1142
+ }
1143
+
1144
+ // Assets we can embed in a viewer page. Everything else (JSON, .txt, arbitrary
1145
+ // downloads) is served raw — there is nothing useful to wrap it in.
1146
+ function viewableAssetKind(contentType) {
1147
+ var ct = (contentType || '').toLowerCase();
1148
+ if (ct.indexOf('image/') === 0) return 'image';
1149
+ if (ct.indexOf('video/') === 0) return 'video';
1150
+ if (ct.indexOf('audio/') === 0) return 'audio';
1151
+ if (ct.indexOf('application/pdf') === 0) return 'pdf';
1152
+ return '';
1153
+ }
1154
+
1155
+ // A minimal dark viewer page: the attribution bar on top, the asset centered
1156
+ // below. The media element points back at THIS url with ?raw so it loads the
1157
+ // stored bytes (not the viewer recursively) — and that ?raw fetch re-runs the
1158
+ // same me/org gate, so a private asset stays private inside its own viewer.
1159
+ function renderAssetViewer(pathname, kind, meta, handle, opts) {
1160
+ var rawUrl = escapeHtml(pathname + '?raw=1');
1161
+ var name = '';
1162
+ try { name = decodeURIComponent(pathname.split('/').pop() || 'file'); } catch (e) { name = pathname.split('/').pop() || 'file'; }
1163
+ var bar = renderAttributionBar(meta, handle, opts);
1164
+ var media = '';
1165
+ if (kind === 'image') media = '<img src="' + rawUrl + '" alt="' + escapeHtml(name) + '">';
1166
+ else if (kind === 'video') media = '<video src="' + rawUrl + '" controls playsinline></video>';
1167
+ else if (kind === 'audio') media = '<audio src="' + rawUrl + '" controls></audio>';
1168
+ else if (kind === 'pdf') media = '<iframe src="' + rawUrl + '" title="' + escapeHtml(name) + '"></iframe>';
1169
+ return '<!doctype html><html><head><meta charset="utf-8">' +
1170
+ '<meta name="viewport" content="width=device-width,initial-scale=1">' +
1171
+ '<title>' + escapeHtml(name) + '</title>' +
1172
+ '<style>html,body{margin:0;background:#0f0f11}' +
1173
+ 'body{min-height:100vh;display:flex;align-items:center;justify-content:center;padding:16px;box-sizing:border-box}' +
1174
+ '.ashv{max-width:100%;max-height:calc(100vh - 90px);display:flex;align-items:center;justify-content:center}' +
1175
+ '.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)}' +
1176
+ '.ashv audio{width:min(90vw,560px)}' +
1177
+ '.ashv iframe{width:min(94vw,1000px);height:calc(100vh - 90px);border:0;border-radius:8px;background:#fff}' +
1178
+ '</style></head><body>' + bar + '<div class="ashv">' + media + '</div></body></html>';
1179
+ }
1180
+
556
1181
  function json(body, status) {
557
1182
  return new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
558
1183
  }
@@ -654,19 +1279,59 @@ function isIdentityGated(visibility) {
654
1279
  return visibility === 'me' || visibility === 'org';
655
1280
  }
656
1281
 
657
- async function gateRestrictedGet(request, env, url, obj) {
1282
+ function managedCoverHeaders(visibility) {
1283
+ const headers = new Headers({ 'content-type': 'image/png' });
1284
+ if (isIdentityGated(visibility)) {
1285
+ headers.set('cache-control', 'private, no-store');
1286
+ headers.set('X-Robots-Tag', 'noindex');
1287
+ } else {
1288
+ headers.set('cache-control', 'public, max-age=31536000, immutable');
1289
+ if (visibility === 'unlisted') headers.set('X-Robots-Tag', 'noindex');
1290
+ }
1291
+ return headers;
1292
+ }
1293
+
1294
+ // Gate a me/org read given the ALREADY-RESOLVED viewer identity. Pure/sync: the
1295
+ // caller resolves the viewer once (it also needs the identity for the ownership
1296
+ // check) and both the page GET and the ?revisions=json path share this gate.
1297
+ function gateVisibility(url, env, obj, identity) {
658
1298
  const visibility = (obj.customMetadata && obj.customMetadata.visibility) || 'public';
659
1299
  if (!isIdentityGated(visibility)) return null;
660
- const viewer = await resolveViewer(request, env, url);
661
- if (viewer.redirect) return viewer.redirect;
662
- if (viewer.error) return viewer.error;
663
- if (!viewer.identity) return bounceToLogin(url, env);
664
- if (!viewerMayRead(visibility, obj.customMetadata, viewer.identity)) {
1300
+ if (!identity) return bounceToLogin(url, env);
1301
+ if (!viewerMayRead(visibility, obj.customMetadata, identity)) {
665
1302
  return new Response('not found', { status: 404, headers: { 'content-type': 'text/plain' } });
666
1303
  }
667
1304
  return null;
668
1305
  }
669
1306
 
1307
+ // Per-slug view counter, stored as a SEPARATE R2 object under __views/<path> so
1308
+ // counting a view never rewrites the page object (which would reset its uploaded
1309
+ // timestamp and corrupt "last updated"). Best-effort telemetry: a read/write
1310
+ // failure must never break page serving, so both degrade to a no-op rather than
1311
+ // throwing. R2 has no atomic increment, so two simultaneous views can race and
1312
+ // lose a count — acceptable for an approximate visitor count.
1313
+ async function readViews(env, path) {
1314
+ try {
1315
+ const obj = await env.BUCKET.get('__views/' + path);
1316
+ if (!obj) return 0;
1317
+ const raw = typeof obj.text === 'function' ? await obj.text() : '';
1318
+ const n = parseInt(raw, 10);
1319
+ return Number.isFinite(n) && n >= 0 ? n : 0;
1320
+ } catch {
1321
+ return 0;
1322
+ }
1323
+ }
1324
+
1325
+ async function writeViews(env, path, count) {
1326
+ try {
1327
+ await env.BUCKET.put('__views/' + path, String(count), {
1328
+ httpMetadata: { contentType: 'text/plain; charset=utf-8' },
1329
+ });
1330
+ } catch {
1331
+ // best-effort: a failed counter write must never surface to the visitor
1332
+ }
1333
+ }
1334
+
670
1335
  function viewerMayRead(visibility, meta, identity) {
671
1336
  if (visibility === 'me') {
672
1337
  const owner = meta && meta.owner;
@@ -743,6 +1408,66 @@ async function signShareCookie(identity, env) {
743
1408
  return SHARE_COOKIE + '=' + value + '; HttpOnly; Secure; Path=/; SameSite=Lax; Max-Age=' + SHARE_COOKIE_MAX_AGE;
744
1409
  }
745
1410
 
1411
+ // A short-lived login ticket, self-signed with the same HMAC secret as the cookie
1412
+ // but DOMAIN-SEPARATED (the signature covers a 'ticket:'-prefixed payload), so a
1413
+ // ticket value can never be replayed as a cookie value or vice versa. It rides in
1414
+ // the ?phoenix_ticket= param for exactly one navigation; resolveViewer verifies it
1415
+ // locally and exchanges it for the __share cookie. TTL is deliberately tiny — it
1416
+ // only has to survive the round-trip from CLI mint to the browser opening the URL.
1417
+ var SELF_TICKET_MAX_AGE = 120;
1418
+
1419
+ async function signSelfTicket(identity, env) {
1420
+ const secret = typeof env.WRITE_TOKEN === 'string' ? env.WRITE_TOKEN : '';
1421
+ if (!secret || !identity || !identity.userId) return '';
1422
+ const exp = Math.floor(Date.now() / 1000) + SELF_TICKET_MAX_AGE;
1423
+ const payload = identity.userId + '|' + (identity.email || '') + '|' + exp;
1424
+ const sig = await hmacHex(secret, 'ticket:' + payload);
1425
+ return toB64Url(payload) + '.' + sig;
1426
+ }
1427
+
1428
+ async function verifySelfTicket(ticket, env) {
1429
+ const secret = typeof env.WRITE_TOKEN === 'string' ? env.WRITE_TOKEN : '';
1430
+ if (!secret || !ticket) return null;
1431
+ const dot = ticket.lastIndexOf('.');
1432
+ if (dot < 1) return null;
1433
+ let payload;
1434
+ try {
1435
+ payload = fromB64Url(ticket.slice(0, dot));
1436
+ } catch {
1437
+ return null;
1438
+ }
1439
+ const sig = ticket.slice(dot + 1);
1440
+ const expected = await hmacHex(secret, 'ticket:' + payload);
1441
+ if (!safeEqual(sig, expected)) return null;
1442
+ const first = payload.indexOf('|');
1443
+ const last = payload.lastIndexOf('|');
1444
+ if (first < 0 || last <= first) return null;
1445
+ const userId = payload.slice(0, first);
1446
+ const email = payload.slice(first + 1, last);
1447
+ const exp = Number(payload.slice(last + 1));
1448
+ if (!userId || !Number.isFinite(exp) || Math.floor(Date.now() / 1000) > exp) return null;
1449
+ return { userId: userId, email: email };
1450
+ }
1451
+
1452
+ // Single-use enforcement for a self-signed ticket: claim its signature the first
1453
+ // time it is redeemed, and reject any later presentation of the same ticket. The
1454
+ // HMAC + short TTL stop forgery and bound the window, but without this a ticket the
1455
+ // CLI printed (--json / --no-open / a browser-open failure) could be replayed by
1456
+ // anyone who captured that output before it expired, minting the owner's 7-day
1457
+ // cookie a second time. Get-then-put mirrors claimHandle's one-time-claim pattern;
1458
+ // the marker rides a __-prefixed key (GET-blocked, excluded from every listing).
1459
+ async function consumeSelfTicket(env, sig) {
1460
+ if (!sig) return false;
1461
+ const key = '__ticket-used/' + sig;
1462
+ const existing = await env.BUCKET.get(key);
1463
+ if (existing) return false;
1464
+ await env.BUCKET.put(key, '1', {
1465
+ httpMetadata: { contentType: 'text/plain' },
1466
+ customMetadata: { visibility: 'unlisted' },
1467
+ });
1468
+ return true;
1469
+ }
1470
+
746
1471
  async function identityFromCookie(request, env) {
747
1472
  const secret = typeof env.WRITE_TOKEN === 'string' ? env.WRITE_TOKEN : '';
748
1473
  if (!secret) return null;
@@ -802,7 +1527,17 @@ async function resolveViewer(request, env, url) {
802
1527
  if (cookieId) return { identity: cookieId };
803
1528
  const ticket = url.searchParams.get('phoenix_ticket');
804
1529
  if (!ticket) return {};
805
- const redeemed = await redeemTicket(ticket, env);
1530
+ // Prefer the locally-verifiable self-signed ticket (PHNX-3370, no network), and
1531
+ // enforce single use by claiming its signature on first redemption — a captured
1532
+ // ticket replayed within its TTL finds it already spent. Fall back to the
1533
+ // external identity-server redeem for tickets it issued.
1534
+ let redeemed = await verifySelfTicket(ticket, env);
1535
+ if (redeemed) {
1536
+ const sig = ticket.slice(ticket.lastIndexOf('.') + 1);
1537
+ if (!(await consumeSelfTicket(env, sig))) redeemed = null;
1538
+ } else {
1539
+ redeemed = await redeemTicket(ticket, env);
1540
+ }
806
1541
  if (!redeemed) return { error: json({ error: 'invalid ticket' }, 401) };
807
1542
  const cookie = await signShareCookie(redeemed, env);
808
1543
  if (!cookie) return { error: json({ error: 'phoenix login is not configured' }, 401) };
@@ -813,6 +1548,61 @@ async function resolveViewer(request, env, url) {
813
1548
  return { redirect: new Response(null, { status: 302, headers: headers }) };
814
1549
  }
815
1550
 
1551
+ function extractHtmlMeta(html, field) {
1552
+ const pattern = field === 'title'
1553
+ ? /<title[^>]*>([\\s\\S]*?)<\\/title>/i
1554
+ : /<meta[^>]+(?:name|property)=["'](?:description|og:description)["'][^>]+content=["']([^"']*)["'][^>]*>/i;
1555
+ const match = pattern.exec(html);
1556
+ return match ? match[1].replace(/<[^>]*>/g, '').replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').trim() : '';
1557
+ }
1558
+
1559
+ let rendererReady;
1560
+ async function renderOgCard(input) {
1561
+ if (!rendererReady) {
1562
+ rendererReady = Promise.all([
1563
+ initYoga(yogaWasm).then(function (yoga) { initSatori(yoga); }),
1564
+ initResvg(resvgWasm),
1565
+ ]);
1566
+ }
1567
+ await rendererReady;
1568
+ const visibilityLabels = {
1569
+ public: 'PUBLIC',
1570
+ unlisted: 'UNLISTED',
1571
+ me: 'ONLY YOU',
1572
+ org: input.orgDomain ? 'ANYONE AT ' + input.orgDomain.toUpperCase() : 'ORGANIZATION',
1573
+ };
1574
+ const node = {
1575
+ type: 'div',
1576
+ props: {
1577
+ style: { width: '100%', height: '100%', display: 'flex', flexDirection: 'column', background: '#0a0a0a', color: '#f5f5f5', padding: '68px 76px 58px', fontFamily: 'Inter' },
1578
+ children: [
1579
+ { type: 'div', props: { style: { display: 'flex', color: '#a3e635', fontFamily: 'JetBrains Mono', fontSize: 25, fontWeight: 600, letterSpacing: '-0.5px' }, children: 'AGI · agents-cli.sh' } },
1580
+ { type: 'div', props: { style: { display: 'flex', flexDirection: 'column', flexGrow: 1, justifyContent: 'center', maxWidth: 1050 }, children: [
1581
+ { 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' } },
1582
+ 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,
1583
+ ].filter(Boolean) } },
1584
+ { type: 'div', props: { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', borderTop: '1px solid #262626', paddingTop: 25, fontSize: 21, color: '#a3a3a3' }, children: [
1585
+ { type: 'div', props: { style: { display: 'flex' }, children: [
1586
+ { type: 'span', props: { style: { color: '#737373' }, children: 'shared by ' } },
1587
+ { type: 'span', props: { style: { marginLeft: 7, color: '#f5f5f5', fontWeight: 700 }, children: '@' + input.handle } },
1588
+ ] } },
1589
+ { 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' } },
1590
+ ] } },
1591
+ ],
1592
+ },
1593
+ };
1594
+ const svg = await satori(node, {
1595
+ width: 1200,
1596
+ height: 630,
1597
+ fonts: [
1598
+ { name: 'Inter', data: interRegular, weight: 400, style: 'normal' },
1599
+ { name: 'Inter', data: interBold, weight: 700, style: 'normal' },
1600
+ { name: 'JetBrains Mono', data: jetbrainsMono, weight: 600, style: 'normal' },
1601
+ ],
1602
+ });
1603
+ return new Resvg(svg, { fitTo: { mode: 'width', value: 1200 } }).render().asPng();
1604
+ }
1605
+
816
1606
  async function defaultVerifyPhoenixToken(request, env) {
817
1607
  const presented = (request.headers.get('authorization') || '').replace(/^Bearer\\s+/i, '');
818
1608
  if (!presented) return null;
@@ -838,17 +1628,28 @@ async function defaultVerifyPhoenixToken(request, env) {
838
1628
  return { userId: body.userId, email: typeof body.email === 'string' ? body.email : '' };
839
1629
  }
840
1630
 
841
- // Two legitimate principals: WRITE_TOKEN equality first (admin/BYO), otherwise
842
- // the bearer is a Phoenix token and we verify it. 401 when neither authenticates.
1631
+ // Write principals, in order: WRITE_TOKEN equality (admin/BYO), then a Phoenix
1632
+ // bearer, then the signed-in viewer's __share cookie. The cookie is an
1633
+ // HMAC-signed {userId,email} proof (identityFromCookie verifies it), which lets
1634
+ // the shared page's inline visibility control PATCH with credentials:'include'
1635
+ // and no bearer — the browser has the identity cookie, not a token. It cannot be
1636
+ // abused cross-site: the cookie is SameSite=Lax, which blocks it from riding a
1637
+ // cross-origin non-GET (CSRF), and namespace/owner enforcement on PUT/PATCH/
1638
+ // DELETE still confines a cookie holder to their own pages. 401 when none apply.
843
1639
  async function authorizeWrite(request, env) {
844
1640
  const presented = (request.headers.get('authorization') || '').replace(/^Bearer\\s+/i, '');
845
- if (!presented) return { error: json({ error: 'unauthorized' }, 401) };
846
- if (env.WRITE_TOKEN && safeEqual(presented, env.WRITE_TOKEN)) {
847
- return { kind: 'byo', owner: env.SHARE_NAMESPACE || 'byo' };
1641
+ if (presented) {
1642
+ if (env.WRITE_TOKEN && safeEqual(presented, env.WRITE_TOKEN)) {
1643
+ return { kind: 'byo', owner: env.SHARE_NAMESPACE || 'byo' };
1644
+ }
1645
+ const claims = await hooks.verifyPhoenixToken(request, env);
1646
+ if (claims && typeof claims.userId === 'string' && claims.userId) {
1647
+ return { kind: 'phoenix', owner: claims.userId, email: typeof claims.email === 'string' ? claims.email : '' };
1648
+ }
848
1649
  }
849
- const claims = await hooks.verifyPhoenixToken(request, env);
850
- if (claims && typeof claims.userId === 'string' && claims.userId) {
851
- return { kind: 'phoenix', owner: claims.userId, email: typeof claims.email === 'string' ? claims.email : '' };
1650
+ const cookieId = await identityFromCookie(request, env);
1651
+ if (cookieId && cookieId.userId) {
1652
+ return { kind: 'phoenix', owner: cookieId.userId, email: typeof cookieId.email === 'string' ? cookieId.email : '' };
852
1653
  }
853
1654
  return { error: json({ error: 'unauthorized' }, 401) };
854
1655
  }