@phnx-labs/agents-cli 1.22.52 → 1.22.54

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 (180) hide show
  1. package/CHANGELOG.md +336 -0
  2. package/README.md +42 -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 +220 -174
  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 +26 -17
  16. package/dist/commands/fleet-capture.js +7 -0
  17. package/dist/commands/focus.d.ts +1 -0
  18. package/dist/commands/focus.js +4 -2
  19. package/dist/commands/go.d.ts +5 -4
  20. package/dist/commands/go.js +8 -7
  21. package/dist/commands/insights.js +9 -0
  22. package/dist/commands/monitors.js +85 -30
  23. package/dist/commands/output.js +8 -2
  24. package/dist/commands/repo.js +18 -0
  25. package/dist/commands/secrets.js +33 -14
  26. package/dist/commands/sessions-inject.js +8 -3
  27. package/dist/commands/sessions-picker.js +2 -1
  28. package/dist/commands/sessions.d.ts +20 -12
  29. package/dist/commands/sessions.js +94 -40
  30. package/dist/commands/setup-accounts.d.ts +8 -0
  31. package/dist/commands/setup-accounts.js +47 -0
  32. package/dist/commands/setup.d.ts +1 -1
  33. package/dist/commands/setup.js +11 -2
  34. package/dist/commands/share.d.ts +52 -3
  35. package/dist/commands/share.js +262 -18
  36. package/dist/commands/ssh.d.ts +7 -0
  37. package/dist/commands/ssh.js +53 -14
  38. package/dist/commands/status.js +14 -0
  39. package/dist/commands/sync.js +44 -0
  40. package/dist/commands/view.d.ts +3 -1
  41. package/dist/commands/view.js +5 -4
  42. package/dist/lib/account-registry.d.ts +15 -5
  43. package/dist/lib/account-registry.js +165 -53
  44. package/dist/lib/accounting/rotate.d.ts +20 -6
  45. package/dist/lib/accounting/rotate.js +38 -7
  46. package/dist/lib/accounting/usage.d.ts +37 -1
  47. package/dist/lib/accounting/usage.js +71 -6
  48. package/dist/lib/agent-spec/agents.d.ts +5 -2
  49. package/dist/lib/agent-spec/agents.js +25 -7
  50. package/dist/lib/analytics/mix-commands.js +12 -6
  51. package/dist/lib/answer-router.js +2 -1
  52. package/dist/lib/auth-mint.d.ts +150 -0
  53. package/dist/lib/auth-mint.js +434 -0
  54. package/dist/lib/browser/profiles.d.ts +18 -0
  55. package/dist/lib/browser/profiles.js +26 -1
  56. package/dist/lib/browser/registry.d.ts +44 -14
  57. package/dist/lib/browser/registry.js +141 -45
  58. package/dist/lib/browser/remote-control.d.ts +9 -7
  59. package/dist/lib/browser/remote-control.js +9 -7
  60. package/dist/lib/claude-account-token.d.ts +10 -0
  61. package/dist/lib/claude-account-token.js +14 -4
  62. package/dist/lib/config-drift.d.ts +37 -0
  63. package/dist/lib/config-drift.js +72 -0
  64. package/dist/lib/daemon/auth-sync-service.d.ts +19 -0
  65. package/dist/lib/daemon/auth-sync-service.js +34 -0
  66. package/dist/lib/daemon/daemon.js +30 -4
  67. package/dist/lib/daemon/runner.js +10 -2
  68. package/dist/lib/daemon-services.d.ts +1 -1
  69. package/dist/lib/daemon-services.js +5 -0
  70. package/dist/lib/device-config.d.ts +3 -3
  71. package/dist/lib/device-config.js +8 -7
  72. package/dist/lib/devices/config-migration.js +147 -1
  73. package/dist/lib/devices/connect.d.ts +26 -0
  74. package/dist/lib/devices/connect.js +48 -1
  75. package/dist/lib/devices/device-docs.d.ts +35 -0
  76. package/dist/lib/devices/device-docs.js +163 -0
  77. package/dist/lib/devices/discovery-policy.d.ts +14 -2
  78. package/dist/lib/devices/discovery-policy.js +31 -21
  79. package/dist/lib/devices/doctor-findings.d.ts +5 -1
  80. package/dist/lib/devices/doctor-findings.js +19 -1
  81. package/dist/lib/devices/registry.d.ts +11 -5
  82. package/dist/lib/devices/registry.js +46 -18
  83. package/dist/lib/exec.d.ts +88 -30
  84. package/dist/lib/exec.js +138 -34
  85. package/dist/lib/feed/feed.d.ts +10 -2
  86. package/dist/lib/feed/feed.js +35 -2
  87. package/dist/lib/feed-broadcast.js +1 -1
  88. package/dist/lib/fleet/apply.d.ts +11 -0
  89. package/dist/lib/fleet/apply.js +23 -3
  90. package/dist/lib/fleet/auth-sync.js +5 -3
  91. package/dist/lib/help.d.ts +9 -0
  92. package/dist/lib/help.js +29 -1
  93. package/dist/lib/hosts/dispatch.d.ts +4 -3
  94. package/dist/lib/hosts/dispatch.js +12 -8
  95. package/dist/lib/hosts/passthrough.d.ts +1 -10
  96. package/dist/lib/hosts/passthrough.js +1 -13
  97. package/dist/lib/hosts/providers/local.d.ts +9 -3
  98. package/dist/lib/hosts/providers/local.js +23 -12
  99. package/dist/lib/hosts/reconnect.d.ts +7 -4
  100. package/dist/lib/hosts/reconnect.js +29 -25
  101. package/dist/lib/hosts/registry.js +4 -1
  102. package/dist/lib/hosts/remote-os.js +3 -1
  103. package/dist/lib/installations/versions.js +9 -1
  104. package/dist/lib/linux-userns.d.ts +58 -0
  105. package/dist/lib/linux-userns.js +116 -0
  106. package/dist/lib/memory.d.ts +26 -0
  107. package/dist/lib/memory.js +80 -1
  108. package/dist/lib/monitors/config.d.ts +11 -0
  109. package/dist/lib/monitors/config.js +8 -0
  110. package/dist/lib/monitors/engine.js +8 -1
  111. package/dist/lib/monitors/state.d.ts +37 -1
  112. package/dist/lib/monitors/state.js +79 -4
  113. package/dist/lib/permissions-registry.d.ts +2 -0
  114. package/dist/lib/permissions-registry.js +116 -14
  115. package/dist/lib/permissions.d.ts +5 -3
  116. package/dist/lib/permissions.js +25 -27
  117. package/dist/lib/profiles.d.ts +8 -7
  118. package/dist/lib/profiles.js +12 -0
  119. package/dist/lib/project-key.d.ts +9 -0
  120. package/dist/lib/project-key.js +11 -0
  121. package/dist/lib/secrets/bundles.d.ts +35 -0
  122. package/dist/lib/secrets/bundles.js +78 -1
  123. package/dist/lib/secrets/push.d.ts +3 -8
  124. package/dist/lib/secrets/push.js +18 -14
  125. package/dist/lib/secrets/remote.d.ts +9 -18
  126. package/dist/lib/secrets/remote.js +11 -26
  127. package/dist/lib/secrets/reserved-sync.d.ts +65 -0
  128. package/dist/lib/secrets/reserved-sync.js +129 -0
  129. package/dist/lib/self-heal/checks/hook-manifest.d.ts +2 -0
  130. package/dist/lib/self-heal/checks/hook-manifest.js +56 -0
  131. package/dist/lib/self-heal/registry.js +4 -0
  132. package/dist/lib/self-heal/types.d.ts +1 -1
  133. package/dist/lib/session/active.d.ts +10 -1
  134. package/dist/lib/session/active.js +8 -5
  135. package/dist/lib/session/actor-sidecar.d.ts +7 -0
  136. package/dist/lib/session/actor-sidecar.js +2 -0
  137. package/dist/lib/session/db.d.ts +39 -4
  138. package/dist/lib/session/db.js +168 -31
  139. package/dist/lib/session/discover.d.ts +32 -4
  140. package/dist/lib/session/discover.js +126 -37
  141. package/dist/lib/session/insights.d.ts +14 -0
  142. package/dist/lib/session/insights.js +25 -2
  143. package/dist/lib/session/linear.js +1 -1
  144. package/dist/lib/session/live-metadata.js +1 -0
  145. package/dist/lib/session/pid-registry.d.ts +7 -0
  146. package/dist/lib/session/prompt.d.ts +15 -0
  147. package/dist/lib/session/prompt.js +21 -0
  148. package/dist/lib/session/shell-programs.d.ts +17 -0
  149. package/dist/lib/session/shell-programs.js +21 -0
  150. package/dist/lib/session/state.js +2 -1
  151. package/dist/lib/session/stream-render.js +2 -1
  152. package/dist/lib/session/tool-calls.js +2 -5
  153. package/dist/lib/session/trajectory-html.js +2 -1
  154. package/dist/lib/session/trajectory.js +3 -12
  155. package/dist/lib/session/types.d.ts +25 -0
  156. package/dist/lib/session/types.js +10 -0
  157. package/dist/lib/share/publish.d.ts +53 -5
  158. package/dist/lib/share/publish.js +99 -17
  159. package/dist/lib/share/worker-template.js +594 -64
  160. package/dist/lib/startup/root-command.js +2 -1
  161. package/dist/lib/state.d.ts +24 -0
  162. package/dist/lib/state.js +318 -54
  163. package/dist/lib/sync-status.d.ts +4 -0
  164. package/dist/lib/sync-status.js +3 -0
  165. package/dist/lib/terminal/resolve.d.ts +7 -0
  166. package/dist/lib/terminal/resolve.js +41 -2
  167. package/dist/lib/traces/classify.js +24 -19
  168. package/dist/lib/traces/insights.d.ts +67 -0
  169. package/dist/lib/traces/insights.js +178 -0
  170. package/dist/lib/traces/phenotype.d.ts +67 -0
  171. package/dist/lib/traces/phenotype.js +437 -0
  172. package/dist/lib/traces/segments.d.ts +133 -0
  173. package/dist/lib/traces/segments.js +301 -0
  174. package/dist/lib/traces/sync.d.ts +33 -0
  175. package/dist/lib/traces/sync.js +11 -2
  176. package/dist/lib/types.d.ts +47 -1
  177. package/dist/lib/usage-refresh.js +2 -1
  178. package/dist/lib/view-types.d.ts +2 -0
  179. package/dist/lib/watchdog/runner.js +18 -4
  180. package/package.json +2 -1
