@songsid/agend 2.2.0 → 2.3.0-beta.2

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 (298) hide show
  1. package/dist/agent-cli.js +248 -2
  2. package/dist/agent-cli.js.map +1 -1
  3. package/dist/agent-endpoint.d.ts +2 -0
  4. package/dist/agent-endpoint.js +25 -0
  5. package/dist/agent-endpoint.js.map +1 -1
  6. package/dist/agent-file-read.d.ts +35 -0
  7. package/dist/agent-file-read.js +144 -0
  8. package/dist/agent-file-read.js.map +1 -0
  9. package/dist/agent-whats-new.js +8 -0
  10. package/dist/agent-whats-new.js.map +1 -1
  11. package/dist/atomic-write.js +9 -1
  12. package/dist/atomic-write.js.map +1 -1
  13. package/dist/auth-api.js +17 -0
  14. package/dist/auth-api.js.map +1 -1
  15. package/dist/backend/antigravity.js +3 -3
  16. package/dist/backend/antigravity.js.map +1 -1
  17. package/dist/backend/claude-code.js +5 -1
  18. package/dist/backend/claude-code.js.map +1 -1
  19. package/dist/backend/launch-effort.js +1 -1
  20. package/dist/backend/launch-effort.js.map +1 -1
  21. package/dist/backend/muse.js +10 -2
  22. package/dist/backend/muse.js.map +1 -1
  23. package/dist/backend/types.d.ts +8 -0
  24. package/dist/backend/types.js +10 -0
  25. package/dist/backend/types.js.map +1 -1
  26. package/dist/cache-analysis.d.ts +47 -1
  27. package/dist/cache-analysis.js +68 -8
  28. package/dist/cache-analysis.js.map +1 -1
  29. package/dist/cache-report-tool.d.ts +17 -0
  30. package/dist/cache-report-tool.js +40 -0
  31. package/dist/cache-report-tool.js.map +1 -0
  32. package/dist/cache-service.d.ts +15 -0
  33. package/dist/cache-service.js +26 -1
  34. package/dist/cache-service.js.map +1 -1
  35. package/dist/channel/adapters/discord.d.ts +2 -0
  36. package/dist/channel/adapters/discord.js +17 -2
  37. package/dist/channel/adapters/discord.js.map +1 -1
  38. package/dist/channel/adapters/telegram.d.ts +8 -1
  39. package/dist/channel/adapters/telegram.js +74 -4
  40. package/dist/channel/adapters/telegram.js.map +1 -1
  41. package/dist/channel/mcp-tools.js +36 -0
  42. package/dist/channel/mcp-tools.js.map +1 -1
  43. package/dist/channel/types.d.ts +15 -0
  44. package/dist/cli.js +7 -3
  45. package/dist/cli.js.map +1 -1
  46. package/dist/config-validator.js +22 -2
  47. package/dist/config-validator.js.map +1 -1
  48. package/dist/daemon.js +244 -20
  49. package/dist/daemon.js.map +1 -1
  50. package/dist/fleet-context.d.ts +5 -0
  51. package/dist/fleet-manager.d.ts +92 -0
  52. package/dist/fleet-manager.js +410 -29
  53. package/dist/fleet-manager.js.map +1 -1
  54. package/dist/general-knowledge/skills/credential-profiles/SKILL.md +7 -0
  55. package/dist/general-knowledge/skills/web-appearance/SKILL.md +367 -0
  56. package/dist/instance-avatar.js +3 -0
  57. package/dist/instance-avatar.js.map +1 -1
  58. package/dist/instance-config-impact.js +3 -0
  59. package/dist/instance-config-impact.js.map +1 -1
  60. package/dist/instance-lifecycle.js +9 -4
  61. package/dist/instance-lifecycle.js.map +1 -1
  62. package/dist/needs-you-hub.d.ts +4 -0
  63. package/dist/needs-you-hub.js +31 -6
  64. package/dist/needs-you-hub.js.map +1 -1
  65. package/dist/needs-you.d.ts +1 -13
  66. package/dist/needs-you.js +3 -37
  67. package/dist/needs-you.js.map +1 -1
  68. package/dist/outbound-handlers.d.ts +7 -0
  69. package/dist/outbound-handlers.js +65 -10
  70. package/dist/outbound-handlers.js.map +1 -1
  71. package/dist/outbound-schemas.d.ts +74 -0
  72. package/dist/outbound-schemas.js +56 -0
  73. package/dist/outbound-schemas.js.map +1 -1
  74. package/dist/public-web-gateway.js +14 -0
  75. package/dist/public-web-gateway.js.map +1 -1
  76. package/dist/repo-worktree-guard.d.ts +36 -0
  77. package/dist/repo-worktree-guard.js +143 -0
  78. package/dist/repo-worktree-guard.js.map +1 -0
  79. package/dist/scheduler/scheduler.d.ts +70 -1
  80. package/dist/scheduler/scheduler.js +256 -17
  81. package/dist/scheduler/scheduler.js.map +1 -1
  82. package/dist/service-installer.d.ts +2 -1
  83. package/dist/service-installer.js +18 -3
  84. package/dist/service-installer.js.map +1 -1
  85. package/dist/settings-api.js +74 -1
  86. package/dist/settings-api.js.map +1 -1
  87. package/dist/settings-change.js +8 -0
  88. package/dist/settings-change.js.map +1 -1
  89. package/dist/settings-effect.js +16 -2
  90. package/dist/settings-effect.js.map +1 -1
  91. package/dist/settings-http-confirmation.js +5 -0
  92. package/dist/settings-http-confirmation.js.map +1 -1
  93. package/dist/tool-permissions.d.ts +5 -0
  94. package/dist/tool-permissions.js +61 -2
  95. package/dist/tool-permissions.js.map +1 -1
  96. package/dist/topic-commands.d.ts +10 -6
  97. package/dist/topic-commands.js +21 -17
  98. package/dist/topic-commands.js.map +1 -1
  99. package/dist/topic-link.d.ts +128 -0
  100. package/dist/topic-link.js +269 -0
  101. package/dist/topic-link.js.map +1 -0
  102. package/dist/types.d.ts +13 -0
  103. package/dist/ui/app-effects.js +187 -0
  104. package/dist/ui/chat-effect-posters.js +42 -0
  105. package/dist/ui/chat-effects.js +72 -0
  106. package/dist/ui/chat-live-replies.js +58 -0
  107. package/dist/ui/chat-render.js +4 -4
  108. package/dist/ui/chat-store.js +9 -3
  109. package/dist/ui/chat-strings.js +15 -0
  110. package/dist/ui/chat-thread.js +47 -21
  111. package/dist/ui/chat-tour.js +1 -1
  112. package/dist/ui/effect-trial-controller.js +119 -0
  113. package/dist/ui/effect-trial-offer.js +31 -0
  114. package/dist/ui/effect-trial-panel.js +37 -0
  115. package/dist/ui/fleet-cache.js +23 -1
  116. package/dist/ui/image-lightbox.js +1 -1
  117. package/dist/ui/panel-chat.js +85 -17
  118. package/dist/ui/panel-details.js +18 -5
  119. package/dist/ui/panel-fleet.js +3 -3
  120. package/dist/ui/panel-log.js +6 -6
  121. package/dist/ui/panel-needs.js +3 -3
  122. package/dist/ui/panel-settings.js +8 -5
  123. package/dist/ui/preview-panel.js +25 -9
  124. package/dist/ui/settings-appearance.js +325 -0
  125. package/dist/ui/settings-effects.js +201 -0
  126. package/dist/ui/settings-mods.js +257 -0
  127. package/dist/ui/settings-owner-effects.js +80 -0
  128. package/dist/ui/settings-strings.js +245 -0
  129. package/dist/ui/shared/agend-auth.js +27 -3
  130. package/dist/ui/shared/agent-widget.js +248 -0
  131. package/dist/ui/shared/app-i18n.js +2 -0
  132. package/dist/ui/shared/app-prefs.js +30 -10
  133. package/dist/ui/shared/app-route.js +23 -1
  134. package/dist/ui/shared/app-session.js +1 -1
  135. package/dist/ui/shared/app-shell.js +29 -16
  136. package/dist/ui/shared/app-store.js +2 -0
  137. package/dist/ui/shared/app-stream.js +12 -7
  138. package/dist/ui/shared/app-themes.js +188 -0
  139. package/dist/ui/shared/app.css +225 -3
  140. package/dist/ui/shared/app.js +6 -0
  141. package/dist/ui/shared/card-strings.js +28 -0
  142. package/dist/ui/shared/effect-app-runtime.js +248 -0
  143. package/dist/ui/shared/effect-baseline.js +64 -0
  144. package/dist/ui/shared/effect-chat-runtime.js +154 -0
  145. package/dist/ui/shared/effect-cursor.js +125 -0
  146. package/dist/ui/shared/effect-frame.js +127 -0
  147. package/dist/ui/shared/effect-geometry.js +122 -0
  148. package/dist/ui/shared/effect-host-style.js +10 -0
  149. package/dist/ui/shared/effect-owner-regions.js +74 -0
  150. package/dist/ui/shared/effect-owner-source.js +115 -0
  151. package/dist/ui/shared/effect-policy.js +137 -0
  152. package/dist/ui/shared/effect-poster-reader.js +98 -0
  153. package/dist/ui/shared/effect-posters.js +117 -0
  154. package/dist/ui/shared/effect-profiler.js +162 -0
  155. package/dist/ui/shared/effect-profiles.js +65 -0
  156. package/dist/ui/shared/effect-registry.js +496 -0
  157. package/dist/ui/shared/effect-runtime-loader.js +234 -0
  158. package/dist/ui/shared/effect-safety.js +153 -0
  159. package/dist/ui/shared/effect-stock-snapshot.js +34 -0
  160. package/dist/ui/shared/effect-stock-trust.js +165 -0
  161. package/dist/ui/shared/effect-trial-source.js +103 -0
  162. package/dist/ui/shared/effect-trial-stage.js +147 -0
  163. package/dist/ui/shared/header-tools.js +1 -1
  164. package/dist/ui/shared/identity-card.js +202 -0
  165. package/dist/ui/shared/identity-color.js +261 -0
  166. package/dist/ui/shared/instance-nav.js +37 -19
  167. package/dist/ui/shared/owner-css.js +221 -0
  168. package/dist/ui/shared/owner-effect-prefs.js +117 -0
  169. package/dist/ui/shared/panel-view.js +81 -8
  170. package/dist/ui/shared/signin.js +2 -0
  171. package/dist/ui/shared/ui-dialog.js +1 -1
  172. package/dist/ui/shared/ui-icons.js +2 -0
  173. package/dist/ui/shared/ui-menu.js +1 -1
  174. package/dist/ui/shared/ui-toast.js +1 -1
  175. package/dist/ui/shared/usage-dialog.js +14 -2
  176. package/dist/ui/shared/view-strings.js +36 -2
  177. package/dist/ui/signin.html +1 -0
  178. package/dist/usage/providers.d.ts +3 -0
  179. package/dist/usage/providers.js +4 -3
  180. package/dist/usage/providers.js.map +1 -1
  181. package/dist/view-api.d.ts +29 -0
  182. package/dist/view-api.js +188 -8
  183. package/dist/view-api.js.map +1 -1
  184. package/dist/web-api.d.ts +42 -0
  185. package/dist/web-api.js +898 -36
  186. package/dist/web-api.js.map +1 -1
  187. package/dist/web-appearance-limits.d.ts +82 -0
  188. package/dist/web-appearance-limits.js +83 -0
  189. package/dist/web-appearance-limits.js.map +1 -0
  190. package/dist/web-appearance-policy.d.ts +66 -0
  191. package/dist/web-appearance-policy.js +94 -0
  192. package/dist/web-appearance-policy.js.map +1 -0
  193. package/dist/web-appearance-tools.d.ts +50 -0
  194. package/dist/web-appearance-tools.js +105 -0
  195. package/dist/web-appearance-tools.js.map +1 -0
  196. package/dist/web-appearance-view.d.ts +99 -0
  197. package/dist/web-appearance-view.js +163 -0
  198. package/dist/web-appearance-view.js.map +1 -0
  199. package/dist/web-appearance.d.ts +79 -0
  200. package/dist/web-appearance.js +298 -0
  201. package/dist/web-appearance.js.map +1 -0
  202. package/dist/web-asset-parts.d.ts +93 -0
  203. package/dist/web-asset-parts.js +133 -0
  204. package/dist/web-asset-parts.js.map +1 -0
  205. package/dist/web-auth.js +17 -1
  206. package/dist/web-auth.js.map +1 -1
  207. package/dist/web-card.d.ts +96 -0
  208. package/dist/web-card.js +232 -0
  209. package/dist/web-card.js.map +1 -0
  210. package/dist/web-chat-history.d.ts +13 -0
  211. package/dist/web-chat-history.js +23 -2
  212. package/dist/web-chat-history.js.map +1 -1
  213. package/dist/web-effect-bundle.d.ts +28 -0
  214. package/dist/web-effect-bundle.js +126 -0
  215. package/dist/web-effect-bundle.js.map +1 -0
  216. package/dist/web-effect-canonical.d.ts +8 -0
  217. package/dist/web-effect-canonical.js +27 -0
  218. package/dist/web-effect-canonical.js.map +1 -0
  219. package/dist/web-effect-library.d.ts +114 -0
  220. package/dist/web-effect-library.js +530 -0
  221. package/dist/web-effect-library.js.map +1 -0
  222. package/dist/web-effect-poster.d.ts +8 -0
  223. package/dist/web-effect-poster.js +52 -0
  224. package/dist/web-effect-poster.js.map +1 -0
  225. package/dist/web-effect-profiles.d.ts +118 -0
  226. package/dist/web-effect-profiles.js +180 -0
  227. package/dist/web-effect-profiles.js.map +1 -0
  228. package/dist/web-effect-service.d.ts +86 -0
  229. package/dist/web-effect-service.js +314 -0
  230. package/dist/web-effect-service.js.map +1 -0
  231. package/dist/web-effect-tools.d.ts +47 -0
  232. package/dist/web-effect-tools.js +129 -0
  233. package/dist/web-effect-tools.js.map +1 -0
  234. package/dist/web-effect-trials.d.ts +86 -0
  235. package/dist/web-effect-trials.js +204 -0
  236. package/dist/web-effect-trials.js.map +1 -0
  237. package/dist/web-effect-upload.d.ts +27 -0
  238. package/dist/web-effect-upload.js +185 -0
  239. package/dist/web-effect-upload.js.map +1 -0
  240. package/dist/web-effect-worker.d.ts +8 -0
  241. package/dist/web-effect-worker.js +170 -0
  242. package/dist/web-effect-worker.js.map +1 -0
  243. package/dist/web-effect.d.ts +103 -0
  244. package/dist/web-effect.js +153 -0
  245. package/dist/web-effect.js.map +1 -0
  246. package/dist/web-identity-color.d.ts +87 -0
  247. package/dist/web-identity-color.js +209 -0
  248. package/dist/web-identity-color.js.map +1 -0
  249. package/dist/web-identity-palette.d.ts +113 -0
  250. package/dist/web-identity-palette.js +84 -0
  251. package/dist/web-identity-palette.js.map +1 -0
  252. package/dist/web-image-meta.d.ts +25 -0
  253. package/dist/web-image-meta.js +200 -0
  254. package/dist/web-image-meta.js.map +1 -0
  255. package/dist/web-mod-agent.d.ts +50 -0
  256. package/dist/web-mod-agent.js +205 -0
  257. package/dist/web-mod-agent.js.map +1 -0
  258. package/dist/web-mod-bundle.d.ts +149 -0
  259. package/dist/web-mod-bundle.js +470 -0
  260. package/dist/web-mod-bundle.js.map +1 -0
  261. package/dist/web-mod-export.d.ts +59 -0
  262. package/dist/web-mod-export.js +79 -0
  263. package/dist/web-mod-export.js.map +1 -0
  264. package/dist/web-mod-store.d.ts +283 -0
  265. package/dist/web-mod-store.js +625 -0
  266. package/dist/web-mod-store.js.map +1 -0
  267. package/dist/web-org.d.ts +3 -3
  268. package/dist/web-org.js +11 -17
  269. package/dist/web-org.js.map +1 -1
  270. package/dist/web-owner-css-store.d.ts +180 -0
  271. package/dist/web-owner-css-store.js +384 -0
  272. package/dist/web-owner-css-store.js.map +1 -0
  273. package/dist/web-owner-css.d.ts +58 -0
  274. package/dist/web-owner-css.js +552 -0
  275. package/dist/web-owner-css.js.map +1 -0
  276. package/dist/web-prefs.d.ts +48 -0
  277. package/dist/web-prefs.js +209 -33
  278. package/dist/web-prefs.js.map +1 -1
  279. package/dist/web-preview.d.ts +9 -1
  280. package/dist/web-preview.js +24 -7
  281. package/dist/web-preview.js.map +1 -1
  282. package/dist/web-settings-patch.d.ts +18 -0
  283. package/dist/web-settings-patch.js +51 -0
  284. package/dist/web-settings-patch.js.map +1 -1
  285. package/dist/web-shell-routes.d.ts +1 -1
  286. package/dist/web-shell-routes.js +1 -1
  287. package/dist/web-shell-routes.js.map +1 -1
  288. package/dist/web-theme-store.d.ts +218 -0
  289. package/dist/web-theme-store.js +516 -0
  290. package/dist/web-theme-store.js.map +1 -0
  291. package/dist/web-theme.d.ts +121 -0
  292. package/dist/web-theme.js +407 -0
  293. package/dist/web-theme.js.map +1 -0
  294. package/dist/web-widget.d.ts +72 -0
  295. package/dist/web-widget.js +245 -0
  296. package/dist/web-widget.js.map +1 -0
  297. package/package.json +1 -1
  298. package/templates/systemd.service.ejs +8 -0
