@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.
- package/dist/agent-cli.js +248 -2
- package/dist/agent-cli.js.map +1 -1
- package/dist/agent-endpoint.d.ts +2 -0
- package/dist/agent-endpoint.js +25 -0
- package/dist/agent-endpoint.js.map +1 -1
- package/dist/agent-file-read.d.ts +35 -0
- package/dist/agent-file-read.js +144 -0
- package/dist/agent-file-read.js.map +1 -0
- package/dist/agent-whats-new.js +8 -0
- package/dist/agent-whats-new.js.map +1 -1
- package/dist/atomic-write.js +9 -1
- package/dist/atomic-write.js.map +1 -1
- package/dist/auth-api.js +17 -0
- package/dist/auth-api.js.map +1 -1
- package/dist/backend/antigravity.js +3 -3
- package/dist/backend/antigravity.js.map +1 -1
- package/dist/backend/claude-code.js +5 -1
- package/dist/backend/claude-code.js.map +1 -1
- package/dist/backend/launch-effort.js +1 -1
- package/dist/backend/launch-effort.js.map +1 -1
- package/dist/backend/muse.js +10 -2
- package/dist/backend/muse.js.map +1 -1
- package/dist/backend/types.d.ts +8 -0
- package/dist/backend/types.js +10 -0
- package/dist/backend/types.js.map +1 -1
- package/dist/cache-analysis.d.ts +47 -1
- package/dist/cache-analysis.js +68 -8
- package/dist/cache-analysis.js.map +1 -1
- package/dist/cache-report-tool.d.ts +17 -0
- package/dist/cache-report-tool.js +40 -0
- package/dist/cache-report-tool.js.map +1 -0
- package/dist/cache-service.d.ts +15 -0
- package/dist/cache-service.js +26 -1
- package/dist/cache-service.js.map +1 -1
- package/dist/channel/adapters/discord.d.ts +2 -0
- package/dist/channel/adapters/discord.js +17 -2
- package/dist/channel/adapters/discord.js.map +1 -1
- package/dist/channel/adapters/telegram.d.ts +8 -1
- package/dist/channel/adapters/telegram.js +74 -4
- package/dist/channel/adapters/telegram.js.map +1 -1
- package/dist/channel/mcp-tools.js +36 -0
- package/dist/channel/mcp-tools.js.map +1 -1
- package/dist/channel/types.d.ts +15 -0
- package/dist/cli.js +7 -3
- package/dist/cli.js.map +1 -1
- package/dist/config-validator.js +22 -2
- package/dist/config-validator.js.map +1 -1
- package/dist/daemon.js +244 -20
- package/dist/daemon.js.map +1 -1
- package/dist/fleet-context.d.ts +5 -0
- package/dist/fleet-manager.d.ts +92 -0
- package/dist/fleet-manager.js +410 -29
- package/dist/fleet-manager.js.map +1 -1
- package/dist/general-knowledge/skills/credential-profiles/SKILL.md +7 -0
- package/dist/general-knowledge/skills/web-appearance/SKILL.md +367 -0
- package/dist/instance-avatar.js +3 -0
- package/dist/instance-avatar.js.map +1 -1
- package/dist/instance-config-impact.js +3 -0
- package/dist/instance-config-impact.js.map +1 -1
- package/dist/instance-lifecycle.js +9 -4
- package/dist/instance-lifecycle.js.map +1 -1
- package/dist/needs-you-hub.d.ts +4 -0
- package/dist/needs-you-hub.js +31 -6
- package/dist/needs-you-hub.js.map +1 -1
- package/dist/needs-you.d.ts +1 -13
- package/dist/needs-you.js +3 -37
- package/dist/needs-you.js.map +1 -1
- package/dist/outbound-handlers.d.ts +7 -0
- package/dist/outbound-handlers.js +65 -10
- package/dist/outbound-handlers.js.map +1 -1
- package/dist/outbound-schemas.d.ts +74 -0
- package/dist/outbound-schemas.js +56 -0
- package/dist/outbound-schemas.js.map +1 -1
- package/dist/public-web-gateway.js +14 -0
- package/dist/public-web-gateway.js.map +1 -1
- package/dist/repo-worktree-guard.d.ts +36 -0
- package/dist/repo-worktree-guard.js +143 -0
- package/dist/repo-worktree-guard.js.map +1 -0
- package/dist/scheduler/scheduler.d.ts +70 -1
- package/dist/scheduler/scheduler.js +256 -17
- package/dist/scheduler/scheduler.js.map +1 -1
- package/dist/service-installer.d.ts +2 -1
- package/dist/service-installer.js +18 -3
- package/dist/service-installer.js.map +1 -1
- package/dist/settings-api.js +74 -1
- package/dist/settings-api.js.map +1 -1
- package/dist/settings-change.js +8 -0
- package/dist/settings-change.js.map +1 -1
- package/dist/settings-effect.js +16 -2
- package/dist/settings-effect.js.map +1 -1
- package/dist/settings-http-confirmation.js +5 -0
- package/dist/settings-http-confirmation.js.map +1 -1
- package/dist/tool-permissions.d.ts +5 -0
- package/dist/tool-permissions.js +61 -2
- package/dist/tool-permissions.js.map +1 -1
- package/dist/topic-commands.d.ts +10 -6
- package/dist/topic-commands.js +21 -17
- package/dist/topic-commands.js.map +1 -1
- package/dist/topic-link.d.ts +128 -0
- package/dist/topic-link.js +269 -0
- package/dist/topic-link.js.map +1 -0
- package/dist/types.d.ts +13 -0
- package/dist/ui/app-effects.js +187 -0
- package/dist/ui/chat-effect-posters.js +42 -0
- package/dist/ui/chat-effects.js +72 -0
- package/dist/ui/chat-live-replies.js +58 -0
- package/dist/ui/chat-render.js +4 -4
- package/dist/ui/chat-store.js +9 -3
- package/dist/ui/chat-strings.js +15 -0
- package/dist/ui/chat-thread.js +47 -21
- package/dist/ui/chat-tour.js +1 -1
- package/dist/ui/effect-trial-controller.js +119 -0
- package/dist/ui/effect-trial-offer.js +31 -0
- package/dist/ui/effect-trial-panel.js +37 -0
- package/dist/ui/fleet-cache.js +23 -1
- package/dist/ui/image-lightbox.js +1 -1
- package/dist/ui/panel-chat.js +85 -17
- package/dist/ui/panel-details.js +18 -5
- package/dist/ui/panel-fleet.js +3 -3
- package/dist/ui/panel-log.js +6 -6
- package/dist/ui/panel-needs.js +3 -3
- package/dist/ui/panel-settings.js +8 -5
- package/dist/ui/preview-panel.js +25 -9
- package/dist/ui/settings-appearance.js +325 -0
- package/dist/ui/settings-effects.js +201 -0
- package/dist/ui/settings-mods.js +257 -0
- package/dist/ui/settings-owner-effects.js +80 -0
- package/dist/ui/settings-strings.js +245 -0
- package/dist/ui/shared/agend-auth.js +27 -3
- package/dist/ui/shared/agent-widget.js +248 -0
- package/dist/ui/shared/app-i18n.js +2 -0
- package/dist/ui/shared/app-prefs.js +30 -10
- package/dist/ui/shared/app-route.js +23 -1
- package/dist/ui/shared/app-session.js +1 -1
- package/dist/ui/shared/app-shell.js +29 -16
- package/dist/ui/shared/app-store.js +2 -0
- package/dist/ui/shared/app-stream.js +12 -7
- package/dist/ui/shared/app-themes.js +188 -0
- package/dist/ui/shared/app.css +225 -3
- package/dist/ui/shared/app.js +6 -0
- package/dist/ui/shared/card-strings.js +28 -0
- package/dist/ui/shared/effect-app-runtime.js +248 -0
- package/dist/ui/shared/effect-baseline.js +64 -0
- package/dist/ui/shared/effect-chat-runtime.js +154 -0
- package/dist/ui/shared/effect-cursor.js +125 -0
- package/dist/ui/shared/effect-frame.js +127 -0
- package/dist/ui/shared/effect-geometry.js +122 -0
- package/dist/ui/shared/effect-host-style.js +10 -0
- package/dist/ui/shared/effect-owner-regions.js +74 -0
- package/dist/ui/shared/effect-owner-source.js +115 -0
- package/dist/ui/shared/effect-policy.js +137 -0
- package/dist/ui/shared/effect-poster-reader.js +98 -0
- package/dist/ui/shared/effect-posters.js +117 -0
- package/dist/ui/shared/effect-profiler.js +162 -0
- package/dist/ui/shared/effect-profiles.js +65 -0
- package/dist/ui/shared/effect-registry.js +496 -0
- package/dist/ui/shared/effect-runtime-loader.js +234 -0
- package/dist/ui/shared/effect-safety.js +153 -0
- package/dist/ui/shared/effect-stock-snapshot.js +34 -0
- package/dist/ui/shared/effect-stock-trust.js +165 -0
- package/dist/ui/shared/effect-trial-source.js +103 -0
- package/dist/ui/shared/effect-trial-stage.js +147 -0
- package/dist/ui/shared/header-tools.js +1 -1
- package/dist/ui/shared/identity-card.js +202 -0
- package/dist/ui/shared/identity-color.js +261 -0
- package/dist/ui/shared/instance-nav.js +37 -19
- package/dist/ui/shared/owner-css.js +221 -0
- package/dist/ui/shared/owner-effect-prefs.js +117 -0
- package/dist/ui/shared/panel-view.js +81 -8
- package/dist/ui/shared/signin.js +2 -0
- package/dist/ui/shared/ui-dialog.js +1 -1
- package/dist/ui/shared/ui-icons.js +2 -0
- package/dist/ui/shared/ui-menu.js +1 -1
- package/dist/ui/shared/ui-toast.js +1 -1
- package/dist/ui/shared/usage-dialog.js +14 -2
- package/dist/ui/shared/view-strings.js +36 -2
- package/dist/ui/signin.html +1 -0
- package/dist/usage/providers.d.ts +3 -0
- package/dist/usage/providers.js +4 -3
- package/dist/usage/providers.js.map +1 -1
- package/dist/view-api.d.ts +29 -0
- package/dist/view-api.js +188 -8
- package/dist/view-api.js.map +1 -1
- package/dist/web-api.d.ts +42 -0
- package/dist/web-api.js +898 -36
- package/dist/web-api.js.map +1 -1
- package/dist/web-appearance-limits.d.ts +82 -0
- package/dist/web-appearance-limits.js +83 -0
- package/dist/web-appearance-limits.js.map +1 -0
- package/dist/web-appearance-policy.d.ts +66 -0
- package/dist/web-appearance-policy.js +94 -0
- package/dist/web-appearance-policy.js.map +1 -0
- package/dist/web-appearance-tools.d.ts +50 -0
- package/dist/web-appearance-tools.js +105 -0
- package/dist/web-appearance-tools.js.map +1 -0
- package/dist/web-appearance-view.d.ts +99 -0
- package/dist/web-appearance-view.js +163 -0
- package/dist/web-appearance-view.js.map +1 -0
- package/dist/web-appearance.d.ts +79 -0
- package/dist/web-appearance.js +298 -0
- package/dist/web-appearance.js.map +1 -0
- package/dist/web-asset-parts.d.ts +93 -0
- package/dist/web-asset-parts.js +133 -0
- package/dist/web-asset-parts.js.map +1 -0
- package/dist/web-auth.js +17 -1
- package/dist/web-auth.js.map +1 -1
- package/dist/web-card.d.ts +96 -0
- package/dist/web-card.js +232 -0
- package/dist/web-card.js.map +1 -0
- package/dist/web-chat-history.d.ts +13 -0
- package/dist/web-chat-history.js +23 -2
- package/dist/web-chat-history.js.map +1 -1
- package/dist/web-effect-bundle.d.ts +28 -0
- package/dist/web-effect-bundle.js +126 -0
- package/dist/web-effect-bundle.js.map +1 -0
- package/dist/web-effect-canonical.d.ts +8 -0
- package/dist/web-effect-canonical.js +27 -0
- package/dist/web-effect-canonical.js.map +1 -0
- package/dist/web-effect-library.d.ts +114 -0
- package/dist/web-effect-library.js +530 -0
- package/dist/web-effect-library.js.map +1 -0
- package/dist/web-effect-poster.d.ts +8 -0
- package/dist/web-effect-poster.js +52 -0
- package/dist/web-effect-poster.js.map +1 -0
- package/dist/web-effect-profiles.d.ts +118 -0
- package/dist/web-effect-profiles.js +180 -0
- package/dist/web-effect-profiles.js.map +1 -0
- package/dist/web-effect-service.d.ts +86 -0
- package/dist/web-effect-service.js +314 -0
- package/dist/web-effect-service.js.map +1 -0
- package/dist/web-effect-tools.d.ts +47 -0
- package/dist/web-effect-tools.js +129 -0
- package/dist/web-effect-tools.js.map +1 -0
- package/dist/web-effect-trials.d.ts +86 -0
- package/dist/web-effect-trials.js +204 -0
- package/dist/web-effect-trials.js.map +1 -0
- package/dist/web-effect-upload.d.ts +27 -0
- package/dist/web-effect-upload.js +185 -0
- package/dist/web-effect-upload.js.map +1 -0
- package/dist/web-effect-worker.d.ts +8 -0
- package/dist/web-effect-worker.js +170 -0
- package/dist/web-effect-worker.js.map +1 -0
- package/dist/web-effect.d.ts +103 -0
- package/dist/web-effect.js +153 -0
- package/dist/web-effect.js.map +1 -0
- package/dist/web-identity-color.d.ts +87 -0
- package/dist/web-identity-color.js +209 -0
- package/dist/web-identity-color.js.map +1 -0
- package/dist/web-identity-palette.d.ts +113 -0
- package/dist/web-identity-palette.js +84 -0
- package/dist/web-identity-palette.js.map +1 -0
- package/dist/web-image-meta.d.ts +25 -0
- package/dist/web-image-meta.js +200 -0
- package/dist/web-image-meta.js.map +1 -0
- package/dist/web-mod-agent.d.ts +50 -0
- package/dist/web-mod-agent.js +205 -0
- package/dist/web-mod-agent.js.map +1 -0
- package/dist/web-mod-bundle.d.ts +149 -0
- package/dist/web-mod-bundle.js +470 -0
- package/dist/web-mod-bundle.js.map +1 -0
- package/dist/web-mod-export.d.ts +59 -0
- package/dist/web-mod-export.js +79 -0
- package/dist/web-mod-export.js.map +1 -0
- package/dist/web-mod-store.d.ts +283 -0
- package/dist/web-mod-store.js +625 -0
- package/dist/web-mod-store.js.map +1 -0
- package/dist/web-org.d.ts +3 -3
- package/dist/web-org.js +11 -17
- package/dist/web-org.js.map +1 -1
- package/dist/web-owner-css-store.d.ts +180 -0
- package/dist/web-owner-css-store.js +384 -0
- package/dist/web-owner-css-store.js.map +1 -0
- package/dist/web-owner-css.d.ts +58 -0
- package/dist/web-owner-css.js +552 -0
- package/dist/web-owner-css.js.map +1 -0
- package/dist/web-prefs.d.ts +48 -0
- package/dist/web-prefs.js +209 -33
- package/dist/web-prefs.js.map +1 -1
- package/dist/web-preview.d.ts +9 -1
- package/dist/web-preview.js +24 -7
- package/dist/web-preview.js.map +1 -1
- package/dist/web-settings-patch.d.ts +18 -0
- package/dist/web-settings-patch.js +51 -0
- package/dist/web-settings-patch.js.map +1 -1
- package/dist/web-shell-routes.d.ts +1 -1
- package/dist/web-shell-routes.js +1 -1
- package/dist/web-shell-routes.js.map +1 -1
- package/dist/web-theme-store.d.ts +218 -0
- package/dist/web-theme-store.js +516 -0
- package/dist/web-theme-store.js.map +1 -0
- package/dist/web-theme.d.ts +121 -0
- package/dist/web-theme.js +407 -0
- package/dist/web-theme.js.map +1 -0
- package/dist/web-widget.d.ts +72 -0
- package/dist/web-widget.js +245 -0
- package/dist/web-widget.js.map +1 -0
- package/package.json +1 -1
- 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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
704
|
-
|
|
705
|
-
|
|
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 =
|
|
738
|
-
if (now
|
|
1540
|
+
const now = prefsViewer(req, url, ctx);
|
|
1541
|
+
if (!now?.canWrite) {
|
|
739
1542
|
refuse(now);
|
|
740
1543
|
return;
|
|
741
1544
|
}
|
|
742
|
-
|
|
743
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
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
|
|
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(
|
|
2690
|
+
return `<html lang="${lang}" data-prefs="${attr(JSON.stringify(view))}">`;
|
|
1847
2691
|
}
|
|
1848
|
-
|
|
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
|
|
1883
|
-
|
|
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" });
|