@@ -1,7 +1,8 @@
1
1
  // The Cloudflare Worker that fronts the R2 share bucket.
2
2
  //
3
3
  // One tiny Worker does both sides:
4
- // - PUT /<username>/<slug> — bearer-gated by TWO principals (not a fallback):
4
+ // - PUT /<username>/<slug> — write-gated by authorizeWrite's THREE principals
5
+ // (not a fallback chain — each is a distinct legitimate identity):
5
6
  // 1. Static WRITE_TOKEN (BYO Cloudflare) — checked first when the presented
6
7
  // bearer equals env.WRITE_TOKEN. owner = env.SHARE_NAMESPACE or the
7
8
  // path's first segment.
@@ -12,8 +13,14 @@
12
13
  // so one user cannot write another's prefix (403 namespace mismatch).
13
14
  // A __handles/<handle> claim binds the handle to the first userId that
14
15
  // writes it; a different userId gets 409 handle taken.
16
+ // 3. __share HMAC cookie — the signed-in viewer's identity cookie (same
17
+ // {userId,email} identityFromCookie verifies for GET). Lets the shared
18
+ // page's inline visibility control PATCH with credentials:'include' and
19
+ // no bearer; SameSite=Lax blocks it cross-site, and the same namespace/
20
+ // owner checks confine it to the holder's own pages. Applies to PUT,
21
+ // PATCH, and DELETE alike, since all three share authorizeWrite.
15
22
  // A managed deployment sets PHOENIX_ID_BASE; BYO sets WRITE_TOKEN; the
16
- // platform endpoint may set both. Fail loud (401) when neither authenticates.
23
+ // platform endpoint may set both. Fail loud (401) when none authenticates.
17
24
  // Writes the body to R2 via the BUCKET binding, storing visibility
18
25
  // (public|unlisted|me|org), owner, org_domain (org only), an optional
19
26
  // expires-at, plus provenance (agent/session/host/repo/date), a label, and
@@ -22,6 +29,13 @@
22
29
  // domain is 400. Overwriting an existing slug first copies the current
23
30
  // object to <slug>/rev-<ts>-<rand> (revision history) unless
24
31
  // x-share-no-revision is set.
32
+ // - PATCH /<username>/<slug> — authenticated metadata-only edit. Rewrites the
33
+ // exact existing body with all HTTP/custom metadata preserved except the
34
+ // explicitly requested label/arbitrary metadata changes; never revisions.
35
+ // Conditional put (onlyIf etagMatches) so a concurrent republish 409s
36
+ // instead of rolling the body back. Phoenix requires customMetadata.owner
37
+ // === auth.owner (fail closed when the stamp is missing); WRITE_TOKEN is
38
+ // the admin repair path.
25
39
  // - GET /<username>/<slug> — public|unlisted are anonymous; me requires the
26
40
  // Phoenix owner, org requires a same-domain Phoenix identity (Bearer, then
27
41
  // HMAC cookie, then phoenix_ticket). Unauthenticated me/org 302s to
@@ -62,7 +76,7 @@ export const hooks = {
62
76
  };
63
77
 