package/dist/web-api.js CHANGED
@@ -3,6 +3,12 @@ import { withSpawnHoldReply } from "./spawn-hold-reply.js";
3
3
  import { binaryProbe } from "./binary-probe.js";
4
4
  import { settingsRequestExecution, settingsWrite, isSettingsReplay } from "./settings-request-capability.js";
5
5
  import { readBoundedWebBody } from "./web-body.js";
6
+ import { MOD_MAX_BYTES } from "./web-mod-bundle.js";
7
+ import { buildModBundle } from "./web-mod-export.js";
8
+ import { AGENT_WIDGET_SLOTS, checkWidgetData, checkWidgetSpec } from "./web-widget.js";
9
+ import { checkOwnerCss, ownerCssAssetRefs } from "./web-owner-css.js";
10
+ import { canonicalText } from "./web-theme.js";
11
+ import { EFFECT_WIRE_LIMIT } from "./web-effect-upload.js";
6
12
  import { gatewayRequestContext } from "./web-request-context.js";
7
13
  import { permitWebContinuation } from "./web-continuation.js";
8
14
  /**
@@ -28,13 +34,14 @@ import { dashboardOriginFor } from "./web-preview.js";
28
34
  import { newWebMessageId, parseLastEventId } from "./web-chat-history.js";
29
35
  import { attachmentDelivery, createPartFile, displayName, fileTooLargeText, INLINE_MIME, isFileId, MEDIA_RANGE_MAX, publicAttachment, UPLOAD_LIMITS, UPLOAD_MAX_MS, UPLOAD_STALL_MS, UploadSniffer, uploadLimits, wellFormed } from "./web-upload.js";
30
36
  import { getAgendHome } from "./paths.js";
31
- import { OPERATOR } from "./web-prefs.js";
32
- import { WEB_NAME_FALLBACK } from "./web-profile.js";
37
+ import { OPERATOR, isOwnerEffectKey } from "./web-prefs.js";
38
+ import { WEB_NAME_FALLBACK, webPersonKey } from "./web-profile.js";
33
39
  import { commitTeamCreate, commitTeamUpdate } from "./outbound-handlers.js";
34
40
  import { parseMentionQuery } from "./web-mentions.js";
35
41
  import { authorizeExplicitInstanceRemoval } from "./instance-removal.js";
36
42
  import { measureSyncWork } from "./sync-work-attribution.js";
37
43
  import { resolveTz } from "./tz-utils.js";
44
+ import { filesPlayInChat } from "./web-effect.js";
38
45
  // ── Strict public-facing schemas ────────────────────────────────────────────
39
46
  // web-api endpoints must reject unknown fields so the dashboard cannot inject
40
47
  // internal-only flags that would reach handleCreate/scheduler/config writers.
@@ -150,6 +157,702 @@ function stillAuthorized(req, res, url, ctx) {
150
157
  json(res, 401, { error: WEB_TOKEN_INVALID_MESSAGE });
151
158
  return false;
152
159
  }
160
+ /**
161
+ * #1629 r4 §3: the owner-CSS routes (see their call site). `who()`, asked first and again after any body: the person a
162
+ * signed-in human session is, or the status to answer.
163
+ */
164
+ function handleOwnerCss(req, res, url, ctx, path, method) {
165
+ const store = ctx.ownerCss;
166
+ if (!store) {
167
+ json(res, 404, { error: "Owner CSS is not kept on this fleet" });
168
+ return true;
169
+ }
170
+ const reading = method === "GET" || method === "HEAD";
171
+ if (gatewayRequestContext(req)) {
172
+ json(res, reading ? 404 : 403, { error: "owner_css_not_over_public_link" });
173
+ return true;
174
+ }
175
+ const who = () => {
176
+ const verdict = evaluateWebRequest(req, url, ctx.webToken ?? null, ctx.webSessions, { touch: false });
177
+ if (verdict.kind !== "allow")
178
+ return { status: 401, error: WEB_TOKEN_INVALID_MESSAGE };
179
+ if (verdict.via !== "session" || !verdict.session)
180
+ return { status: 403, error: "owner_css_needs_a_signed_in_person" };
181
+ if (!reading && verdict.session.tier !== "admin")
182
+ return { status: 403, error: "owner_css_read_only" };
183
+ return { person: webPersonKey(verdict.session.owner) };
184
+ };
185
+ const first = who();
186
+ if ("status" in first) {
187
+ json(res, first.status, { error: first.error });
188
+ return true;
189
+ }
190
+ const again = () => {
191
+ if (!permitWebContinuation(req, res, ctx))
192
+ return null;
193
+ const now = who();
194
+ if ("status" in now) {
195
+ json(res, now.status, { error: now.error });
196
+ return null;
197
+ }
198
+ if (now.person !== first.person) {
199
+ json(res, 409, { error: "owner_css_person_changed" });
200
+ return null;
201
+ }
202
+ return now.person;
203
+ };
204
+ const announce = () => ctx.emitSseEvent("status", ctx.getUiStatus()); // their other devices see the new prefs rev
205
+ const fail = (err) => {
206
+ ctx.logger.error({ err: err.message }, "Owner CSS request failed");
207
+ try {
208
+ json(res, 500, { error: "Owner CSS request failed" });
209
+ }
210
+ catch { /* already answered */ }
211
+ };
212
+ if (path === "/ui/owner-css.css" && method === "GET") {
213
+ const v = url.searchParams.get("v");
214
+ const sha = v ?? store.state(first.person).active;
215
+ const text = sha ? store.served(first.person, sha) : null;
216
+ if (text === null) {
217
+ json(res, 404, { error: "no owner CSS" });
218
+ return true;
219
+ }
220
+ res.writeHead(200, {
221
+ "Content-Type": "text/css; charset=utf-8", "X-Content-Type-Options": "nosniff",
222
+ // Keyed by its sha (?v): a version's text never changes. Without ?v it is whatever is in force now.
223
+ "Cache-Control": v ? "private, max-age=31536000, immutable" : "private, no-store",
224
+ });
225
+ res.end(text);
226
+ return true;
227
+ }
228
+ const assetPath = /^\/ui\/owner-css\/asset\/([0-9a-f]{32})$/.exec(path);
229
+ if (assetPath && method === "GET") {
230
+ const asset = store.asset(first.person, assetPath[1]);
231
+ if (!asset) {
232
+ json(res, 404, { error: "no such asset" });
233
+ return true;
234
+ }
235
+ res.writeHead(200, {
236
+ "Content-Type": asset.mime, "X-Content-Type-Options": "nosniff", "Cache-Control": "private, max-age=31536000, immutable",
237
+ "Content-Security-Policy": "default-src 'none'; sandbox",
238
+ });
239
+ res.end(asset.bytes);
240
+ return true;
241
+ }
242
+ if (path === "/ui/owner-css/state" && method === "GET") {
243
+ json(res, 200, store.state(first.person));
244
+ return true;
245
+ }
246
+ if (assetPath && method === "DELETE") {
247
+ const removed = store.removeAsset(first.person, assetPath[1]);
248
+ json(res, removed.ok ? 200 : removed.status, removed.ok ? { ok: true } : { error: removed.error });
249
+ return true;
250
+ }
251
+ if (method !== "POST" || !["/ui/owner-css", "/ui/owner-css/keep", "/ui/owner-css/revert", "/ui/owner-css/restore", "/ui/owner-css/asset"].includes(path)) {
252
+ json(res, 404, { error: "not found" });
253
+ return true;
254
+ }
255
+ (async () => {
256
+ if (path === "/ui/owner-css/asset") {
257
+ let bytes;
258
+ try {
259
+ bytes = await readBoundedWebBody(req, 1024 * 1024);
260
+ }
261
+ catch {
262
+ json(res, 413, { error: "asset_quota", detail: "at most 1 MiB" });
263
+ return;
264
+ }
265
+ const person = again();
266
+ if (!person)
267
+ return;
268
+ const added = store.addAsset(person, bytes);
269
+ if (!added.ok) {
270
+ json(res, added.status, { error: added.error, ...(added.detail ? { detail: added.detail } : {}) });
271
+ return;
272
+ }
273
+ json(res, 200, { id: added.id, kind: added.kind, bytes: added.bytes });
274
+ return;
275
+ }
276
+ let body;
277
+ try {
278
+ body = await parseBody(req);
279
+ }
280
+ catch {
281
+ json(res, 400, { error: "Invalid JSON" });
282
+ return;
283
+ }
284
+ const person = again();
285
+ if (!person)
286
+ return;
287
+ if (!body || typeof body !== "object" || Array.isArray(body)) {
288
+ json(res, 400, { error: "an object" });
289
+ return;
290
+ }
291
+ if (path === "/ui/owner-css") {
292
+ const saved = store.save(person, body.text);
293
+ if (!saved.ok) {
294
+ json(res, saved.status, { error: saved.error, ...(saved.detail ? { detail: saved.detail } : {}) });
295
+ return;
296
+ }
297
+ announce();
298
+ json(res, 200, { sha: saved.sha, previous: saved.previous, trial: saved.trial });
299
+ }
300
+ else if (path === "/ui/owner-css/keep") {
301
+ const kept = store.keep(person, body.trial);
302
+ if (!kept.ok) {
303
+ json(res, kept.status, { error: kept.error });
304
+ return;
305
+ }
306
+ announce(); // the person's other devices drop their bar
307
+ json(res, 200, { active: kept.active });
308
+ }
309
+ else if (path === "/ui/owner-css/revert") {
310
+ // Exactly this trial, while it still is one: a trial kept (or ended) on another device meanwhile stays (409).
311
+ const reverted = store.revertTrial(person, body.trial);
312
+ if (!reverted.ok) {
313
+ json(res, reverted.status, { error: reverted.error });
314
+ return;
315
+ }
316
+ announce();
317
+ json(res, 200, { active: reverted.active });
318
+ }
319
+ else {
320
+ const restored = store.restore(person, body.sha);
321
+ if (!restored.ok) {
322
+ json(res, restored.status, { error: restored.error });
323
+ return;
324
+ }
325
+ announce();
326
+ json(res, 200, { active: restored.active });
327
+ }
328
+ })().catch(fail);
329
+ return true;
330
+ }
331
+ /** #1629: static current-placement posters, separate from the local human-only runtime API. */
332
+ function handleEffectPosters(res, url, ctx, path, method) {
333
+ res.setHeader("Cache-Control", "private, no-store");
334
+ res.setHeader("X-Content-Type-Options", "nosniff");
335
+ const image = /^\/ui\/effect-posters\/([0-9a-f]{32})\.png$/.exec(path);
336
+ const names = [...url.searchParams.keys()], instance = url.searchParams.get("instance");
337
+ if (method !== "GET" || path !== "/ui/effect-posters" && !image || names.length !== 1 || names[0] !== "instance" || !instance || instance.length > 128) {
338
+ json(res, 404, { error: "not_found" });
339
+ return true;
340
+ }
341
+ const service = ctx.effectService;
342
+ if (!service) {
343
+ json(res, 404, { error: "effects_unavailable" });
344
+ return true;
345
+ }
346
+ if (image) {
347
+ const r = service.placedPoster(image[1], instance);
348
+ if (!r.ok)
349
+ json(res, 404, { error: "not_found" });
350
+ else {
351
+ res.writeHead(200, { "Content-Type": "image/png", "Content-Security-Policy": "default-src 'none'; sandbox" });
352
+ res.end(r.bytes);
353
+ }
354
+ }
355
+ else {
356
+ const result = service.posterPlacements(instance);
357
+ json(res, result.error === "themes_unreadable" ? 503 : result.error ? 404 : 200, result);
358
+ }
359
+ return true;
360
+ }
361
+ /** #1629 Try: separate local-admin routes. These never borrow ordinary read-tier/placement authority. */
362
+ function handleEffectTrials(req, res, url, ctx, path, method) {
363
+ res.setHeader("Cache-Control", "private, no-store");
364
+ res.setHeader("X-Content-Type-Options", "nosniff");
365
+ const reading = method === "GET";
366
+ if (gatewayRequestContext(req)) {
367
+ json(res, reading ? 404 : 403, { error: "trials_not_over_public_link" });
368
+ return true;
369
+ }
370
+ const who = () => {
371
+ const sessions = ctx.webSessions;
372
+ const v = evaluateWebRequest(req, url, ctx.webToken ?? null, sessions, { touch: false });
373
+ if (v.kind !== "allow")
374
+ return { error: WEB_TOKEN_INVALID_MESSAGE, status: 401 };
375
+ if (v.via !== "session" || v.session.tier !== "admin" || v.session.surface !== "local")
376
+ return { error: "trials_need_a_local_admin", status: 403 };
377
+ const person = webPersonKey(v.session.owner);
378
+ let css = "unknown";
379
+ try {
380
+ const value = ctx.ownerCss?.effectState(person);
381
+ if (value === "off" || value === "on")
382
+ css = value;
383
+ }
384
+ catch { /* unavailable is never off */ }
385
+ // CSS remains a checked runtime value. The authority refuses anything except off on start/read.
386
+ const actor = Object.freeze({ via: "session", surface: "local", tier: "admin", sessionHash: v.session.idHash, epoch: v.session.handle, person, ownerCss: css });
387
+ return { actor, record: v.session, sessions };
388
+ };
389
+ const first = who();
390
+ if ("error" in first) {
391
+ json(res, first.status, { error: first.error });
392
+ return true;
393
+ }
394
+ const service = ctx.effectService;
395
+ if (!service) {
396
+ json(res, 404, { error: "trials_unavailable" });
397
+ return true;
398
+ }
399
+ const start = path === "/ui/effect-trials/start";
400
+ const route = /^\/ui\/effect-trials\/([0-9a-f]{32})\/(resource|poster\.png|profile|stop)$/.exec(path);
401
+ const resource = reading && route && ["resource", "poster.png", "profile"].includes(route[2]);
402
+ const stop = method === "POST" && route?.[2] === "stop";
403
+ if (!(resource || stop || method === "POST" && start)) {
404
+ json(res, 404, { error: "not_found" });
405
+ return true;
406
+ }
407
+ const allowed = resource ? ["ownerEpoch", "instance"] : ["ownerEpoch"];
408
+ if ([...url.searchParams.keys()].some(k => !allowed.includes(k)) || allowed.some(k => url.searchParams.getAll(k).length !== 1)
409
+ || url.searchParams.get("ownerEpoch") !== first.actor.epoch) {
410
+ json(res, reading ? 404 : 409, { error: reading ? "not_found" : "trials_person_changed" });
411
+ return true;
412
+ }
413
+ const instance = url.searchParams.get("instance") ?? "";
414
+ const answer = (result) => json(res, result.error ? result.error === "not_found" ? 404
415
+ : ["profiles_unreadable", "profiles_busy", "profiles_write_failed"].includes(String(result.error)) ? 503 : 400 : 200, result);
416
+ if (resource) {
417
+ if (route[2] === "poster.png") {
418
+ const result = service.trialPoster(route[1], instance, first.actor);
419
+ if (!result.ok)
420
+ json(res, 404, { error: "not_found" });
421
+ else {
422
+ res.writeHead(200, { "Content-Type": "image/png", "Content-Security-Policy": "default-src 'none'; sandbox" });
423
+ res.end(result.bytes);
424
+ }
425
+ }
426
+ else
427
+ answer(route[2] === "profile" ? service.trialProfile(route[1], instance, first.actor) : service.trialResource(route[1], instance, first.actor));
428
+ return true;
429
+ }
430
+ (async () => {
431
+ let bytes;
432
+ try {
433
+ bytes = await readBoundedWebBody(req, 16 * 1024);
434
+ }
435
+ catch {
436
+ json(res, 413, { error: "trial_body_bound" });
437
+ return;
438
+ }
439
+ if (!permitWebContinuation(req, res, ctx))
440
+ return;
441
+ const now = who();
442
+ if ("error" in now) {
443
+ json(res, now.status, { error: now.error });
444
+ return;
445
+ }
446
+ if (now.record !== first.record || now.sessions !== first.sessions || now.actor.sessionHash !== first.actor.sessionHash
447
+ || now.actor.person !== first.actor.person || now.actor.epoch !== first.actor.epoch) {
448
+ json(res, 409, { error: "trials_person_changed" });
449
+ return;
450
+ }
451
+ let body;
452
+ try {
453
+ body = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
454
+ }
455
+ catch {
456
+ json(res, 400, { error: "trial_json" });
457
+ return;
458
+ }
459
+ if (start) {
460
+ const parsed = z.strictObject({ offer: z.string().regex(/^[0-9a-f]{32}$/), instance: z.string().min(1).max(128) }).safeParse(body);
461
+ if (!parsed.success) {
462
+ json(res, 400, { error: "trial_args" });
463
+ return;
464
+ }
465
+ answer(service.trialStart(parsed.data.offer, parsed.data.instance, now.actor));
466
+ }
467
+ else {
468
+ if (!z.strictObject({}).safeParse(body).success) {
469
+ json(res, 400, { error: "trial_args" });
470
+ return;
471
+ }
472
+ answer(service.trialStop(route[1], now.actor));
473
+ }
474
+ })().catch(() => { try {
475
+ json(res, 500, { error: "trial_request_failed" });
476
+ }
477
+ catch { /* response ended */ } });
478
+ return true;
479
+ }
480
+ /** #1629 B2: effect routes require a human session; route bodies never supply an actor or reference holder. */
481
+ function handleEffects(req, res, url, ctx, path, method) {
482
+ res.setHeader("Cache-Control", "private, no-store");
483
+ res.setHeader("X-Content-Type-Options", "nosniff");
484
+ const reading = method === "GET";
485
+ // Reports are bounded untrusted diagnostics, not an appearance or admission write (r2 §6).
486
+ const profilePost = method === "POST" && /^\/ui\/effects\/profile\/[0-9a-f]{32}$/.test(path);
487
+ if (gatewayRequestContext(req)) {
488
+ json(res, reading ? 404 : 403, { error: "effects_not_over_public_link" });
489
+ return true;
490
+ }
491
+ const who = () => {
492
+ const verdict = evaluateWebRequest(req, url, ctx.webToken ?? null, ctx.webSessions, { touch: false });
493
+ if (verdict.kind !== "allow")
494
+ return { status: 401, error: WEB_TOKEN_INVALID_MESSAGE };
495
+ if (verdict.via !== "session" || !verdict.session)
496
+ return { status: 403, error: "effects_need_a_signed_in_person" };
497
+ if (!reading && !profilePost && verdict.session.tier !== "admin")
498
+ return { status: 403, error: "effects_read_only" };
499
+ return { person: webPersonKey(verdict.session.owner), canWrite: verdict.session.tier === "admin", epoch: verdict.session.handle };
500
+ };
501
+ const first = who();
502
+ if ("status" in first) {
503
+ json(res, first.status, { error: first.error });
504
+ return true;
505
+ }
506
+ const service = ctx.effectService;
507
+ if (!service) {
508
+ json(res, 404, { error: "effects_unavailable" });
509
+ return true;
510
+ }
511
+ const unavailable = new Set(["themes_unreadable", "profiles_unreadable", "library_unreadable", "references_unreadable", "library_busy", "profiles_busy", "profiles_write_failed", "library_write_failed", "prefs_unreadable"]);
512
+ const answer = (result) => json(res, typeof result.error === "string" ? result.error === "not_found" ? 404 : result.error === "profile_rate_limited" ? 429 : unavailable.has(result.error) ? 503 : 400 : 200, result);
513
+ const idPath = /^\/ui\/effects\/(resource|profile)\/([0-9a-f]{32})$/.exec(path);
514
+ const instance = url.searchParams.get("instance") ?? undefined, slot = url.searchParams.get("slot");
515
+ if (slot !== null && instance !== undefined) {
516
+ json(res, 404, { error: "not_found" });
517
+ return true;
518
+ }
519
+ const runtimeRead = reading && (slot !== null || path === "/ui/effects/owner" || path === "/ui/effects/placements" || idPath?.[1] === "resource" || instance !== undefined && /^\/ui\/effects\/image\//.test(path));
520
+ if ((runtimeRead || profilePost) && (url.searchParams.getAll("ownerEpoch").length !== 1 || url.searchParams.get("ownerEpoch") !== first.epoch)) {
521
+ const privateResource = reading && (slot !== null || idPath?.[1] === "resource" || /^\/ui\/effects\/image\//.test(path));
522
+ json(res, privateResource ? 404 : 409, { error: privateResource ? "not_found" : "effects_person_changed" });
523
+ return true;
524
+ }
525
+ if (reading) {
526
+ const image = /^\/ui\/effects\/image\/([0-9a-f]{32})\/(poster|cursor)\.png$/.exec(path);
527
+ if (image) {
528
+ const r = slot === null ? service.image(first.person, image[1], image[2], instance) : service.ownerImage(first.person, image[1], image[2], slot);
529
+ if (!r.ok)
530
+ json(res, 404, { error: "not_found" });
531
+ else {
532
+ res.writeHead(200, { "Content-Type": "image/png", "Content-Security-Policy": "default-src 'none'; sandbox" });
533
+ res.end(r.bytes);
534
+ }
535
+ return true;
536
+ }
537
+ if (path === "/ui/effects")
538
+ answer({ ...service.list(first.person), canWrite: first.canWrite });
539
+ else if (path === "/ui/effects/owner" && instance === undefined && slot === null)
540
+ answer(service.ownerSelections(first.person));
541
+ else if (path === "/ui/effects/placements" && instance !== undefined)
542
+ answer(service.placements(first.person, instance));
543
+ else if (idPath?.[1] === "resource")
544
+ answer(slot === null ? service.resource(first.person, idPath[2], instance) : service.ownerResource(first.person, idPath[2], slot));
545
+ else if (idPath?.[1] === "profile" && instance === undefined)
546
+ answer(service.profile(first.person, idPath[2]));
547
+ else
548
+ json(res, 404, { error: "not_found" });
549
+ return true;
550
+ }
551
+ const deletion = /^\/ui\/effects\/([0-9a-f]{32})$/.exec(path);
552
+ if (method === "DELETE" && deletion) {
553
+ answer(service.delete(first.person, deletion[1]));
554
+ return true;
555
+ }
556
+ if (method !== "POST" || !["/ui/effects/upload", "/ui/effects/apply", "/ui/effects/remove", "/ui/effects/share"].includes(path) && idPath?.[1] !== "profile") {
557
+ json(res, 404, { error: "not_found" });
558
+ return true;
559
+ }
560
+ (async () => {
561
+ let bytes;
562
+ try {
563
+ bytes = await readBoundedWebBody(req, path === "/ui/effects/upload" ? EFFECT_WIRE_LIMIT : 16 * 1024);
564
+ }
565
+ catch {
566
+ json(res, 413, { error: "effect_body_bound" });
567
+ return;
568
+ }
569
+ if (!permitWebContinuation(req, res, ctx))
570
+ return;
571
+ const now = who();
572
+ if ("status" in now) {
573
+ json(res, now.status, { error: now.error });
574
+ return;
575
+ }
576
+ if (now.person !== first.person || now.epoch !== first.epoch) {
577
+ json(res, 409, { error: "effects_person_changed" });
578
+ return;
579
+ }
580
+ if (path === "/ui/effects/upload") {
581
+ answer(service.upload(now.person, bytes));
582
+ return;
583
+ }
584
+ let body;
585
+ try {
586
+ body = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
587
+ }
588
+ catch {
589
+ json(res, 400, { error: "effect_json" });
590
+ return;
591
+ }
592
+ if (path === "/ui/effects/apply")
593
+ answer(service.apply(now.person, body));
594
+ else if (path === "/ui/effects/remove")
595
+ answer(service.remove(now.person, body));
596
+ else if (path === "/ui/effects/share")
597
+ answer(service.share(now.person, body));
598
+ else
599
+ answer(service.report(now.person, idPath[2], body, instance));
600
+ })().catch(() => { try {
601
+ json(res, 500, { error: "effect_request_failed" });
602
+ }
603
+ catch { /* response ended */ } });
604
+ return true;
605
+ }
606
+ /**
607
+ * #1629 r4 §4 (r4-5): mods — a person's import (stage, then commit), uninstall and export. Every route is a signed-in
608
+ * session's, writes an admin's: never the CLI's header token (the way an agent could reach it) and never the public link
609
+ * (absent from its manifest, and refused here too). `who()` is asked first and again once a body is in.
610
+ */
611
+ function handleMods(req, res, url, ctx, path, method) {
612
+ const mods = ctx.mods;
613
+ if (!mods) {
614
+ json(res, 404, { error: "Mods are not kept on this fleet" });
615
+ return true;
616
+ }
617
+ const reading = method === "GET";
618
+ if (gatewayRequestContext(req)) {
619
+ json(res, reading ? 404 : 403, { error: "mods_not_over_public_link" });
620
+ return true;
621
+ }
622
+ const who = () => {
623
+ const verdict = evaluateWebRequest(req, url, ctx.webToken ?? null, ctx.webSessions, { touch: false });
624
+ if (verdict.kind !== "allow")
625
+ return { status: 401, error: WEB_TOKEN_INVALID_MESSAGE };
626
+ if (verdict.via !== "session" || !verdict.session)
627
+ return { status: 403, error: "mods_need_a_signed_in_session" };
628
+ if (!reading && verdict.session.tier !== "admin")
629
+ return { status: 403, error: "mods_need_an_admin_session" };
630
+ return { person: webPersonKey(verdict.session.owner) };
631
+ };
632
+ const first = who();
633
+ if ("status" in first) {
634
+ json(res, first.status, { error: first.error });
635
+ return true;
636
+ }
637
+ const again = () => {
638
+ if (!permitWebContinuation(req, res, ctx))
639
+ return null;
640
+ const now = who();
641
+ if ("status" in now) {
642
+ json(res, now.status, { error: now.error });
643
+ return null;
644
+ }
645
+ if (now.person !== first.person) {
646
+ json(res, 409, { error: "mods_person_changed" });
647
+ return null;
648
+ }
649
+ return now.person;
650
+ };
651
+ const answer = (r, body) => {
652
+ if (r.ok)
653
+ json(res, 200, body);
654
+ else
655
+ json(res, r.status ?? 500, { error: r.error, ...(r.detail ? { detail: r.detail } : {}), ...(r.problems ? { problems: r.problems } : {}) });
656
+ };
657
+ const fail = (err) => {
658
+ ctx.logger.error({ err: err.message }, "Mod request failed");
659
+ try {
660
+ json(res, 500, { error: "Mod request failed" });
661
+ }
662
+ catch { /* already answered */ }
663
+ };
664
+ if (path === "/ui/mods" && method === "GET") {
665
+ json(res, 200, { ...mods.list(), staged: mods.staged(first.person), proposals: mods.proposals() });
666
+ return true;
667
+ }
668
+ // A staged mod's picture (this session's person's stage only): the plan shows the actual pictures before install.
669
+ const stagedPic = /^\/ui\/mods\/staged\/([0-9a-f]{32})$/.exec(path);
670
+ if (stagedPic && method === "GET") {
671
+ const pic = mods.stagedPicture(first.person, stagedPic[1]);
672
+ if (!pic) {
673
+ json(res, 404, { error: "No such image" });
674
+ return true;
675
+ }
676
+ res.writeHead(200, { "Content-Type": pic.mime, "X-Content-Type-Options": "nosniff", "Cache-Control": "private, no-store", "Content-Security-Policy": "default-src 'none'; sandbox" });
677
+ res.end(Buffer.from(pic.bytes));
678
+ return true;
679
+ }
680
+ const fileMatch = /^\/ui\/mods\/file\/([0-9a-f]{32})$/.exec(path);
681
+ if (fileMatch && method === "GET") {
682
+ const text = mods.bundleText(fileMatch[1]);
683
+ const rec = mods.list().mods.find(m => m.id === fileMatch[1]);
684
+ if (text === null || !rec) {
685
+ json(res, 404, { error: "no_such_mod" });
686
+ return true;
687
+ }
688
+ // The file is the installed bundle, effects and all — also effects its import left out, which nobody installed.
689
+ // So the export ACL judges what the file carries, not what was installed: it downloads only when this person may
690
+ // read every effect in it (their own claim on that content, or a shared one). A file without effects is any admin's.
691
+ const fx = mods.bundleEffects(fileMatch[1]);
692
+ if (fx === "unknown" || (fx ?? []).some(id => !ctx.modPlacedEffect?.(first.person, id))) {
693
+ json(res, 403, { error: "mod_effects_not_yours", detail: "this mod's file carries effects that are not yours to share: export your own look instead" });
694
+ return true;
695
+ }
696
+ res.writeHead(200, { "Content-Type": "application/json; charset=utf-8", "X-Content-Type-Options": "nosniff", "Cache-Control": "private, no-store",
697
+ "Content-Disposition": `attachment; filename="${rec.name}-${rec.version}.agendmod.json"` });
698
+ res.end(text);
699
+ return true;
700
+ }
701
+ if (method !== "POST" || !["/ui/mods/stage", "/ui/mods/abort", "/ui/mods/commit", "/ui/mods/uninstall", "/ui/mods/export", "/ui/mods/proposal/stage", "/ui/mods/proposal/dismiss"].includes(path)) {
702
+ json(res, 404, { error: "not found" });
703
+ return true;
704
+ }
705
+ (async () => {
706
+ if (path === "/ui/mods/stage") {
707
+ // Too large by its own declared length: answered before a byte is read (the body is drained, not cut off).
708
+ if (Number(req.headers["content-length"]) > MOD_MAX_BYTES) {
709
+ req.resume();
710
+ json(res, 413, { error: "mod_too_large", detail: `at most ${MOD_MAX_BYTES / 1024 / 1024} MiB` });
711
+ return;
712
+ }
713
+ let raw;
714
+ try {
715
+ raw = await readBoundedWebBody(req, MOD_MAX_BYTES);
716
+ }
717
+ catch {
718
+ json(res, 413, { error: "mod_too_large", detail: `at most ${MOD_MAX_BYTES / 1024 / 1024} MiB` });
719
+ return;
720
+ }
721
+ const person = again();
722
+ if (!person)
723
+ return;
724
+ let text;
725
+ try {
726
+ text = new TextDecoder("utf-8", { fatal: true }).decode(raw);
727
+ }
728
+ catch {
729
+ json(res, 400, { error: "mod_invalid", problems: [{ path: "", problem: "not UTF-8" }] });
730
+ return;
731
+ }
732
+ const r = mods.stage(person, text);
733
+ answer(r, r.ok ? { stage: r.stage, plan: r.plan, plan_hash: r.plan_hash } : null);
734
+ return;
735
+ }
736
+ let body;
737
+ try {
738
+ body = await parseBody(req);
739
+ }
740
+ catch {
741
+ json(res, 400, { error: "Invalid JSON" });
742
+ return;
743
+ }
744
+ const person = again();
745
+ if (!person)
746
+ return;
747
+ if (!body || typeof body !== "object" || Array.isArray(body)) {
748
+ json(res, 400, { error: "an object" });
749
+ return;
750
+ }
751
+ if (path === "/ui/mods/abort") {
752
+ const r = mods.abort(person, body.stage);
753
+ answer(r, { ok: true });
754
+ return;
755
+ }
756
+ // r4-5b: an agent's proposal, opened into the usual review (staged as this person's), or dismissed.
757
+ if (path === "/ui/mods/proposal/stage") {
758
+ const r = mods.stageProposal(person, body.instance);
759
+ answer(r, r.ok ? { stage: r.stage, plan: r.plan, plan_hash: r.plan_hash } : null);
760
+ return;
761
+ }
762
+ if (path === "/ui/mods/proposal/dismiss") {
763
+ const r = mods.dismiss(body.instance);
764
+ answer(r, { ok: true });
765
+ return;
766
+ }
767
+ if (path === "/ui/mods/commit") {
768
+ const r = mods.commit(person, body);
769
+ if (r.ok)
770
+ ctx.emitSseEvent("status", ctx.getUiStatus()); // themes_rev and prefs_rev moved: every page re-reads
771
+ answer(r, r.ok ? { id: r.id, applied: r.applied, ...(r.css ? { css: r.css } : {}) } : null);
772
+ return;
773
+ }
774
+ if (path === "/ui/mods/uninstall") {
775
+ const r = mods.uninstall(body.id);
776
+ answer(r, r.ok ? { ok: true, released: r.released } : null);
777
+ return;
778
+ }
779
+ // Export: what is in force now, as a bundle file.
780
+ const r = exportMod(ctx, person, body);
781
+ if (!r.ok) {
782
+ json(res, r.status, { error: r.error, ...(r.detail ? { detail: r.detail } : {}) });
783
+ return;
784
+ }
785
+ res.writeHead(200, { "Content-Type": "application/json; charset=utf-8", "X-Content-Type-Options": "nosniff", "Cache-Control": "private, no-store",
786
+ "Content-Disposition": `attachment; filename="${r.filename}"`, "X-AgEnD-Effects-Omitted": String(r.omitted) });
787
+ res.end(r.text);
788
+ })().catch(fail);
789
+ return true;
790
+ }
791
+ /** A person's export: an instance's look (theme, identity, its widgets), their sidebar widget and their own CSS. */
792
+ export function exportMod(ctx, person, body) {
793
+ const isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
794
+ const inc = isObj(body.include) ? body.include : null;
795
+ if (!inc)
796
+ return { ok: false, status: 400, error: "include" };
797
+ const meta = { name: body.name, version: body.version, title: body.title };
798
+ if (typeof meta.name !== "string" || !/^[a-z0-9][a-z0-9-]{0,63}$/.test(meta.name))
799
+ return { ok: false, status: 400, error: "name", detail: "a-z, 0-9 and - (1..64)" };
800
+ if (typeof meta.version !== "string" || !/^(0|[1-9]\d{0,5})\.(0|[1-9]\d{0,5})\.(0|[1-9]\d{0,5})$/.test(meta.version))
801
+ return { ok: false, status: 400, error: "version", detail: "MAJOR.MINOR.PATCH" };
802
+ // The importer's own rule (#1897 review): what is exported always imports.
803
+ const title = canonicalText(meta.title, 1, 60);
804
+ if (title === null)
805
+ return { ok: false, status: 400, error: "title", detail: "1..60 characters on one line" };
806
+ const parts = {};
807
+ let omitted = 0;
808
+ const wantsInstance = inc.theme === true || inc.identity === true || (isObj(inc.widgets) && AGENT_WIDGET_SLOTS.some(s => inc.widgets[s] === true));
809
+ let name = null;
810
+ if (wantsInstance) {
811
+ if (typeof body.instance !== "string" || !ctx.isKnownInstance?.(body.instance) || !ctx.themeStore)
812
+ return { ok: false, status: 400, error: "instance" };
813
+ name = body.instance;
814
+ }
815
+ if (name && ctx.themeStore) {
816
+ const store = ctx.themeStore;
817
+ const rec = store.get(name);
818
+ if (inc.theme === true) {
819
+ // The effects the theme places travel with it, as the library keeps them (a person may export what is placed).
820
+ // Only effects this person may carry away travel (Sol's ACL); the rest are counted, never packaged.
821
+ // #2017: an older placement of an effect that doesn't play in chat stays out (a mod can't place it): counted.
822
+ const effects = rec.placements.effects.flatMap(p => { const e = ctx.modPlacedEffect?.(person, p.id); if (!e || !filesPlayInChat(e.files)) {
823
+ omitted++;
824
+ return [];
825
+ } return [{ name: e.name, files: e.files, params: p.params ?? {} }]; });
826
+ parts.theme = { theme: rec.theme, picture: id => { const a = rec.assets.find(x => x.id === id); const b = store.asset(name, id); return a && b ? { bytes: b.bytes, kind: a.kind } : null; }, effects };
827
+ }
828
+ if (inc.identity === true)
829
+ parts.identity = ctx.modCardText?.(name) ?? {};
830
+ for (const slot of AGENT_WIDGET_SLOTS) {
831
+ const w = rec.placements.widgets[slot];
832
+ if (isObj(inc.widgets) && inc.widgets[slot] === true && w)
833
+ (parts.widgets ??= {})[slot] = { spec: w.spec, data: w.data };
834
+ }
835
+ }
836
+ if (isObj(inc.widgets) && inc.widgets.sidebar === true && ctx.webPrefs) {
837
+ const v = ctx.webPrefs.read(OPERATOR).values["widget.sidebar"];
838
+ const spec = v ? checkWidgetSpec(v.spec, "sidebar") : null;
839
+ const data = spec?.ok ? checkWidgetData(v.data, spec.value) : null;
840
+ if (spec?.ok && data?.ok)
841
+ (parts.widgets ??= {}).sidebar = { spec: spec.value, data: { ...data.value } };
842
+ }
843
+ if (inc.ownerCss === true && ctx.ownerCss) {
844
+ const state = ctx.ownerCss.state(person);
845
+ const checked = state.text ? checkOwnerCss(state.text, new Set(state.assets.map(a => a.id))) : null;
846
+ if (state.text && checked?.ok) {
847
+ // Only the assets this text names travel (read from its tokens: url("\\61sset:<id>") names one too).
848
+ const named = ownerCssAssetRefs(checked.tokens);
849
+ const assets = state.assets.filter(a => named.has(a.id)).flatMap(a => { const b = ctx.ownerCss.asset(person, a.id); return b ? [{ id: a.id, bytes: b.bytes, kind: a.kind }] : []; });
850
+ parts.ownerCss = { text: state.text, assets };
851
+ }
852
+ }
853
+ const built = buildModBundle({ name: meta.name, version: meta.version, title }, parts);
854
+ return built.ok ? { ...built, omitted } : { ok: false, status: built.error === "nothing_to_export" ? 400 : 409, error: built.error };
855
+ }
153
856
  /**
154
857
  * #1827: whose a request is, asked NOW (after its body arrived: a session revoked, expired or rotated away meanwhile is
155
858
  * no one — `null`, never the operator). An authorized session is the account it was signed in as, or (an `agend web`
@@ -465,6 +1168,110 @@ export function handleWebRequest(req, res, url, ctx) {
465
1168
  res.end(got.bytes);
466
1169
  return true;
467
1170
  }
1171
+ // ── #1629: agents' themes — read by every page (the public link included: it views them), changed by a person ──
1172
+ // GET /ui/themes: { rev, themes, locked, widgets } when the status frame's themes_rev moves (passive, web-auth.ts).
1173
+ // GET /ui/img/theme/<instance>/<id>: a theme's picture (same headers as #1630's images; the id is the content hash).
1174
+ // POST /ui/themes/<instance>/reset | lock | widget (r4-4: { slot } — take an agent's widget out): a person's, from an admin session — never the CLI token, never the public
1175
+ // link (absent from its manifest, and refused here too).
1176
+ if (method === "GET" && path === "/ui/themes") {
1177
+ const store = ctx.themeStore;
1178
+ if (!store) {
1179
+ json(res, 404, { error: "Themes are not kept on this fleet" });
1180
+ return true;
1181
+ }
1182
+ json(res, 200, { rev: store.rev(), themes: store.themes(), locked: store.lockedNames(), widgets: store.widgets() });
1183
+ return true;
1184
+ }
1185
+ const themeImage = path.match(/^\/ui\/img\/theme\/([^/]+)\/([0-9a-f]{32})$/);
1186
+ if (method === "GET" && themeImage) {
1187
+ let name;
1188
+ try {
1189
+ name = decodeURIComponent(themeImage[1]);
1190
+ }
1191
+ catch {
1192
+ json(res, 404, { error: "No such image" });
1193
+ return true;
1194
+ }
1195
+ const image = ctx.themeStore?.asset(name, themeImage[2]) ?? null;
1196
+ if (!image) {
1197
+ json(res, 404, { error: "No such image" });
1198
+ return true;
1199
+ }
1200
+ res.setHeader("Content-Type", image.mime);
1201
+ res.setHeader("X-Content-Type-Options", "nosniff");
1202
+ res.setHeader("Cache-Control", "private, max-age=86400");
1203
+ res.setHeader("Content-Security-Policy", "default-src 'none'; sandbox");
1204
+ res.writeHead(200);
1205
+ res.end(image.bytes);
1206
+ return true;
1207
+ }
1208
+ const themeWrite = path.match(/^\/ui\/themes\/([^/]+)\/(reset|lock|widget)$/);
1209
+ if (method === "POST" && themeWrite) {
1210
+ const service = ctx.webAppearance;
1211
+ if (!service) {
1212
+ json(res, 404, { error: "Themes are not kept on this fleet" });
1213
+ return true;
1214
+ }
1215
+ if (gatewayRequestContext(req)) {
1216
+ json(res, 403, { error: "theme_not_over_public_link" });
1217
+ return true;
1218
+ }
1219
+ let name;
1220
+ try {
1221
+ name = decodeURIComponent(themeWrite[1]);
1222
+ }
1223
+ catch {
1224
+ json(res, 400, { error: "instance" });
1225
+ return true;
1226
+ }
1227
+ const who = () => {
1228
+ const verdict = evaluateWebRequest(req, url, ctx.webToken ?? null, ctx.webSessions, { touch: false });
1229
+ if (verdict.kind !== "allow")
1230
+ return 401;
1231
+ return verdict.via === "session" && verdict.session?.tier === "admin" ? 200 : 403;
1232
+ };
1233
+ const first = who();
1234
+ if (first !== 200) {
1235
+ json(res, first, { error: first === 401 ? WEB_TOKEN_INVALID_MESSAGE : "theme_needs_an_admin_session" });
1236
+ return true;
1237
+ }
1238
+ (async () => {
1239
+ let body = {};
1240
+ try {
1241
+ body = await parseBody(req);
1242
+ }
1243
+ catch {
1244
+ json(res, 400, { error: "Invalid JSON" });
1245
+ return;
1246
+ }
1247
+ if (!permitWebContinuation(req, res, ctx))
1248
+ return;
1249
+ const now = who();
1250
+ if (now !== 200) {
1251
+ json(res, now, { error: now === 401 ? WEB_TOKEN_INVALID_MESSAGE : "theme_needs_an_admin_session" });
1252
+ return;
1253
+ }
1254
+ if (!ctx.isKnownInstance?.(name)) {
1255
+ json(res, 404, { error: "unknown instance" });
1256
+ return;
1257
+ }
1258
+ const r = themeWrite[2] === "reset" ? service.humanReset(name)
1259
+ : themeWrite[2] === "widget" ? service.humanRemoveWidget(name, body.slot)
1260
+ : typeof body.locked === "boolean" ? service.humanLock(name, body.locked) : { error: "locked: true or false", status: 400 };
1261
+ if (typeof r.error === "string") {
1262
+ json(res, typeof r.status === "number" ? r.status : 503, { error: r.error });
1263
+ return;
1264
+ }
1265
+ json(res, 200, r);
1266
+ })().catch(err => {
1267
+ ctx.logger.error({ err: err.message }, "Theme reset/lock failed");
1268
+ try {
1269
+ json(res, 500, { error: "Theme change failed" });
1270
+ }
1271
+ catch { /* already answered */ }
1272
+ });
1273
+ return true;
1274
+ }
468
1275
  // ── #1630: avatars and Discord server emoji, same-origin (the page never loads a platform's CDN; img-src stays 'self') ──
469
1276
  const imageMatch = path.match(/^\/ui\/img\/(bot|avatar|emoji)\/([^/]+)$/);
470
1277
  if (method === "GET" && imageMatch) {
@@ -679,15 +1486,19 @@ export function handleWebRequest(req, res, url, ctx) {
679
1486
  json(res, 200, { code: p.code, previewOrigin: p.previewOrigin, boot: p.previewOrigin ? p.boot : null, detail: p.detail ?? null });
680
1487
  return true;
681
1488
  }
682
- // #1665: the web preferences that follow the person between devices. Read by any page of the panel, the public link
683
- // included (it reads what is in force); written only by a signed-in admin session or the CLI's header token — never
684
- // over the public link (absent from its route manifest, and refused here too), never by a read-only session.
1489
+ // Host preferences preserve their scope; only five declared owner slots use the current human session bucket.
685
1490
  if (path === "/ui/prefs" && method === "GET") {
686
1491
  if (!ctx.webPrefs) {
687
1492
  json(res, 404, { error: "Preferences are not kept on this fleet" });
688
1493
  return true;
689
1494
  }
690
- json(res, 200, ctx.webPrefs.read(OPERATOR));
1495
+ const viewer = prefsViewer(req, url, ctx);
1496
+ if (!viewer) {
1497
+ json(res, 401, { error: WEB_TOKEN_INVALID_MESSAGE });
1498
+ return true;
1499
+ }
1500
+ res.setHeader("Cache-Control", "private, no-store");
1501
+ json(res, 200, prefsView(ctx.webPrefs, viewer));
691
1502
  return true;
692
1503
  }
693
1504
  if (path === "/ui/prefs" && method === "POST") {
@@ -700,17 +1511,9 @@ export function handleWebRequest(req, res, url, ctx) {
700
1511
  json(res, 403, { error: "prefs_not_over_public_link" });
701
1512
  return true;
702
1513
  }
703
- // Who may write, asked now and again once the body is in: a session revoked or expired, or a token rotated, while
704
- // the body was on its way writes nothing and announces nothing.
705
- const writer = () => {
706
- const verdict = evaluateWebRequest(req, url, ctx.webToken ?? null, ctx.webSessions, { touch: false });
707
- if (verdict.kind !== "allow")
708
- return 401;
709
- return verdict.via === "session" && verdict.session?.tier !== "admin" ? 403 : 200;
710
- };
711
- const refuse = (code) => json(res, code, code === 401 ? { error: WEB_TOKEN_INVALID_MESSAGE } : { error: "prefs_read_only" });
712
- const first = writer();
713
- if (first !== 200) {
1514
+ const refuse = (viewer) => json(res, viewer ? 403 : 401, { error: viewer ? "prefs_read_only" : WEB_TOKEN_INVALID_MESSAGE });
1515
+ const first = prefsViewer(req, url, ctx);
1516
+ if (!first?.canWrite) {
714
1517
  refuse(first);
715
1518
  return true;
716
1519
  }
@@ -734,21 +1537,39 @@ export function handleWebRequest(req, res, url, ctx) {
734
1537
  json(res, 400, { error: parsed.error });
735
1538
  return;
736
1539
  }
737
- const now = writer();
738
- if (now !== 200) {
1540
+ const now = prefsViewer(req, url, ctx);
1541
+ if (!now?.canWrite) {
739
1542
  refuse(now);
740
1543
  return;
741
1544
  }
742
- const onlyIfUnset = body.onlyIfUnset === true;
743
- const written = prefs.write(OPERATOR, parsed.patch, { onlyIfUnset });
1545
+ if (now.person !== first.person || now.epoch !== first.epoch || now.via !== first.via) {
1546
+ json(res, 409, { error: "prefs_person_changed" });
1547
+ return;
1548
+ }
1549
+ const personal = [...parsed.patch.keys()].some(isOwnerEffectKey);
1550
+ if (personal && now.person === null) {
1551
+ json(res, 403, { error: "prefs_need_a_signed_in_person" });
1552
+ return;
1553
+ }
1554
+ // A scope marker rejects a queued old page after a different login, including the same person's new session.
1555
+ // It supplies neither identity nor authority; the current verified session still chooses the bucket.
1556
+ if (personal && body.ownerEpoch !== now.epoch) {
1557
+ json(res, 409, { error: "prefs_person_changed" });
1558
+ return;
1559
+ }
1560
+ const before = prefs.readForViewer(now.person);
1561
+ const written = prefs.writeForViewer(now.person, parsed.patch, { onlyIfUnset: body.onlyIfUnset === true,
1562
+ validateEffect: (person, key, id) => ctx.effectService ? ctx.effectService.validateOwnerSelection(person, key, id) : "effects_unavailable" });
744
1563
  if (!written.ok) {
745
1564
  json(res, written.status, { error: written.error });
746
1565
  return;
747
1566
  }
748
- // Another device sees the new rev in the next status frame — at once on the live stream.
1567
+ const dropped = written.changed.filter(isOwnerEffectKey).flatMap(key => typeof before.values[key] === "string" && before.values[key] !== written.view.values[key] ? [before.values[key]] : []);
1568
+ if (dropped.length)
1569
+ ctx.effectService?.collectOwnerReplaced(dropped);
749
1570
  if (written.changed.length)
750
1571
  ctx.emitSseEvent("status", ctx.getUiStatus());
751
- json(res, 200, { ...written.view, changed: written.changed });
1572
+ json(res, 200, { ...written.view, ...prefsScope(now), changed: written.changed });
752
1573
  })().catch(err => {
753
1574
  ctx.logger.error({ err: err.message }, "Saving web preferences failed");
754
1575
  try {
@@ -758,6 +1579,24 @@ export function handleWebRequest(req, res, url, ctx) {
758
1579
  });
759
1580
  return true;
760
1581
  }
1582
+ // #1629 r4 §3: owner CSS — one person's own stylesheet, for their own screens. Every route is a signed-in human's
1583
+ // session: never the CLI's header token (no person, and the way an agent could reach it), never the public link
1584
+ // (absent from its manifest, and refused here too), reads included. The stylesheet and its assets are that session's
1585
+ // person's only: no name or identity in any URL.
1586
+ if (path === "/ui/owner-css.css" || path === "/ui/owner-css" || path.startsWith("/ui/owner-css/")) {
1587
+ return handleOwnerCss(req, res, url, ctx, path, method);
1588
+ }
1589
+ // #1629 r4-5: mods — a person's import, uninstall and export (handleMods: sessions only, never the public link).
1590
+ if (path === "/ui/mods" || path.startsWith("/ui/mods/"))
1591
+ return handleMods(req, res, url, ctx, path, method);
1592
+ // Posters remain behind the common session gate, with a closed GET-only public manifest.
1593
+ if (path === "/ui/effect-posters" || path.startsWith("/ui/effect-posters/"))
1594
+ return handleEffectPosters(res, url, ctx, path, method);
1595
+ // Canvas effects are absent from the public gateway manifest and independently refused above.
1596
+ if (path === "/ui/effect-trials" || path.startsWith("/ui/effect-trials/"))
1597
+ return handleEffectTrials(req, res, url, ctx, path, method);
1598
+ if (path === "/ui/effects" || path.startsWith("/ui/effects/"))
1599
+ return handleEffects(req, res, url, ctx, path, method);
761
1600
  // #1386: Acknowledge a "Needs you" delivery item — a session write (CSRF, checked by the gate) or the CLI's header
762
1601
  // token. Who did it is recorded by principal: the session's public handle, never the session id or a credential.
763
1602
  if (method === "POST" && path === "/ui/needs/ack") {
@@ -1830,23 +2669,42 @@ function serveMediaRange(req, res, files, id, file) {
1830
2669
  res.writeHead(ranged ? 206 : 200);
1831
2670
  res.end(got.bytes);
1832
2671
  }
1833
- /**
1834
- * #1665: `<html>` carrying the host's preferences (data-prefs: `{"rev":n,"values":{…}}`, attribute-escaped). theme.js (a
1835
- * blocking script in <head>) and the app's dictionary take the theme and language from it over the browser's cache,
1836
- * and app-prefs.js copies every value into that cache before any panel reads it — so no device paints a stale theme
1837
- * or replays a tour already seen, a new device included. Only for the signed-in app (the public link reads what is in
1838
- * force too); the anonymous View reader and the sign-in page keep the browser's own.
1839
- */
1840
- function shellHtmlTag(ctx, mode) {
2672
+ function prefsViewer(req, url, ctx) {
2673
+ const v = evaluateWebRequest(req, url, ctx.webToken ?? null, ctx.webSessions, { touch: false });
2674
+ if (v.kind !== "allow")
2675
+ return null;
2676
+ if (!gatewayRequestContext(req) && v.via === "session" && v.session) {
2677
+ const person = webPersonKey(v.session.owner);
2678
+ return { person, epoch: v.session.handle, scope: person === OPERATOR ? "operator" : "person", canWrite: v.session.tier === "admin", via: "session" };
2679
+ }
2680
+ return { person: null, epoch: null, scope: "none", canWrite: v.via !== "session" || v.session?.tier === "admin", via: v.via };
2681
+ }
2682
+ const prefsScope = (v) => ({ ownerScope: v?.scope ?? "none", ownerEpoch: v?.epoch ?? null });
2683
+ const prefsView = (prefs, v) => ({ ...prefs.readForViewer(v?.person ?? null), ...prefsScope(v) });
2684
+ function shellHtmlTag(ctx, mode, viewer) {
1841
2685
  if (mode !== "full" || !ctx.webPrefs)
1842
2686
  return '<html lang="en">';
1843
- const view = ctx.webPrefs.read(OPERATOR);
2687
+ const view = prefsView(ctx.webPrefs, viewer);
1844
2688
  const lang = view.values.lang === "zh-TW" ? "zh-Hant" : "en";
1845
2689
  const attr = (v) => v.replace(/[&<>"']/g, c => `&#${c.charCodeAt(0)};`);
1846
- return `<html lang="${lang}" data-prefs="${attr(JSON.stringify({ rev: view.rev, values: view.values }))}">`;
2690
+ return `<html lang="${lang}" data-prefs="${attr(JSON.stringify(view))}">`;
1847
2691
  }
1848
- function shellBodyTag(req, ctx, mode) {
2692
+ /**
2693
+ * #1629 r4 §3: the owner CSS this load links (its sha), or null. Only the full app of a signed-in human session — never
2694
+ * the public link, the anonymous View reader, the CLI's header token or the sign-in and setup pages (other panels).
2695
+ */
2696
+ function shellOwnerCss(ctx, mode, viewer) {
2697
+ return mode === "full" && viewer?.person !== null && viewer?.person !== undefined && ctx.ownerCss ? ctx.ownerCss.active(viewer.person) : null;
2698
+ }
2699
+ function shellBodyTag(req, ctx, mode, viewer) {
1849
2700
  const p = shellPreview(req, ctx, mode);
2701
+ const ownerCss = shellOwnerCss(ctx, mode, viewer);
2702
+ let ownerCssState = "unknown";
2703
+ if (mode === "full" && viewer?.person != null && ctx.ownerCss) {
2704
+ ownerCssState = ctx.ownerCss.effectState(viewer.person);
2705
+ if (ownerCssState === "off" && ownerCss !== null || ownerCssState === "on" && ownerCss === null)
2706
+ ownerCssState = "unknown";
2707
+ }
1850
2708
  const attr = (v) => String(v ?? "").replace(/[&<>"']/g, c => `&#${c.charCodeAt(0)};`);
1851
2709
  return `<body data-mode="${mode}"${mode === "full" && gatewayRequestContext(req) ? ' data-web-transport="poll"' : ""} data-dashboard-origin="${attr(p?.dashboardOrigin)}" data-preview-origin="${attr(p?.previewOrigin)}"`
1852
2710
  + ` data-preview-boot="${attr(p?.previewOrigin ? p.boot : "")}" data-preview-reason="${attr(p ? p.reason : "Previews are not available on this fleet.")}"`
@@ -1863,6 +2721,8 @@ function shellBodyTag(req, ctx, mode) {
1863
2721
  + ` data-upload-limits="${attr(JSON.stringify(uploadLimits(ctx.fleetConfig?.web, { publicLink: gatewayRequestContext(req) !== undefined })))}"`
1864
2722
  // #1770: the fleet's zone (chat-logs' and fleet.log's), in which the chat reads its message times and days.
1865
2723
  + ` data-fleet-tz="${attr(resolveTz())}"`
2724
+ // #1629 r4 §3: the person's owner CSS in force (a sha), linked by the page unless this device or this load says not.
2725
+ + ` data-owner-css-state="${ownerCssState}"${ownerCss ? ` data-owner-css="${attr(ownerCss)}"` : ""}`
1866
2726
  // #1589: the public link shows attachments as downloads only (no inline text or media): its scope is unchanged.
1867
2727
  + `${gatewayRequestContext(req) ? ' data-public-link="1"' : ""}>`;
1868
2728
  }
@@ -1871,6 +2731,7 @@ function shellBodyTag(req, ctx, mode) {
1871
2731
  export function serveAppShell(req, res, ctx, mode) {
1872
2732
  try {
1873
2733
  const html = readFileSync(join(__dirname, "ui", "app.html"), "utf-8");
2734
+ const viewer = mode === "full" ? prefsViewer(req, new URL(req.url ?? "/", "http://localhost"), ctx) : null;
1874
2735
  const p = shellPreview(req, ctx, mode);
1875
2736
  // #1589: media plays only from this listener's own file route (path-scoped, like frame-src), at the address this page
1876
2737
  // was loaded from (a Host the host guard accepted); never on the public link, nor where no chat is shown.
@@ -1879,8 +2740,9 @@ export function serveAppShell(req, res, ctx, mode) {
1879
2740
  const mediaSrc = own ? `${own}/ui/file/` : "'none'";
1880
2741
  // A public-link page frames what its exposure's preview names (the quick-tunnel wildcard: its host is not known when
1881
2742
  // the page may load); every other page frames exactly the origin this load chose (#1723).
1882
- const frameSrc = p && "frameSrc" in p ? p.frameSrc : p?.previewOrigin ? `${p.previewOrigin}/frame` : null;
1883
- sendPanelHtml(res, html.replace('<html lang="en">', shellHtmlTag(ctx, mode)).replace("<body>", shellBodyTag(req, ctx, mode)), 200, {}, { ...(frameSrc ? { frameSrc } : {}), mediaSrc });
2743
+ const frameSrc = p && "frameSrc" in p ? p.frameSrc : p?.previewOrigin
2744
+ ? `${p.previewOrigin}/frame${mode === "full" && !gatewayRequestContext(req) ? ` ${p.previewOrigin}/effect` : ""}` : null;
2745
+ sendPanelHtml(res, html.replace('<html lang="en">', shellHtmlTag(ctx, mode, viewer)).replace("<body>", shellBodyTag(req, ctx, mode, viewer)), 200, {}, { ...(frameSrc ? { frameSrc } : {}), mediaSrc });
1884
2746
  }
1885
2747
  catch {
1886
2748
  json(res, 500, { error: "app.html not found" });