64
78
  export default {
65
- async fetch(request, env) {
79
+ async fetch(request, env, ctx) {
66
80
  const url = new URL(request.url);
67
81
  const path = decodeURIComponent(url.pathname.replace(/^\\/+/, ''));
68
82
 
@@ -119,21 +133,58 @@ export default {
119
133
  // never smuggle through on a publish that carries no agent/session/host/
120
134
  // repo/date at all — e.g. a human publishing outside an agent session, or
121
135
  // 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') || '';
136
+ // Full-Unicode headers (PHNX-2786): the CLI always sends the latin1-safe
137
+ // x-share-<field> (what a pre-Unicode Worker read), and when the fold was
138
+ // lossy ALSO a percent-encoded x-share-<field>-u companion plus an
139
+ // x-share-encoding: percent opt-in. Prefer the decoded companion when the
140
+ // opt-in is present, so a Japanese/emoji title/label/--meta renders in full
141
+ // instead of (unnamed) or a dropped glyph; a malformed companion falls
142
+ // back to the folded value rather than failing the publish.
143
+ const usePercent = (request.headers.get('x-share-encoding') || '') === 'percent';
144
+ const readText = (name) => {
145
+ const folded = request.headers.get('x-share-' + name) || '';
146
+ if (usePercent) {
147
+ const encoded = request.headers.get('x-share-' + name + '-u');
148
+ if (encoded) {
149
+ try {
150
+ return decodeURIComponent(encoded);
151
+ } catch (e) {
152
+ return folded;
153
+ }
154
+ }
155
+ }
156
+ return folded;
157
+ };
158
+ const agent = readText('agent');
159
+ const session = readText('session');
160
+ const host = readText('host');
161
+ const repo = readText('repo');
162
+ const date = readText('date');
163
+ const avatar = readText('avatar');
164
+ const label = readText('label');
128
165
  const labelSource = request.headers.get('x-share-label-source') || '';
129
166
  let extraMeta = {};
130
- const metaHeader = request.headers.get('x-share-meta');
131
- if (metaHeader) {
167
+ // The -u meta companion carries the whole raw object percent-encoded, so a
168
+ // single decode+parse recovers full-Unicode keys and values; fall back to
169
+ // the per-value-folded x-share-meta when it is absent or malformed.
170
+ const metaEncoded = usePercent ? request.headers.get('x-share-meta-u') : null;
171
+ if (metaEncoded) {
132
172
  try {
133
- const parsed = JSON.parse(metaHeader);
173
+ const parsed = JSON.parse(decodeURIComponent(metaEncoded));
134
174
  if (parsed && typeof parsed === 'object') extraMeta = parsed;
135
- } catch {
136
- // malformed --meta header — ignore rather than fail the whole publish
175
+ } catch (e) {
176
+ // malformed unicode meta companion — fall through to the folded header
177
+ }
178
+ }
179
+ if (!Object.keys(extraMeta).length) {
180
+ const metaHeader = request.headers.get('x-share-meta');
181
+ if (metaHeader) {
182
+ try {
183
+ const parsed = JSON.parse(metaHeader);
184
+ if (parsed && typeof parsed === 'object') extraMeta = parsed;
185
+ } catch {
186
+ // malformed --meta header — ignore rather than fail the whole publish
187
+ }
137
188
  }
138
189
  }
139
190
  const customMetadata = { ...extraMeta };
@@ -156,6 +207,7 @@ export default {
156
207
  if (host) customMetadata['host'] = host;
157
208
  if (repo) customMetadata['repo'] = repo;
158
209
  if (date) customMetadata['date'] = date;
210
+ if (avatar) customMetadata['avatar'] = avatar;
159
211
  if (label) customMetadata['label'] = label;
160
212
  if (labelSource) customMetadata['label-source'] = labelSource;
161
213
 
@@ -189,6 +241,95 @@ export default {
189
241
  return json({ ok: true, url: url.origin + '/' + path, expiresAt: expiresAt || null, unlisted: visibility === 'unlisted', visibility }, 200);
190
242
  }
191
243
 
244
+ if (request.method === 'PATCH') {
245
+ const auth = await authorizeWrite(request, env);
246
+ if (auth.error) return auth.error;
247
+ const segments = path.split('/').filter(Boolean);
248
+ if (segments.length < 2) return json({ error: 'metadata edit requires /<username>/<slug>' }, 400);
249
+ if (auth.kind === 'phoenix') {
250
+ const expected = phoenixHandle(auth);
251
+ if (!expected || segments[0] !== expected) return json({ error: 'namespace mismatch', owner: expected }, 403);
252
+ }
253
+ const existing = await env.BUCKET.get(path);
254
+ if (!existing) return json({ error: 'share not found', key: path }, 404);
255
+ const owner = existing.customMetadata && existing.customMetadata.owner;
256
+ // WRITE_TOKEN is the endpoint owner/admin credential (the same authority
257
+ // the DELETE path grants it). Phoenix must prove ownership — fail closed
258
+ // when the object has no owner stamp (handles collide after sanitization).
259
+ if (auth.kind === 'phoenix' && (!owner || owner !== auth.owner)) return json({ error: 'forbidden' }, 403);
260
+
261
+ let edit;
262
+ try { edit = await request.json(); } catch { return json({ error: 'PATCH body must be JSON' }, 400); }
263
+ if (!edit || typeof edit !== 'object') return json({ error: 'PATCH body must be an object' }, 400);
264
+ const metadata = { ...(existing.customMetadata || {}) };
265
+ const previousVisibility = metadata.visibility || 'public';
266
+ if (Object.prototype.hasOwnProperty.call(edit, 'label')) {
267
+ if (edit.label !== null && typeof edit.label !== 'string') return json({ error: 'label must be a string or null' }, 400);
268
+ 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);
269
+ if (edit.label === null) { delete metadata.label; delete metadata['label-source']; }
270
+ else { metadata.label = edit.label; metadata['label-source'] = 'explicit'; }
271
+ }
272
+ // Visibility is a first-class edit field (like label), not an arbitrary
273
+ // --meta entry — 'visibility' is a RESERVED key, so it can only be changed
274
+ // here, never smuggled through edit.meta. me/org require a Phoenix identity
275
+ // and org additionally requires a private (non-public-inbox) email domain,
276
+ // the SAME gate PUT enforces. This is a metadata-only rewrite: the body is
277
+ // untouched, so no revision is created (identical to a label/meta edit).
278
+ if (Object.prototype.hasOwnProperty.call(edit, 'visibility')) {
279
+ const vis = normalizeVisibility(edit.visibility);
280
+ if (vis.error) return vis.error;
281
+ const visibility = vis.value;
282
+ if (visibility === 'me' || visibility === 'org') {
283
+ const phoenixBase = typeof env.PHOENIX_ID_BASE === 'string' ? env.PHOENIX_ID_BASE.replace(/\\/+$/, '') : '';
284
+ if (auth.kind !== 'phoenix' || !phoenixBase) return json({ error: 'visibility me/org requires Phoenix identity' }, 400);
285
+ if (visibility === 'org') {
286
+ const domain = emailDomain(auth.email);
287
+ if (!domain) return json({ error: 'org visibility requires a verified email domain' }, 400);
288
+ if (PUBLIC_INBOX_DOMAINS.indexOf(domain) !== -1) return json({ error: 'org visibility cannot use a public email domain', domain: domain }, 400);
289
+ }
290
+ }
291
+ metadata.visibility = visibility;
292
+ // org_domain is meaningful only for org — set it there, drop the stale
293
+ // value on any move away from org so it can never gate a later read.
294
+ if (visibility === 'org') metadata.org_domain = emailDomain(auth.email);
295
+ else delete metadata.org_domain;
296
+ }
297
+ const mode = edit.metaMode || 'merge';
298
+ if (mode !== 'merge' && mode !== 'replace') return json({ error: 'metaMode must be merge or replace' }, 400);
299
+ const incoming = edit.meta || {};
300
+ if (!incoming || typeof incoming !== 'object' || Array.isArray(incoming)) return json({ error: 'meta must be an object' }, 400);
301
+ for (const key in incoming) {
302
+ if (RESERVED_METADATA_KEYS.indexOf(key) !== -1) return json({ error: 'reserved metadata key', key: key }, 400);
303
+ if (!/^[a-z0-9-]{1,64}$/.test(key) || typeof incoming[key] !== 'string') return json({ error: 'invalid metadata entry', key: key }, 400);
304
+ }
305
+ if (mode === 'replace') {
306
+ for (const key in metadata) if (RESERVED_METADATA_KEYS.indexOf(key) === -1) delete metadata[key];
307
+ }
308
+ for (const key in incoming) metadata[key] = incoming[key];
309
+ const remove = edit.removeMeta || [];
310
+ if (!Array.isArray(remove)) return json({ error: 'removeMeta must be an array' }, 400);
311
+ for (const key of remove) {
312
+ 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);
313
+ delete metadata[key];
314
+ }
315
+ if (new TextEncoder().encode(JSON.stringify(extraMetaOf(metadata))).length > 2048) return json({ error: 'metadata exceeds 2048 bytes' }, 400);
316
+ // R2 assigns a fresh uploaded timestamp to every put. Preserve the
317
+ // canonical publication time before this metadata-only rewrite so gallery
318
+ // ordering and list JSON do not pretend the page was republished today.
319
+ if (!metadata['published-at']) metadata['published-at'] = new Date(existing.uploaded).toISOString();
320
+ const httpHeaders = new Headers();
321
+ if (typeof existing.writeHttpMetadata === 'function') existing.writeHttpMetadata(httpHeaders);
322
+ const putOpts = { httpMetadata: httpHeaders, customMetadata: metadata };
323
+ // onlyIf.etagMatches wants the bare hash (R2Object#etag), not the
324
+ // quoted HTTP header form (R2Object#httpEtag) — passing httpEtag 500s
325
+ // on a real Workers R2 binding ("Conditional ETag should not be
326
+ // wrapped in quotes"), a defect only a real R2 backend surfaces.
327
+ if (existing.etag) putOpts.onlyIf = { etagMatches: existing.etag };
328
+ const putResult = await env.BUCKET.put(path, existing.body, putOpts);
329
+ if (putResult === null) return json({ error: 'conflict', key: path }, 409);
330
+ return json({ ok: true, url: url.origin + '/' + path, label: metadata.label || null, meta: extraMetaOf(metadata), visibility: metadata.visibility || 'public', previousVisibility: previousVisibility }, 200);
331
+ }
332
+
192
333
  if (request.method === 'GET' || request.method === 'HEAD') {
193
334
  // Single-segment path may be a user gallery/listing OR a legacy flat slug.
194
335
  // The disambiguator is whether the <seg>/ prefix holds any object: only a
@@ -202,9 +343,12 @@ export default {
202
343
  const list = await env.BUCKET.list({ prefix: segments[0] + '/', limit: 1 });
203
344
  const isNamespace = list.objects && list.objects.length > 0;
204
345
  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);
346
+ if (url.searchParams.get('format') === 'json') {
347
+ const scope = await resolveListingScope(request, env, segments[0]);
348
+ if (scope.error) return scope.error;
349
+ return renderListing(env.BUCKET, url.origin, segments[0], request.method, scope.includeHidden);
350
+ }
351
+ return renderGallery(env.BUCKET, url.origin, segments[0], request.method);
208
352
  }
209
353
  // Not a namespace: fall through. A legacy flat-slug object resolves to its
210
354
  // real content; anything else 404s (an empty/nonexistent namespace, which
@@ -219,8 +363,15 @@ export default {
219
363
  // ?revisions=json must not leak that the page exists.
220
364
  if (segments.length === 2 && url.searchParams.get('revisions') === 'json') {
221
365
  const canonical = await env.BUCKET.get(path);
222
- if (canonical) {
223
- const denied = await gateRestrictedGet(request, env, url, canonical);
366
+ // Only a me/org canonical needs the viewer resolved — and only there does
367
+ // a phoenix_ticket failure gate the response, exactly as before this route
368
+ // shared resolveViewer with the page GET. A public/unlisted revision list
369
+ // never invoked ticket redemption, so a stale ticket must not 401 it.
370
+ if (canonical && isIdentityGated((canonical.customMetadata && canonical.customMetadata.visibility) || 'public')) {
371
+ const viewer = await resolveViewer(request, env, url);
372
+ if (viewer.redirect) return viewer.redirect;
373
+ if (viewer.error) return viewer.error;
374
+ const denied = gateVisibility(url, env, canonical, viewer.identity || null);
224
375
  if (denied) return denied;
225
376
  }
226
377
  return renderRevisions(
@@ -239,9 +390,24 @@ export default {
239
390
  await env.BUCKET.delete(path);
240
391
  return new Response('gone — this link has expired', { status: 410, headers: { 'content-type': 'text/plain' } });
241
392
  }
242
- const denied = await gateRestrictedGet(request, env, url, obj);
243
- if (denied) return denied;
393
+ // Resolve the viewer ONCE — needed both to gate me/org reads AND to decide
394
+ // whether THIS viewer OWNS this namespace (the interactive visibility
395
+ // control below). A successful phoenix_ticket redemption (redirect: set the
396
+ // identity cookie, strip the ticket) is honored on ANY page. But a ticket
397
+ // FAILURE (expired/consumed/unreachable) must only gate a page that needs
398
+ // identity — a public/unlisted page never invoked ticket redemption before
399
+ // this refactor, so a stale ticket there must serve anonymously, not 401.
244
400
  const visibility = (obj.customMetadata && obj.customMetadata.visibility) || 'public';
401
+ const viewer = await resolveViewer(request, env, url);
402
+ if (viewer.redirect) return viewer.redirect;
403
+ if (viewer.error && isIdentityGated(visibility)) return viewer.error;
404
+ const identity = viewer.identity || null;
405
+ const denied = gateVisibility(url, env, obj, identity);
406
+ if (denied) return denied;
407
+ // The owner of the namespace (their handle === the first path segment) gets
408
+ // an interactive visibility control; everyone else keeps the static cue.
409
+ const isOwner = !!identity && handleFromEmail(identity.email) === firstSeg;
410
+ const ownerDomain = identity ? emailDomain(identity.email) : '';
245
411
  const headers = new Headers();
246
412
  obj.writeHttpMetadata(headers);
247
413
  headers.set('etag', obj.httpEtag);
@@ -254,18 +420,58 @@ export default {
254
420
  if (visibility === 'unlisted') headers.set('X-Robots-Tag', 'noindex');
255
421
  }
256
422
  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
423
+ const ctype = headers.get('content-type') || '';
424
+ // HTML pages ALWAYS get the attribution bar injected at serve time (who
425
+ // shared it, what made it, when, and — the point — a visual VISIBILITY cue),
426
+ // all from stamped metadata. This is unconditional: ?raw does NOT strip the
427
+ // bar from a shared page (the always-on cue from #3122/#3140 is not
428
+ // defeatable with a query param). The body changes, so the R2 etag no
261
429
  // longer matches — drop it rather than serve a lying validator.
262
- const isHtmlPage = (headers.get('content-type') || '').indexOf('text/html') !== -1;
263
- if (isHtmlPage) {
430
+ if (ctype.indexOf('text/html') !== -1) {
264
431
  const rawHtml = await obj.text();
265
- const withBar = injectAttributionBar(rawHtml, obj.customMetadata || {}, firstSeg);
432
+ // Per-slug view counter (RUSH view stats). Stored as a SEPARATE R2 object
433
+ // (__views/<path>) so it never rewrites the page object — a rewrite would
434
+ // reset obj.uploaded and corrupt "last updated". The __-prefix key is GET-
435
+ // blocked for direct requests and lives outside every <user>/ list prefix,
436
+ // so it never leaks into the gallery/listing/revisions. Count only a real
437
+ // visitor's page view: not ?raw (an embed/OG fetch), not the owner's own
438
+ // view, and never HEAD (which returned above). The write rides
439
+ // ctx.waitUntil so it never blocks the response; the displayed count folds
440
+ // in the current view so the visitor sees themselves included.
441
+ const wantsRaw = url.searchParams.get('raw') != null;
442
+ const counting = !wantsRaw && !isOwner;
443
+ const storedViews = await readViews(env, path);
444
+ const views = storedViews + (counting ? 1 : 0);
445
+ if (counting && ctx && typeof ctx.waitUntil === 'function') {
446
+ ctx.waitUntil(writeViews(env, path, views));
447
+ }
448
+ const withBar = injectAttributionBar(rawHtml, obj.customMetadata || {}, firstSeg, {
449
+ isOwner,
450
+ ownerDomain,
451
+ views,
452
+ uploaded: obj.uploaded,
453
+ });
266
454
  headers.delete('etag');
267
455
  return new Response(withBar, { status: 200, headers });
268
456
  }
457
+ // Non-HTML asset. ?raw returns the stored bytes untouched — the escape hatch
458
+ // for <img src>, OG crawlers, iframes, and anyone embedding the asset (the
459
+ // viewer's own media element points back at ?raw). Otherwise a BROWSER
460
+ // navigating directly to a viewable asset (image/video/audio/pdf) gets a
461
+ // lightweight viewer page carrying the same bar; a non-browser fetch (Accept
462
+ // without text/html) falls through to raw bytes so embedding is never broken.
463
+ const wantsRaw = url.searchParams.get('raw') != null;
464
+ const kind = viewableAssetKind(ctype);
465
+ if (!wantsRaw && kind && acceptsHtml(request)) {
466
+ const viewerPage = renderAssetViewer(url.pathname, kind, obj.customMetadata || {}, firstSeg, {
467
+ isOwner,
468
+ ownerDomain,
469
+ });
470
+ const vheaders = new Headers(headers);
471
+ vheaders.set('content-type', 'text/html; charset=utf-8');
472
+ vheaders.delete('etag');
473
+ return new Response(viewerPage, { status: 200, headers: vheaders });
474
+ }
269
475
  return new Response(obj.body, { status: 200, headers });
270
476
  }
271
477
 
@@ -307,6 +513,10 @@ function isRevisionKey(key) {
307
513
  return key.split('/').length > 2;
308
514
  }
309
515
 
516
+ function publishedAtOf(object) {
517
+ return (object.customMetadata && object.customMetadata['published-at']) || object.uploaded;
518
+ }
519
+
310
520
  async function renderGallery(bucket, origin, user, method) {
311
521
  const objects = [];
312
522
  let cursor;
@@ -329,7 +539,7 @@ async function renderGallery(bucket, origin, user, method) {
329
539
  const url = origin + '/' + o.key;
330
540
  const label = (o.customMetadata && o.customMetadata['label']) || '';
331
541
  const agent = (o.customMetadata && o.customMetadata['agent']) || '';
332
- return { slug, url, updated: o.uploaded, label, agent };
542
+ return { slug, url, updated: publishedAtOf(o), label, agent };
333
543
  });
334
544
 
335
545
  if (method === 'HEAD') {
@@ -360,7 +570,7 @@ async function renderGallery(bucket, origin, user, method) {
360
570
  return new Response(html, { status: 200, headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'public, max-age=60' } });
361
571
  }
362
572
 
363
- async function renderListing(bucket, origin, user, method) {
573
+ async function renderListing(bucket, origin, user, method, includeHidden) {
364
574
  const objects = [];
365
575
  let cursor;
366
576
  do {
@@ -381,11 +591,11 @@ async function renderListing(bucket, origin, user, method) {
381
591
  const now = Date.now();
382
592
  const items = objects
383
593
  .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.
594
+ // Revisions and sibling .png OG covers are always hidden. Hidden-visibility
595
+ // pages are included ONLY when an authenticated owner requested scope=mine.
386
596
  if (isRevisionKey(o.key)) return false;
387
597
  if (o.key.endsWith('.png')) return false;
388
- if (isHiddenFromGallery(o.customMetadata && o.customMetadata.visibility)) return false;
598
+ if (isHiddenFromGallery(o.customMetadata && o.customMetadata.visibility) && !includeHidden) return false;
389
599
  const expiresAt = o.customMetadata && o.customMetadata['expires-at'];
390
600
  return !(expiresAt && now > Date.parse(expiresAt));
391
601
  })
@@ -394,9 +604,10 @@ async function renderListing(bucket, origin, user, method) {
394
604
  url: origin + '/' + o.key,
395
605
  size: typeof o.size === 'number' ? o.size : 0,
396
606
  contentType: (o.httpMetadata && o.httpMetadata.contentType) || null,
397
- publishedAt: new Date(o.uploaded).toISOString(),
607
+ publishedAt: new Date(publishedAtOf(o)).toISOString(),
398
608
  expiresAt: (o.customMetadata && o.customMetadata['expires-at']) || null,
399
609
  label: (o.customMetadata && o.customMetadata['label']) || null,
610
+ visibility: (o.customMetadata && o.customMetadata.visibility) || 'public',
400
611
  agent: (o.customMetadata && o.customMetadata['agent']) || null,
401
612
  session: (o.customMetadata && o.customMetadata['session']) || null,
402
613
  host: (o.customMetadata && o.customMetadata['host']) || null,
@@ -407,15 +618,39 @@ async function renderListing(bucket, origin, user, method) {
407
618
  // Newest first, so the human table and any script reads the freshest share top.
408
619
  items.sort((a, b) => (a.publishedAt < b.publishedAt ? 1 : a.publishedAt > b.publishedAt ? -1 : 0));
409
620
 
621
+ const cacheHeaders = includeHidden
622
+ ? { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'private, no-store', 'X-Robots-Tag': 'noindex' }
623
+ : { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'public, max-age=30' };
410
624
  if (method === 'HEAD') {
411
- return new Response(null, { status: 200, headers: { 'content-type': 'application/json; charset=utf-8' } });
625
+ return new Response(null, { status: 200, headers: cacheHeaders });
412
626
  }
413
627
  return new Response(JSON.stringify({ user, count: items.length, objects: items }), {
414
628
  status: 200,
415
- headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'public, max-age=30' },
629
+ headers: cacheHeaders,
416
630
  });
417
631
  }
418
632
 
633
+ // The machine-readable listing is public by default. A request with
634
+ // '?format=json&scope=mine' asks to include the owner's hidden pages
635
+ // (unlisted / me / org). We honor it ONLY after a valid owner bearer whose
636
+ // namespace matches the requested handle, so one user cannot list another's
637
+ // hidden shares. Anonymous or mismatched requests fail loud. A BYO WRITE_TOKEN
638
+ // bearer is the worker's owner, so namespace enforcement is skipped for BYO.
639
+ async function resolveListingScope(request, env, user) {
640
+ const scope = new URL(request.url).searchParams.get('scope');
641
+ if (scope !== 'mine') return { includeHidden: false };
642
+ const auth = await authorizeWrite(request, env);
643
+ if (auth.error) return { error: auth.error };
644
+ if (auth.kind === 'byo') {
645
+ return { includeHidden: true };
646
+ }
647
+ const owner = phoenixHandle(auth);
648
+ if (owner !== user) {
649
+ return { error: json({ error: 'namespace mismatch' }, 403) };
650
+ }
651
+ return { includeHidden: true };
652
+ }
653
+
419
654
  async function renderRevisions(bucket, origin, key, method, identityGated) {
420
655
  const objects = [];
421
656
  let cursor;
@@ -459,7 +694,7 @@ async function renderRevisions(bucket, origin, key, method, identityGated) {
459
694
  // before it ever reaches this Worker; see RESERVED_META_KEYS in publish.ts).
460
695
  // One list, reused both to strip a same-named --meta collision on write and
461
696
  // 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'];
697
+ var RESERVED_METADATA_KEYS = ['expires-at', 'published-at', 'visibility', 'owner', 'org_domain', 'agent', 'session', 'host', 'repo', 'date', 'avatar', 'label', 'label-source'];
463
698
  var PUBLIC_INBOX_DOMAINS = ['gmail.com', 'googlemail.com', 'outlook.com', 'hotmail.com', 'live.com', 'icloud.com', 'me.com'];
464
699
  var SHARE_COOKIE = '__Host-phoenix_share';
465
700
  var SHARE_COOKIE_MAX_AGE = 604800;
@@ -498,6 +733,76 @@ var VIS_ICON = {
498
733
  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
734
  };
500
735
 
736
+ // Eye glyph for the view-count stat; caret for the owner's interactive chip.
737
+ 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>';
738
+ 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>';
739
+
740
+ // The owner-only browser script that turns the visibility chip into a live
741
+ // control. Self-contained IIFE, namespaced under .agents-share-bar; injected only
742
+ // when the viewer owns the namespace. It reads the current level and each level's
743
+ // icon/color/label straight from the rendered menu DOM (no embedded config), then
744
+ // PATCHes the SAME in-place edit route the CLI uses — method PATCH, JSON body
745
+ // { visibility } — with credentials:'include' so the viewer's __share cookie /
746
+ // Phoenix identity rides. Optimistic: flip the chip + spinner, then a green check
747
+ // on 2xx or revert + the server's error text on failure (an org-on-public-inbox
748
+ // 400 surfaces verbatim). The share page sets no CSP, so inline JS is fine.
749
+ var ASH_OWNER_JS = \`(function(){
750
+ var root = document.querySelector('.agents-share-bar');
751
+ if(!root) return;
752
+ var chip = root.querySelector('[data-ash-chip]');
753
+ var menu = root.querySelector('[data-ash-menu]');
754
+ var toast = root.querySelector('[data-ash-toast]');
755
+ if(!chip || !menu || !toast) return;
756
+ var labelEl = chip.querySelector('[data-ash-label]');
757
+ var chipIc = chip.querySelector('[data-ash-chip-ic]');
758
+ var busy = false;
759
+ function opts(){ return menu.querySelectorAll('[data-ash-opt]'); }
760
+ function currentKey(){ var s = menu.querySelector('.ash-opt.ash-sel'); return s ? s.getAttribute('data-ash-opt') : ''; }
761
+ 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; }
762
+ function open(){ menu.classList.add('ash-open'); menu.setAttribute('aria-hidden','false'); }
763
+ function close(){ menu.classList.remove('ash-open'); menu.setAttribute('aria-hidden','true'); }
764
+ chip.addEventListener('click', function(e){ e.stopPropagation(); if(menu.classList.contains('ash-open')) close(); else open(); });
765
+ menu.addEventListener('click', function(e){ e.stopPropagation(); });
766
+ document.addEventListener('click', close);
767
+ document.addEventListener('keydown', function(e){ if(e.key==='Escape') close(); });
768
+ function draw(key){
769
+ var sel = null, a = opts();
770
+ 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]; }
771
+ if(!sel) return;
772
+ var ic = sel.querySelector('.ash-opt-ic');
773
+ var color = ic ? ic.style.color : '';
774
+ if(color){ chip.style.color = color; chip.style.borderColor = color + '66'; }
775
+ if(chipIc && ic) chipIc.innerHTML = ic.innerHTML;
776
+ var b = sel.querySelector('b');
777
+ if(labelEl && b) labelEl.textContent = b.textContent;
778
+ }
779
+ function say(inner, cls){ toast.innerHTML = inner; toast.className = 'ash-toast ash-show' + (cls ? ' ' + cls : ''); }
780
+ function fade(ms){ setTimeout(function(){ toast.className = 'ash-toast'; }, ms); }
781
+ function pick(key){
782
+ close();
783
+ var prev = currentKey();
784
+ if(busy || !key || key===prev) return;
785
+ busy = true;
786
+ draw(key);
787
+ var name = labelFor(key);
788
+ say('<span class="ash-spin"></span> Saving…');
789
+ fetch(location.pathname, { method:'PATCH', credentials:'include', headers:{'content-type':'application/json'}, body: JSON.stringify({ visibility: key }) })
790
+ .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: {} }; }); })
791
+ .then(function(r){
792
+ busy = false;
793
+ if(r.ok){ say('<span class="ash-ok">\\u2713</span> Now ' + name, 'ash-good'); fade(1600); }
794
+ else {
795
+ draw(prev);
796
+ var msg = (r.body && r.body.error) ? String(r.body.error) : ('Could not update (HTTP ' + r.status + ')');
797
+ say('<span class="ash-err">\\u2715</span> ' + msg.replace(/[<>&]/g, ' '), 'ash-bad');
798
+ fade(4000);
799
+ }
800
+ }, function(){ busy = false; draw(prev); say('<span class="ash-err">\\u2715</span> Network error \\u2014 not saved', 'ash-bad'); fade(4000); });
801
+ }
802
+ var a = opts();
803
+ 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]); }
804
+ })();\`;
805
+
501
806
  function visibilityChip(visibility, orgDomain) {
502
807
  if (visibility === 'me') return { icon: VIS_ICON.me, label: 'Only you', color: '#f59e0b' };
503
808
  if (visibility === 'org') return { icon: VIS_ICON.org, label: 'Anyone at ' + escapeHtml(orgDomain || 'your organization'), color: '#5b9dff' };
@@ -505,41 +810,186 @@ function visibilityChip(visibility, orgDomain) {
505
810
  return { icon: VIS_ICON.public, label: 'Public', color: '#30a46c' };
506
811
  }
507
812
 
508
- function renderAttributionBar(meta, handle) {
813
+ // A deterministic hue (0-359) from the handle, so a given sharer always gets the
814
+ // same initials-circle colour without any stored palette.
815
+ function avatarHue(s) {
816
+ var h = 0;
817
+ for (var i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) % 360;
818
+ return h;
819
+ }
820
+
821
+ // The avatar slot is ALWAYS rendered: a coloured initials circle derived from the
822
+ // handle. When the object carries an avatar URL (a Gravatar the CLI stamps at
823
+ // publish, or any future hosted photo), a real img is layered on top; onerror
824
+ // removes it so the initials underneath show through when the photo 404s. There
825
+ // is no CSP on the share page, so the inline onerror fallback is honoured.
826
+ function renderAvatar(handle, avatarUrl) {
827
+ var h = (handle || '').trim();
828
+ var initial = escapeHtml((h.charAt(0) || '?').toUpperCase());
829
+ var bg = 'hsl(' + avatarHue(h) + ',48%,42%)';
830
+ var inner = '<span class="ash-av-i">' + initial + '</span>';
831
+ if (avatarUrl) {
832
+ inner += '<img class="ash-av-img" src="' + escapeHtml(avatarUrl) + '" alt="" loading="lazy" ' +
833
+ 'referrerpolicy="no-referrer" onerror="this.remove()">';
834
+ }
835
+ return '<span class="ash-avatar" style="background:' + bg + ' !important" aria-hidden="true">' + inner + '</span>';
836
+ }
837
+
838
+ // A compact relative time for the "last updated" stat: "just now", "2h ago",
839
+ // "3d ago", or an ISO date once past ~30 days. Accepts a Date or a parseable
840
+ // string (R2's obj.uploaded is a Date).
841
+ function relativeTime(when) {
842
+ var then = when instanceof Date ? when.getTime() : Date.parse(when);
843
+ if (!then || isNaN(then)) return '';
844
+ var diff = Date.now() - then;
845
+ if (diff < 0) diff = 0;
846
+ var minute = 60000, hour = 3600000, day = 86400000;
847
+ if (diff < minute) return 'just now';
848
+ if (diff < hour) return Math.floor(diff / minute) + 'm ago';
849
+ if (diff < day) return Math.floor(diff / hour) + 'h ago';
850
+ if (diff < 30 * day) return Math.floor(diff / day) + 'd ago';
851
+ return new Date(then).toISOString().slice(0, 10);
852
+ }
853
+
854
+ // The four visibility levels the owner can pick, in menu order. Descriptions
855
+ // mirror the approved mockup. org always appears — a public-inbox owner still
856
+ // sees it and lets the server's 400 drive the failure message (never a hidden
857
+ // client-side rule). orgDomain is the owner's own email domain when known.
858
+ function visibilityLevels(orgDomain) {
859
+ return [
860
+ { key: 'public', label: 'Public', color: '#30a46c', icon: VIS_ICON.public, desc: 'Anyone with the link · preview card · listed in your gallery' },
861
+ { key: 'unlisted', label: 'Unlisted', color: '#9aa0a6', icon: VIS_ICON.unlisted, desc: 'Anyone with the link · no card · hidden from your gallery' },
862
+ { key: 'me', label: 'Only you', color: '#f59e0b', icon: VIS_ICON.me, desc: 'Sign-in required · only your account can open it' },
863
+ { 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' },
864
+ ];
865
+ }
866
+
867
+ function renderVisibilityMenu(current, orgDomain) {
868
+ var rows = visibilityLevels(orgDomain).map(function (l) {
869
+ 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') + '">' +
870
+ '<span class="ash-opt-ic" style="color:' + l.color + '">' + l.icon + '</span>' +
871
+ '<span class="ash-opt-t"><b>' + l.label + '</b><span>' + l.desc + '</span></span>' +
872
+ '<span class="ash-rd"></span></button>';
873
+ }).join('');
874
+ return '<div class="ash-menu" data-ash-menu role="menu" aria-hidden="true"><h4>Who can see this</h4>' + rows + '</div>';
875
+ }
876
+
877
+ function renderOwnerScript() {
878
+ return '<script>' + ASH_OWNER_JS + '</script>';
879
+ }
880
+
881
+ // opts: { isOwner, ownerDomain, views, uploaded } — all optional. isOwner turns
882
+ // the visibility chip into a live control (inline dropdown, Variation A) and
883
+ // injects the owner-only CSS/JS; views/uploaded render the right-side stats
884
+ // cluster. With no opts the bar is exactly today's static strip.
885
+ function renderAttributionBar(meta, handle, opts) {
886
+ var o = opts || {};
509
887
  var cm = meta || {};
510
- var chip = visibilityChip(cm.visibility || 'public', cm.org_domain);
888
+ var visibility = cm.visibility || 'public';
889
+ var chip = visibilityChip(visibility, cm.org_domain);
890
+ var avatar = renderAvatar(handle, cm.avatar);
511
891
  var left = '';
512
892
  // The handle is already the public URL namespace, so surfacing it leaks nothing new.
513
893
  if (handle) left += 'Shared by <strong>' + escapeHtml(handle) + '</strong>';
514
894
  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>';
895
+
896
+ // Stats cluster (👁 <n> views · updated <rel>) — right side, left of the chip.
897
+ var stats = '';
898
+ var statBits = '';
899
+ if (o.views != null) {
900
+ statBits += STAT_EYE_ICON + '<b>' + escapeHtml(String(o.views)) + '</b> ' + (Number(o.views) === 1 ? 'view' : 'views');
901
+ }
902
+ if (o.uploaded) {
903
+ var rel = relativeTime(o.uploaded);
904
+ if (rel) statBits += (statBits ? '<span class="ash-statsep">·</span>' : '') + 'updated <b>' + escapeHtml(rel) + '</b>';
905
+ }
906
+ if (statBits) stats = '<span class="ash-stats" title="page stats">' + statBits + '</span>';
907
+
908
+ // The visibility chip: an interactive control for the owner, a static cue for
909
+ // everyone else (unchanged from today).
910
+ var chipHtml;
911
+ var menu = '';
912
+ var script = '';
913
+ if (o.isOwner) {
914
+ chipHtml = '<span class="ash-chip ash-chip-own" data-ash-chip tabindex="0" role="button" aria-haspopup="menu" ' +
915
+ 'style="color:' + chip.color + ';border-color:' + chip.color + '66">' +
916
+ '<span data-ash-chip-ic>' + chip.icon + '</span><span data-ash-label>' + chip.label + '</span>' + CARET_ICON + '</span>';
917
+ menu = renderVisibilityMenu(visibility, o.ownerDomain);
918
+ script = renderOwnerScript();
919
+ } else {
920
+ chipHtml = '<span class="ash-chip" style="color:' + chip.color + ';border-color:' + chip.color + '66">' + chip.icon + '<span>' + chip.label + '</span></span>';
921
+ }
922
+
923
+ var right = stats + chipHtml;
516
924
  if (cm.date) right += '<span class="ash-date">' + escapeHtml(cm.date) + '</span>';
517
925
  // The load-bearing background + base colour ride an INLINE style so the host
518
926
  // page's own CSS can never wash the bar out (inline beats a page stylesheet);
519
927
  // the rest is a namespaced style block with !important on every colour so a
520
928
  // broad page rule (span selector to black, etc.) cannot make the text vanish.
929
+ // FIXED, full-viewport-width chrome (not sticky-inside-body — that inherited the
930
+ // page's max-width/margins and rendered as a floating box). html padding-top
931
+ // pushes the whole page down by the bar's height; flex-wrap:nowrap + ellipsis on
932
+ // the left keeps the bar exactly one line tall so the push height stays correct.
521
933
  return '<div class="agents-share-bar" role="contentinfo" aria-label="Sharing details" ' +
522
934
  'style="background:#0b0b0c !important;color:#e8e8e8 !important">' +
523
935
  '<style>' +
524
- '.agents-share-bar{all:initial;position:sticky;top:0;left:0;right:0;z-index:2147483647;box-sizing:border-box;display:flex !important;align-items:center;gap:12px;flex-wrap:wrap;' +
525
- 'padding:8px 16px !important;background:#0b0b0c !important;color:#e8e8e8 !important;border-bottom:1px solid #24242b !important;' +
526
- 'font:13px/1.45 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,system-ui,sans-serif !important}' +
936
+ 'html{padding-top:38px !important}' +
937
+ '.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;' +
938
+ '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;' +
939
+ 'font:13px/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,system-ui,sans-serif !important}' +
527
940
  '.agents-share-bar *{box-sizing:border-box;font-family:inherit}' +
528
- '.agents-share-bar .ash-left{display:flex;align-items:center;gap:8px;min-width:0;color:#cfd3d9 !important}' +
941
+ '.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)}' +
942
+ '.agents-share-bar .ash-av-i{color:#fff !important}' +
943
+ '.agents-share-bar .ash-av-img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;border-radius:50% !important}' +
944
+ '.agents-share-bar .ash-left{flex:1 1 auto;min-width:0;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;color:#cfd3d9 !important}' +
529
945
  '.agents-share-bar strong{color:#fff !important;font-weight:600}' +
530
- '.agents-share-bar .ash-dot{opacity:.4;margin:0 3px}' +
531
- '.agents-share-bar .ash-right{margin-left:auto;display:flex;align-items:center;gap:12px}' +
946
+ '.agents-share-bar .ash-dot{opacity:.4;margin:0 4px}' +
947
+ '.agents-share-bar .ash-right{flex:none;display:flex;align-items:center;gap:12px}' +
532
948
  '.agents-share-bar .ash-date{color:#9096a0 !important}' +
533
949
  '.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}' +
534
- '.agents-share-bar .ash-chip svg{flex:none}' +
950
+ '.agents-share-bar .ash-chip svg{flex:none;vertical-align:middle}' +
951
+ '.agents-share-bar .ash-stats{display:inline-flex;align-items:center;gap:5px;color:#9aa0a6 !important;font-size:12px;white-space:nowrap}' +
952
+ '.agents-share-bar .ash-stats b{color:#c9ced6 !important;font-weight:600}' +
953
+ '.agents-share-bar .ash-stats svg{flex:none;opacity:.85}' +
954
+ '.agents-share-bar .ash-statsep{opacity:.35;margin:0 3px}' +
955
+ (o.isOwner ? OWNER_BAR_CSS : '') +
535
956
  '</style>' +
957
+ avatar +
536
958
  '<span class="ash-left">' + left + '</span>' +
537
959
  '<span class="ash-right">' + right + '</span>' +
538
- '</div>';
960
+ menu +
961
+ (o.isOwner ? '<div class="ash-toast" data-ash-toast></div>' : '') +
962
+ '</div>' +
963
+ script;
539
964
  }
540
965
 
541
- function injectAttributionBar(html, meta, handle) {
542
- var bar = renderAttributionBar(meta, handle);
966
+ // Owner-only chip/menu/toast styling, added to the bar's <style> only when the
967
+ // viewer owns the namespace. Namespaced under .agents-share-bar with !important
968
+ // so the host page's CSS can't wash it out; z-index rides the bar's own stacking.
969
+ var OWNER_BAR_CSS =
970
+ '.agents-share-bar .ash-chip-own{cursor:pointer;transition:background .12s}' +
971
+ '.agents-share-bar .ash-chip-own:hover{background:rgba(255,255,255,.06)}' +
972
+ '.agents-share-bar .ash-caret{opacity:.6;margin-left:1px;flex:none}' +
973
+ '.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}' +
974
+ '.agents-share-bar .ash-menu.ash-open{display:block}' +
975
+ '.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}' +
976
+ '.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}' +
977
+ '.agents-share-bar .ash-opt:hover{background:rgba(255,255,255,.05)}' +
978
+ '.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)}' +
979
+ '.agents-share-bar .ash-opt-t{flex:1;min-width:0}' +
980
+ '.agents-share-bar .ash-opt-t b{display:block;font-size:13px;font-weight:600;color:#f0f1f3 !important}' +
981
+ '.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}' +
982
+ '.agents-share-bar .ash-rd{flex:none;width:16px;height:16px;border-radius:50%;border:2px solid #4a4f59;margin-top:3px}' +
983
+ '.agents-share-bar .ash-opt.ash-sel .ash-rd{border-color:#5b9dff;background:radial-gradient(circle at center,#5b9dff 0 5px,transparent 6px)}' +
984
+ '.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}' +
985
+ '.agents-share-bar .ash-toast.ash-show{display:inline-flex}' +
986
+ '.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}' +
987
+ '@keyframes ash-sp{to{transform:rotate(360deg)}}' +
988
+ '.agents-share-bar .ash-ok{color:#30a46c;font-weight:700}' +
989
+ '.agents-share-bar .ash-err{color:#ff6b6b;font-weight:700}';
990
+
991
+ function injectAttributionBar(html, meta, handle, opts) {
992
+ var bar = renderAttributionBar(meta, handle, opts);
543
993
  var m = /<body[^>]*>/i.exec(html);
544
994
  if (m) {
545
995
  var at = m.index + m[0].length;
@@ -548,6 +998,47 @@ function injectAttributionBar(html, meta, handle) {
548
998
  return bar + html;
549
999
  }
550
1000
 
1001
+ function acceptsHtml(request) {
1002
+ return (request.headers.get('accept') || '').indexOf('text/html') !== -1;
1003
+ }
1004
+
1005
+ // Assets we can embed in a viewer page. Everything else (JSON, .txt, arbitrary
1006
+ // downloads) is served raw — there is nothing useful to wrap it in.
1007
+ function viewableAssetKind(contentType) {
1008
+ var ct = (contentType || '').toLowerCase();
1009
+ if (ct.indexOf('image/') === 0) return 'image';
1010
+ if (ct.indexOf('video/') === 0) return 'video';
1011
+ if (ct.indexOf('audio/') === 0) return 'audio';
1012
+ if (ct.indexOf('application/pdf') === 0) return 'pdf';
1013
+ return '';
1014
+ }
1015
+
1016
+ // A minimal dark viewer page: the attribution bar on top, the asset centered
1017
+ // below. The media element points back at THIS url with ?raw so it loads the
1018
+ // stored bytes (not the viewer recursively) — and that ?raw fetch re-runs the
1019
+ // same me/org gate, so a private asset stays private inside its own viewer.
1020
+ function renderAssetViewer(pathname, kind, meta, handle, opts) {
1021
+ var rawUrl = escapeHtml(pathname + '?raw=1');
1022
+ var name = '';
1023
+ try { name = decodeURIComponent(pathname.split('/').pop() || 'file'); } catch (e) { name = pathname.split('/').pop() || 'file'; }
1024
+ var bar = renderAttributionBar(meta, handle, opts);
1025
+ var media = '';
1026
+ if (kind === 'image') media = '<img src="' + rawUrl + '" alt="' + escapeHtml(name) + '">';
1027
+ else if (kind === 'video') media = '<video src="' + rawUrl + '" controls playsinline></video>';
1028
+ else if (kind === 'audio') media = '<audio src="' + rawUrl + '" controls></audio>';
1029
+ else if (kind === 'pdf') media = '<iframe src="' + rawUrl + '" title="' + escapeHtml(name) + '"></iframe>';
1030
+ return '<!doctype html><html><head><meta charset="utf-8">' +
1031
+ '<meta name="viewport" content="width=device-width,initial-scale=1">' +
1032
+ '<title>' + escapeHtml(name) + '</title>' +
1033
+ '<style>html,body{margin:0;background:#0f0f11}' +
1034
+ 'body{min-height:100vh;display:flex;align-items:center;justify-content:center;padding:16px;box-sizing:border-box}' +
1035
+ '.ashv{max-width:100%;max-height:calc(100vh - 90px);display:flex;align-items:center;justify-content:center}' +
1036
+ '.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)}' +
1037
+ '.ashv audio{width:min(90vw,560px)}' +
1038
+ '.ashv iframe{width:min(94vw,1000px);height:calc(100vh - 90px);border:0;border-radius:8px;background:#fff}' +
1039
+ '</style></head><body>' + bar + '<div class="ashv">' + media + '</div></body></html>';
1040
+ }
1041
+
551
1042
  function json(body, status) {
552
1043
  return new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
553
1044
  }
@@ -649,19 +1140,47 @@ function isIdentityGated(visibility) {
649
1140
  return visibility === 'me' || visibility === 'org';
650
1141
  }
651
1142
 
652
- async function gateRestrictedGet(request, env, url, obj) {
1143
+ // Gate a me/org read given the ALREADY-RESOLVED viewer identity. Pure/sync: the
1144
+ // caller resolves the viewer once (it also needs the identity for the ownership
1145
+ // check) and both the page GET and the ?revisions=json path share this gate.
1146
+ function gateVisibility(url, env, obj, identity) {
653
1147
  const visibility = (obj.customMetadata && obj.customMetadata.visibility) || 'public';
654
1148
  if (!isIdentityGated(visibility)) return null;
655
- const viewer = await resolveViewer(request, env, url);
656
- if (viewer.redirect) return viewer.redirect;
657
- if (viewer.error) return viewer.error;
658
- if (!viewer.identity) return bounceToLogin(url, env);
659
- if (!viewerMayRead(visibility, obj.customMetadata, viewer.identity)) {
1149
+ if (!identity) return bounceToLogin(url, env);
1150
+ if (!viewerMayRead(visibility, obj.customMetadata, identity)) {
660
1151
  return new Response('not found', { status: 404, headers: { 'content-type': 'text/plain' } });
661
1152
  }
662
1153
  return null;
663
1154
  }
664
1155
 
1156
+ // Per-slug view counter, stored as a SEPARATE R2 object under __views/<path> so
1157
+ // counting a view never rewrites the page object (which would reset its uploaded
1158
+ // timestamp and corrupt "last updated"). Best-effort telemetry: a read/write
1159
+ // failure must never break page serving, so both degrade to a no-op rather than
1160
+ // throwing. R2 has no atomic increment, so two simultaneous views can race and
1161
+ // lose a count — acceptable for an approximate visitor count.
1162
+ async function readViews(env, path) {
1163
+ try {
1164
+ const obj = await env.BUCKET.get('__views/' + path);
1165
+ if (!obj) return 0;
1166
+ const raw = typeof obj.text === 'function' ? await obj.text() : '';
1167
+ const n = parseInt(raw, 10);
1168
+ return Number.isFinite(n) && n >= 0 ? n : 0;
1169
+ } catch {
1170
+ return 0;
1171
+ }
1172
+ }
1173
+
1174
+ async function writeViews(env, path, count) {
1175
+ try {
1176
+ await env.BUCKET.put('__views/' + path, String(count), {
1177
+ httpMetadata: { contentType: 'text/plain; charset=utf-8' },
1178
+ });
1179
+ } catch {
1180
+ // best-effort: a failed counter write must never surface to the visitor
1181
+ }
1182
+ }
1183
+
665
1184
  function viewerMayRead(visibility, meta, identity) {
666
1185
  if (visibility === 'me') {
667
1186
  const owner = meta && meta.owner;
@@ -833,17 +1352,28 @@ async function defaultVerifyPhoenixToken(request, env) {
833
1352
  return { userId: body.userId, email: typeof body.email === 'string' ? body.email : '' };
834
1353
  }
835
1354
 
836
- // Two legitimate principals: WRITE_TOKEN equality first (admin/BYO), otherwise
837
- // the bearer is a Phoenix token and we verify it. 401 when neither authenticates.
1355
+ // Write principals, in order: WRITE_TOKEN equality (admin/BYO), then a Phoenix
1356
+ // bearer, then the signed-in viewer's __share cookie. The cookie is an
1357
+ // HMAC-signed {userId,email} proof (identityFromCookie verifies it), which lets
1358
+ // the shared page's inline visibility control PATCH with credentials:'include'
1359
+ // and no bearer — the browser has the identity cookie, not a token. It cannot be
1360
+ // abused cross-site: the cookie is SameSite=Lax, which blocks it from riding a
1361
+ // cross-origin non-GET (CSRF), and namespace/owner enforcement on PUT/PATCH/
1362
+ // DELETE still confines a cookie holder to their own pages. 401 when none apply.
838
1363
  async function authorizeWrite(request, env) {
839
1364
  const presented = (request.headers.get('authorization') || '').replace(/^Bearer\\s+/i, '');
840
- if (!presented) return { error: json({ error: 'unauthorized' }, 401) };
841
- if (env.WRITE_TOKEN && safeEqual(presented, env.WRITE_TOKEN)) {
842
- return { kind: 'byo', owner: env.SHARE_NAMESPACE || 'byo' };
1365
+ if (presented) {
1366
+ if (env.WRITE_TOKEN && safeEqual(presented, env.WRITE_TOKEN)) {
1367
+ return { kind: 'byo', owner: env.SHARE_NAMESPACE || 'byo' };
1368
+ }
1369
+ const claims = await hooks.verifyPhoenixToken(request, env);
1370
+ if (claims && typeof claims.userId === 'string' && claims.userId) {
1371
+ return { kind: 'phoenix', owner: claims.userId, email: typeof claims.email === 'string' ? claims.email : '' };
1372
+ }
843
1373
  }
844
- const claims = await hooks.verifyPhoenixToken(request, env);
845
- if (claims && typeof claims.userId === 'string' && claims.userId) {
846
- return { kind: 'phoenix', owner: claims.userId, email: typeof claims.email === 'string' ? claims.email : '' };
1374
+ const cookieId = await identityFromCookie(request, env);
1375
+ if (cookieId && cookieId.userId) {
1376
+ return { kind: 'phoenix', owner: cookieId.userId, email: typeof cookieId.email === 'string' ? cookieId.email : '' };
847
1377
  }
848
1378
  return { error: json({ error: 'unauthorized' }, 401) };
849
1379
  }