@songsid/agend 2.2.1 → 2.3.0-beta.10
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-instructions.md +5 -2
- package/dist/agent-cli.js +284 -2
- package/dist/agent-cli.js.map +1 -1
- package/dist/agent-endpoint.d.ts +2 -0
- package/dist/agent-endpoint.js +29 -0
- package/dist/agent-endpoint.js.map +1 -1
- package/dist/agent-file-read.d.ts +38 -0
- package/dist/agent-file-read.js +317 -0
- package/dist/agent-file-read.js.map +1 -0
- package/dist/agent-whats-new.d.ts +11 -0
- package/dist/agent-whats-new.js +29 -2
- 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.d.ts +30 -1
- package/dist/auth-api.js +61 -12
- package/dist/auth-api.js.map +1 -1
- package/dist/authority/authority-state.d.ts +38 -0
- package/dist/authority/authority-state.js +114 -0
- package/dist/authority/authority-state.js.map +1 -0
- package/dist/authority/session-authorizations.d.ts +43 -0
- package/dist/authority/session-authorizations.js +102 -0
- package/dist/authority/session-authorizations.js.map +1 -0
- package/dist/authority/web-authority.d.ts +120 -0
- package/dist/authority/web-authority.js +157 -0
- package/dist/authority/web-authority.js.map +1 -0
- package/dist/backend/antigravity.js +3 -3
- package/dist/backend/antigravity.js.map +1 -1
- package/dist/backend/claude-code.d.ts +2 -2
- package/dist/backend/claude-code.js +10 -5
- 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 +14 -0
- package/dist/backend/types.js +25 -0
- package/dist/backend/types.js.map +1 -1
- package/dist/backend-availability.d.ts +120 -0
- package/dist/backend-availability.js +396 -0
- package/dist/backend-availability.js.map +1 -0
- package/dist/backend-command.d.ts +21 -3
- package/dist/backend-command.js +23 -4
- package/dist/backend-command.js.map +1 -1
- package/dist/bundled-skills.d.ts +14 -0
- package/dist/bundled-skills.js +29 -0
- package/dist/bundled-skills.js.map +1 -0
- 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-ledger.d.ts +16 -3
- package/dist/cache-ledger.js +33 -13
- package/dist/cache-ledger.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 +70 -3
- package/dist/cache-service.js +178 -17
- package/dist/cache-service.js.map +1 -1
- package/dist/channel/adapters/discord.d.ts +9 -0
- package/dist/channel/adapters/discord.js +52 -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 +48 -2
- package/dist/channel/mcp-tools.js.map +1 -1
- package/dist/channel/types.d.ts +21 -0
- package/dist/classic-channel-manager.d.ts +10 -5
- package/dist/classic-channel-manager.js +12 -9
- package/dist/classic-channel-manager.js.map +1 -1
- package/dist/cli.js +130 -31
- package/dist/cli.js.map +1 -1
- package/dist/config-validator.js +119 -4
- package/dist/config-validator.js.map +1 -1
- package/dist/daemon.d.ts +5 -0
- package/dist/daemon.js +273 -22
- package/dist/daemon.js.map +1 -1
- package/dist/decision-list.d.ts +25 -0
- package/dist/decision-list.js +30 -0
- package/dist/decision-list.js.map +1 -0
- package/dist/delivery-outbox.d.ts +10 -0
- package/dist/delivery-outbox.js +12 -1
- package/dist/delivery-outbox.js.map +1 -1
- package/dist/fleet-context.d.ts +15 -0
- package/dist/fleet-manager.d.ts +506 -11
- package/dist/fleet-manager.js +1784 -130
- package/dist/fleet-manager.js.map +1 -1
- package/dist/fleet-traffic.d.ts +74 -0
- package/dist/fleet-traffic.js +196 -0
- package/dist/fleet-traffic.js.map +1 -0
- package/dist/general-knowledge/skills/credential-profiles/SKILL.md +20 -5
- package/dist/general-knowledge/skills/cross-instance-messaging/SKILL.md +6 -1
- package/dist/general-knowledge/skills/multi-channel/SKILL.md +3 -2
- package/dist/general-knowledge/skills/persona-emoji/SKILL.md +1 -1
- package/dist/general-knowledge/skills/web-appearance/SKILL.md +492 -0
- package/dist/general-knowledge/skills/web-dashboard/SKILL.md +476 -0
- package/dist/general-knowledge/skills/worker-collaboration/SKILL.md +1 -1
- package/dist/human-delivery-receipts.d.ts +24 -1
- package/dist/human-delivery-receipts.js +64 -11
- package/dist/human-delivery-receipts.js.map +1 -1
- package/dist/instance-avatar.js +3 -0
- package/dist/instance-avatar.js.map +1 -1
- package/dist/instance-config-impact.js +6 -1
- 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/instance-names.d.ts +4 -0
- package/dist/instance-names.js +12 -0
- package/dist/instance-names.js.map +1 -0
- package/dist/instructions.d.ts +12 -1
- package/dist/instructions.js +13 -2
- package/dist/instructions.js.map +1 -1
- package/dist/locale.js +100 -8
- package/dist/locale.js.map +1 -1
- package/dist/ls-attach-resolve.d.ts +95 -0
- package/dist/ls-attach-resolve.js +174 -0
- package/dist/ls-attach-resolve.js.map +1 -0
- package/dist/memory-hogs.d.ts +27 -0
- package/dist/memory-hogs.js +124 -0
- package/dist/memory-hogs.js.map +1 -0
- 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 +15 -0
- package/dist/outbound-handlers.js +152 -11
- package/dist/outbound-handlers.js.map +1 -1
- package/dist/outbound-schemas.d.ts +228 -4
- package/dist/outbound-schemas.js +147 -1
- package/dist/outbound-schemas.js.map +1 -1
- package/dist/public-web-gateway.js +21 -1
- package/dist/public-web-gateway.js.map +1 -1
- package/dist/public-web-link.d.ts +9 -0
- package/dist/public-web-link.js +8 -0
- package/dist/public-web-link.js.map +1 -1
- package/dist/quickstart-api.d.ts +10 -1
- package/dist/quickstart-api.js +12 -4
- package/dist/quickstart-api.js.map +1 -1
- package/dist/reply-buttons-controller.d.ts +4 -0
- package/dist/reply-buttons-controller.js +2 -2
- package/dist/reply-buttons-controller.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/restart-progress.d.ts +65 -1
- package/dist/restart-progress.js +236 -10
- package/dist/restart-progress.js.map +1 -1
- package/dist/rollout-index.d.ts +5 -0
- package/dist/rollout-index.js +26 -5
- package/dist/rollout-index.js.map +1 -1
- 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.d.ts +19 -0
- package/dist/settings-api.js +210 -2
- package/dist/settings-api.js.map +1 -1
- package/dist/settings-change.js +112 -0
- package/dist/settings-change.js.map +1 -1
- package/dist/settings-effect.js +32 -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/tmux-manager.d.ts +5 -0
- package/dist/tmux-manager.js +16 -0
- package/dist/tmux-manager.js.map +1 -1
- package/dist/tool-permissions.d.ts +4 -6
- package/dist/tool-permissions.js +100 -2
- package/dist/tool-permissions.js.map +1 -1
- package/dist/topic-commands.d.ts +17 -7
- package/dist/topic-commands.js +39 -21
- 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 +61 -1
- package/dist/ui/app-effects.js +233 -0
- package/dist/ui/chat-ambient.js +307 -0
- package/dist/ui/chat-effect-posters.js +42 -0
- package/dist/ui/chat-effects.js +160 -0
- package/dist/ui/chat-live-replies.js +58 -0
- package/dist/ui/chat-render.js +9 -6
- package/dist/ui/chat-store.js +9 -3
- package/dist/ui/chat-strings.js +70 -2
- package/dist/ui/chat-thread.js +191 -37
- package/dist/ui/chat-tour.js +1 -1
- package/dist/ui/effect-trial-controller.js +124 -0
- package/dist/ui/effect-trial-offer.js +31 -0
- package/dist/ui/effect-trial-panel.js +40 -0
- package/dist/ui/fleet-cache.js +23 -1
- package/dist/ui/fleet-canvas-layout.js +111 -0
- package/dist/ui/fleet-canvas.js +525 -0
- package/dist/ui/fleet-org.js +27 -12
- package/dist/ui/fleet-traffic.js +343 -0
- package/dist/ui/fleet-usage.js +401 -0
- package/dist/ui/image-lightbox.js +1 -1
- package/dist/ui/panel-chat.js +203 -57
- package/dist/ui/panel-details.js +20 -6
- package/dist/ui/panel-fleet.js +43 -18
- package/dist/ui/panel-log.js +6 -6
- package/dist/ui/panel-needs.js +3 -3
- package/dist/ui/panel-settings.js +34 -13
- package/dist/ui/preview-panel.js +25 -9
- package/dist/ui/preview.js +3 -2
- package/dist/ui/settings-appearance.js +349 -0
- package/dist/ui/settings-catalog.js +92 -0
- package/dist/ui/settings-dialogs.js +40 -12
- package/dist/ui/settings-effects.js +202 -0
- package/dist/ui/settings-guests.js +117 -0
- package/dist/ui/settings-learn.js +25 -0
- package/dist/ui/settings-model.js +15 -6
- package/dist/ui/settings-mods.js +271 -0
- package/dist/ui/settings-owner-effects.js +80 -0
- package/dist/ui/settings-strings.js +333 -10
- package/dist/ui/settings-web-authority.js +91 -0
- package/dist/ui/shared/agend-auth.js +28 -4
- package/dist/ui/shared/agent-widget.js +248 -0
- package/dist/ui/shared/app-i18n.js +6 -2
- package/dist/ui/shared/app-prefs.js +34 -13
- package/dist/ui/shared/app-route.js +24 -2
- package/dist/ui/shared/app-session.js +3 -1
- package/dist/ui/shared/app-shell.js +102 -23
- package/dist/ui/shared/app-store.js +28 -1
- package/dist/ui/shared/app-stream.js +14 -7
- package/dist/ui/shared/app-themes.js +206 -0
- package/dist/ui/shared/app.css +565 -4
- package/dist/ui/shared/app.js +23 -10
- package/dist/ui/shared/card-strings.js +32 -0
- package/dist/ui/shared/effect-app-runtime.js +297 -0
- package/dist/ui/shared/effect-baseline.js +64 -0
- package/dist/ui/shared/effect-chat-runtime.js +167 -0
- package/dist/ui/shared/effect-cursor.js +125 -0
- package/dist/ui/shared/effect-frame.js +146 -0
- package/dist/ui/shared/effect-geometry.js +137 -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 +151 -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 +184 -0
- package/dist/ui/shared/effect-profiles.js +65 -0
- package/dist/ui/shared/effect-registry.js +535 -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 +43 -0
- package/dist/ui/shared/effect-stock-trust.js +217 -0
- package/dist/ui/shared/effect-trial-shadow.js +62 -0
- package/dist/ui/shared/effect-trial-source.js +103 -0
- package/dist/ui/shared/effect-trial-stage.js +170 -0
- package/dist/ui/shared/effect-trial.css +40 -0
- package/dist/ui/shared/fx-motion.js +67 -0
- package/dist/ui/shared/header-tools.js +1 -1
- package/dist/ui/shared/identity-card.js +273 -0
- package/dist/ui/shared/identity-color.js +261 -0
- package/dist/ui/shared/instance-nav.js +215 -41
- 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 +103 -10
- package/dist/ui/shared/signin.js +2 -0
- package/dist/ui/shared/tokens.css +9 -3
- package/dist/ui/shared/ui-dialog.js +1 -1
- package/dist/ui/shared/ui-icons.js +3 -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 +22 -2
- package/dist/ui/shared/view-strings.js +98 -2
- package/dist/ui/shared/web-authorize.js +151 -0
- package/dist/ui/signin.html +1 -0
- package/dist/ui/web-terminal/terminal.css +4 -4
- package/dist/usage/providers.d.ts +28 -0
- package/dist/usage/providers.js +126 -36
- package/dist/usage/providers.js.map +1 -1
- package/dist/usage-report.d.ts +158 -0
- package/dist/usage-report.js +226 -0
- package/dist/usage-report.js.map +1 -0
- package/dist/usage-rollup.d.ts +51 -0
- package/dist/usage-rollup.js +168 -0
- package/dist/usage-rollup.js.map +1 -0
- package/dist/view-api.d.ts +68 -0
- package/dist/view-api.js +249 -8
- package/dist/view-api.js.map +1 -1
- package/dist/web-ambient.d.ts +72 -0
- package/dist/web-ambient.js +161 -0
- package/dist/web-ambient.js.map +1 -0
- package/dist/web-api.d.ts +111 -5
- package/dist/web-api.js +1235 -58
- 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 +64 -0
- package/dist/web-appearance-policy.js +87 -0
- package/dist/web-appearance-policy.js.map +1 -0
- package/dist/web-appearance-tools.d.ts +53 -0
- package/dist/web-appearance-tools.js +124 -0
- package/dist/web-appearance-tools.js.map +1 -0
- package/dist/web-appearance-view.d.ts +124 -0
- package/dist/web-appearance-view.js +278 -0
- package/dist/web-appearance-view.js.map +1 -0
- package/dist/web-appearance.d.ts +93 -0
- package/dist/web-appearance.js +373 -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 +20 -2
- package/dist/web-auth.js.map +1 -1
- package/dist/web-card.d.ts +110 -0
- package/dist/web-card.js +266 -0
- package/dist/web-card.js.map +1 -0
- package/dist/web-channel-echo.d.ts +7 -1
- package/dist/web-channel-echo.js +6 -2
- package/dist/web-channel-echo.js.map +1 -1
- package/dist/web-chat-history.d.ts +59 -0
- package/dist/web-chat-history.js +102 -4
- package/dist/web-chat-history.js.map +1 -1
- package/dist/web-commands.d.ts +1 -0
- package/dist/web-effect-bootstrap.d.ts +12 -0
- package/dist/web-effect-bootstrap.js +49 -0
- package/dist/web-effect-bootstrap.js.map +1 -0
- package/dist/web-effect-bundle.d.ts +28 -0
- package/dist/web-effect-bundle.js +171 -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-playback.d.ts +30 -0
- package/dist/web-effect-playback.js +71 -0
- package/dist/web-effect-playback.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 +93 -0
- package/dist/web-effect-service.js +335 -0
- package/dist/web-effect-service.js.map +1 -0
- package/dist/web-effect-tools.d.ts +48 -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-guest-access.d.ts +125 -0
- package/dist/web-guest-access.js +234 -0
- package/dist/web-guest-access.js.map +1 -0
- package/dist/web-guest-rooms.d.ts +82 -0
- package/dist/web-guest-rooms.js +135 -0
- package/dist/web-guest-rooms.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-login.d.ts +47 -0
- package/dist/web-login.js +115 -0
- package/dist/web-login.js.map +1 -1
- package/dist/web-mod-agent.d.ts +50 -0
- package/dist/web-mod-agent.js +207 -0
- package/dist/web-mod-agent.js.map +1 -0
- package/dist/web-mod-bundle.d.ts +157 -0
- package/dist/web-mod-bundle.js +529 -0
- package/dist/web-mod-bundle.js.map +1 -0
- package/dist/web-mod-export.d.ts +68 -0
- package/dist/web-mod-export.js +100 -0
- package/dist/web-mod-export.js.map +1 -0
- package/dist/web-mod-store.d.ts +289 -0
- package/dist/web-mod-store.js +638 -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 +62 -0
- package/dist/web-prefs.js +232 -36
- package/dist/web-prefs.js.map +1 -1
- package/dist/web-preview.d.ts +15 -3
- package/dist/web-preview.js +28 -9
- package/dist/web-preview.js.map +1 -1
- package/dist/web-returning-device.d.ts +41 -0
- package/dist/web-returning-device.js +57 -5
- package/dist/web-returning-device.js.map +1 -1
- package/dist/web-session-authorization.d.ts +132 -0
- package/dist/web-session-authorization.js +193 -0
- package/dist/web-session-authorization.js.map +1 -0
- package/dist/web-session.d.ts +70 -10
- package/dist/web-session.js +137 -22
- package/dist/web-session.js.map +1 -1
- package/dist/web-settings-patch.d.ts +67 -2
- package/dist/web-settings-patch.js +196 -1
- package/dist/web-settings-patch.js.map +1 -1
- package/dist/web-settings-tools.d.ts +89 -0
- package/dist/web-settings-tools.js +270 -0
- package/dist/web-settings-tools.js.map +1 -0
- package/dist/web-shell-routes.d.ts +2 -2
- package/dist/web-shell-routes.js +2 -2
- package/dist/web-shell-routes.js.map +1 -1
- package/dist/web-theme-store.d.ts +257 -0
- package/dist/web-theme-store.js +627 -0
- package/dist/web-theme-store.js.map +1 -0
- package/dist/web-theme.d.ts +123 -0
- package/dist/web-theme.js +416 -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/dist/workflow-templates/default.md +1 -1
- package/package.json +1 -1
- package/templates/systemd.service.ejs +8 -0
package/dist/web-api.js
CHANGED
|
@@ -1,8 +1,16 @@
|
|
|
1
1
|
import { webHumanOrigin } from "./human-delivery-receipts.js";
|
|
2
|
+
import { classifyWebPeer, confirmAfterBody, noAuthority } from "./authority/web-authority.js";
|
|
3
|
+
import { guestChatRefusal, guestCommandText, guestDeliveries, guestFrameData, guestHistory, guestMessages, guestPollBody, guestPrefs, guestRequestOf, guestSeesMessage, guestStatus } from "./web-guest-access.js";
|
|
2
4
|
import { withSpawnHoldReply } from "./spawn-hold-reply.js";
|
|
3
5
|
import { binaryProbe } from "./binary-probe.js";
|
|
4
6
|
import { settingsRequestExecution, settingsWrite, isSettingsReplay } from "./settings-request-capability.js";
|
|
5
7
|
import { readBoundedWebBody } from "./web-body.js";
|
|
8
|
+
import { MOD_MAX_BYTES } from "./web-mod-bundle.js";
|
|
9
|
+
import { buildModBundle } from "./web-mod-export.js";
|
|
10
|
+
import { AGENT_WIDGET_SLOTS, checkWidgetData, checkWidgetSpec } from "./web-widget.js";
|
|
11
|
+
import { checkOwnerCss, ownerCssAssetRefs } from "./web-owner-css.js";
|
|
12
|
+
import { canonicalText } from "./web-theme.js";
|
|
13
|
+
import { EFFECT_WIRE_LIMIT } from "./web-effect-upload.js";
|
|
6
14
|
import { gatewayRequestContext } from "./web-request-context.js";
|
|
7
15
|
import { permitWebContinuation } from "./web-continuation.js";
|
|
8
16
|
/**
|
|
@@ -28,13 +36,14 @@ import { dashboardOriginFor } from "./web-preview.js";
|
|
|
28
36
|
import { newWebMessageId, parseLastEventId } from "./web-chat-history.js";
|
|
29
37
|
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
38
|
import { getAgendHome } from "./paths.js";
|
|
31
|
-
import { OPERATOR } from "./web-prefs.js";
|
|
32
|
-
import { WEB_NAME_FALLBACK } from "./web-profile.js";
|
|
39
|
+
import { OPERATOR, isOwnerEffectKey } from "./web-prefs.js";
|
|
40
|
+
import { WEB_NAME_FALLBACK, webPersonKey } from "./web-profile.js";
|
|
33
41
|
import { commitTeamCreate, commitTeamUpdate } from "./outbound-handlers.js";
|
|
34
42
|
import { parseMentionQuery } from "./web-mentions.js";
|
|
35
43
|
import { authorizeExplicitInstanceRemoval } from "./instance-removal.js";
|
|
36
44
|
import { measureSyncWork } from "./sync-work-attribution.js";
|
|
37
45
|
import { resolveTz } from "./tz-utils.js";
|
|
46
|
+
import { filesPlayInChat } from "./web-effect.js";
|
|
38
47
|
// ── Strict public-facing schemas ────────────────────────────────────────────
|
|
39
48
|
// web-api endpoints must reject unknown fields so the dashboard cannot inject
|
|
40
49
|
// internal-only flags that would reach handleCreate/scheduler/config writers.
|
|
@@ -90,11 +99,41 @@ const safeInstanceSegment = (name) => /^[^\\/\x00]+$/.test(name) && name !== "."
|
|
|
90
99
|
* the eviction the dead entry leaks forever, and without the try/catch a
|
|
91
100
|
* single dead client breaks delivery to every client iterated after it.
|
|
92
101
|
*/
|
|
102
|
+
/** #1920 phase 1b: the open streams that are a guest's, and whose — each event is projected for them (guestFrameData). */
|
|
103
|
+
const streamGuests = new WeakMap();
|
|
104
|
+
/**
|
|
105
|
+
* #1920 §5.2: one event for the guests' streams only, each as projected for it — a message that has just become what
|
|
106
|
+
* its channel saw (an admin's page already has it, unchanged).
|
|
107
|
+
*/
|
|
108
|
+
export function broadcastToGuests(clients, event, data, onError) {
|
|
109
|
+
const guests = [...clients].filter((c) => streamGuests.has(c));
|
|
110
|
+
if (!guests.length)
|
|
111
|
+
return;
|
|
112
|
+
const live = new Set(guests);
|
|
113
|
+
broadcastSseEvent(live, event, data, onError);
|
|
114
|
+
for (const c of guests)
|
|
115
|
+
if (!live.has(c))
|
|
116
|
+
clients.delete(c); // the ones it ended (their session went)
|
|
117
|
+
}
|
|
93
118
|
export function broadcastSseEvent(clients, event, data, onError, id) {
|
|
94
119
|
const payload = sseFrame(event, data, id);
|
|
95
120
|
const dead = [];
|
|
96
121
|
for (const client of clients) {
|
|
97
122
|
try {
|
|
123
|
+
// An admin's stream gets these bytes; a guest's, the event as projected for its scope now, or nothing (no id: it
|
|
124
|
+
// never replays chat, so a cursor of the admin's stream is not its to hold).
|
|
125
|
+
const guest = streamGuests.get(client);
|
|
126
|
+
if (guest) {
|
|
127
|
+
// The exact session that opened it ended (revoked, expired, rotated away): nothing more, and the stream goes.
|
|
128
|
+
if (!guest.current()) {
|
|
129
|
+
dead.push(client);
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
const shown = guestFrameData(event, data, guest.scope(), guest);
|
|
133
|
+
if (shown !== undefined)
|
|
134
|
+
client.write(sseFrame(event, shown));
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
98
137
|
client.write(payload);
|
|
99
138
|
}
|
|
100
139
|
catch (err) {
|
|
@@ -150,6 +189,719 @@ function stillAuthorized(req, res, url, ctx) {
|
|
|
150
189
|
json(res, 401, { error: WEB_TOKEN_INVALID_MESSAGE });
|
|
151
190
|
return false;
|
|
152
191
|
}
|
|
192
|
+
/**
|
|
193
|
+
* #1629 r4 §3: the owner-CSS routes (see their call site). `who()`, asked first and again after any body: the person a
|
|
194
|
+
* signed-in human session is, or the status to answer.
|
|
195
|
+
*/
|
|
196
|
+
function handleOwnerCss(req, res, url, ctx, path, method) {
|
|
197
|
+
const store = ctx.ownerCss;
|
|
198
|
+
if (!store) {
|
|
199
|
+
json(res, 404, { error: "Owner CSS is not kept on this fleet" });
|
|
200
|
+
return true;
|
|
201
|
+
}
|
|
202
|
+
const reading = method === "GET" || method === "HEAD";
|
|
203
|
+
if (gatewayRequestContext(req)) {
|
|
204
|
+
json(res, reading ? 404 : 403, { error: "owner_css_not_over_public_link" });
|
|
205
|
+
return true;
|
|
206
|
+
}
|
|
207
|
+
const who = () => {
|
|
208
|
+
const verdict = evaluateWebRequest(req, url, ctx.webToken ?? null, ctx.webSessions, { touch: false });
|
|
209
|
+
if (verdict.kind !== "allow")
|
|
210
|
+
return { status: 401, error: WEB_TOKEN_INVALID_MESSAGE };
|
|
211
|
+
if (verdict.via !== "session" || !verdict.session)
|
|
212
|
+
return { status: 403, error: "owner_css_needs_a_signed_in_person" };
|
|
213
|
+
if (!reading && verdict.session.tier !== "admin")
|
|
214
|
+
return { status: 403, error: "owner_css_read_only" };
|
|
215
|
+
return { person: webPersonKey(verdict.session.owner) };
|
|
216
|
+
};
|
|
217
|
+
const first = who();
|
|
218
|
+
if ("status" in first) {
|
|
219
|
+
json(res, first.status, { error: first.error });
|
|
220
|
+
return true;
|
|
221
|
+
}
|
|
222
|
+
const again = () => {
|
|
223
|
+
if (!permitWebContinuation(req, res, ctx))
|
|
224
|
+
return null;
|
|
225
|
+
const now = who();
|
|
226
|
+
if ("status" in now) {
|
|
227
|
+
json(res, now.status, { error: now.error });
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
if (now.person !== first.person) {
|
|
231
|
+
json(res, 409, { error: "owner_css_person_changed" });
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
return now.person;
|
|
235
|
+
};
|
|
236
|
+
const announce = () => ctx.emitSseEvent("status", ctx.getUiStatus()); // their other devices see the new prefs rev
|
|
237
|
+
const fail = (err) => {
|
|
238
|
+
ctx.logger.error({ err: err.message }, "Owner CSS request failed");
|
|
239
|
+
try {
|
|
240
|
+
json(res, 500, { error: "Owner CSS request failed" });
|
|
241
|
+
}
|
|
242
|
+
catch { /* already answered */ }
|
|
243
|
+
};
|
|
244
|
+
if (path === "/ui/owner-css.css" && method === "GET") {
|
|
245
|
+
const v = url.searchParams.get("v");
|
|
246
|
+
const sha = v ?? store.state(first.person).active;
|
|
247
|
+
const text = sha ? store.served(first.person, sha) : null;
|
|
248
|
+
if (text === null) {
|
|
249
|
+
json(res, 404, { error: "no owner CSS" });
|
|
250
|
+
return true;
|
|
251
|
+
}
|
|
252
|
+
res.writeHead(200, {
|
|
253
|
+
"Content-Type": "text/css; charset=utf-8", "X-Content-Type-Options": "nosniff",
|
|
254
|
+
// Keyed by its sha (?v): a version's text never changes. Without ?v it is whatever is in force now.
|
|
255
|
+
"Cache-Control": v ? "private, max-age=31536000, immutable" : "private, no-store",
|
|
256
|
+
});
|
|
257
|
+
res.end(text);
|
|
258
|
+
return true;
|
|
259
|
+
}
|
|
260
|
+
const assetPath = /^\/ui\/owner-css\/asset\/([0-9a-f]{32})$/.exec(path);
|
|
261
|
+
if (assetPath && method === "GET") {
|
|
262
|
+
const asset = store.asset(first.person, assetPath[1]);
|
|
263
|
+
if (!asset) {
|
|
264
|
+
json(res, 404, { error: "no such asset" });
|
|
265
|
+
return true;
|
|
266
|
+
}
|
|
267
|
+
res.writeHead(200, {
|
|
268
|
+
"Content-Type": asset.mime, "X-Content-Type-Options": "nosniff", "Cache-Control": "private, max-age=31536000, immutable",
|
|
269
|
+
"Content-Security-Policy": "default-src 'none'; sandbox",
|
|
270
|
+
});
|
|
271
|
+
res.end(asset.bytes);
|
|
272
|
+
return true;
|
|
273
|
+
}
|
|
274
|
+
if (path === "/ui/owner-css/state" && method === "GET") {
|
|
275
|
+
json(res, 200, store.state(first.person));
|
|
276
|
+
return true;
|
|
277
|
+
}
|
|
278
|
+
if (assetPath && method === "DELETE") {
|
|
279
|
+
const removed = store.removeAsset(first.person, assetPath[1]);
|
|
280
|
+
json(res, removed.ok ? 200 : removed.status, removed.ok ? { ok: true } : { error: removed.error });
|
|
281
|
+
return true;
|
|
282
|
+
}
|
|
283
|
+
if (method !== "POST" || !["/ui/owner-css", "/ui/owner-css/keep", "/ui/owner-css/revert", "/ui/owner-css/restore", "/ui/owner-css/asset"].includes(path)) {
|
|
284
|
+
json(res, 404, { error: "not found" });
|
|
285
|
+
return true;
|
|
286
|
+
}
|
|
287
|
+
(async () => {
|
|
288
|
+
if (path === "/ui/owner-css/asset") {
|
|
289
|
+
let bytes;
|
|
290
|
+
try {
|
|
291
|
+
bytes = await readBoundedWebBody(req, 1024 * 1024);
|
|
292
|
+
}
|
|
293
|
+
catch {
|
|
294
|
+
json(res, 413, { error: "asset_quota", detail: "at most 1 MiB" });
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
const person = again();
|
|
298
|
+
if (!person)
|
|
299
|
+
return;
|
|
300
|
+
const added = store.addAsset(person, bytes);
|
|
301
|
+
if (!added.ok) {
|
|
302
|
+
json(res, added.status, { error: added.error, ...(added.detail ? { detail: added.detail } : {}) });
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
json(res, 200, { id: added.id, kind: added.kind, bytes: added.bytes });
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
let body;
|
|
309
|
+
try {
|
|
310
|
+
body = await parseBody(req);
|
|
311
|
+
}
|
|
312
|
+
catch {
|
|
313
|
+
json(res, 400, { error: "Invalid JSON" });
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
const person = again();
|
|
317
|
+
if (!person)
|
|
318
|
+
return;
|
|
319
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) {
|
|
320
|
+
json(res, 400, { error: "an object" });
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
if (path === "/ui/owner-css") {
|
|
324
|
+
const saved = store.save(person, body.text);
|
|
325
|
+
if (!saved.ok) {
|
|
326
|
+
json(res, saved.status, { error: saved.error, ...(saved.detail ? { detail: saved.detail } : {}) });
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
announce();
|
|
330
|
+
json(res, 200, { sha: saved.sha, previous: saved.previous, trial: saved.trial });
|
|
331
|
+
}
|
|
332
|
+
else if (path === "/ui/owner-css/keep") {
|
|
333
|
+
const kept = store.keep(person, body.trial);
|
|
334
|
+
if (!kept.ok) {
|
|
335
|
+
json(res, kept.status, { error: kept.error });
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
announce(); // the person's other devices drop their bar
|
|
339
|
+
json(res, 200, { active: kept.active });
|
|
340
|
+
}
|
|
341
|
+
else if (path === "/ui/owner-css/revert") {
|
|
342
|
+
// Exactly this trial, while it still is one: a trial kept (or ended) on another device meanwhile stays (409).
|
|
343
|
+
const reverted = store.revertTrial(person, body.trial);
|
|
344
|
+
if (!reverted.ok) {
|
|
345
|
+
json(res, reverted.status, { error: reverted.error });
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
announce();
|
|
349
|
+
json(res, 200, { active: reverted.active });
|
|
350
|
+
}
|
|
351
|
+
else {
|
|
352
|
+
const restored = store.restore(person, body.sha);
|
|
353
|
+
if (!restored.ok) {
|
|
354
|
+
json(res, restored.status, { error: restored.error });
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
announce();
|
|
358
|
+
json(res, 200, { active: restored.active });
|
|
359
|
+
}
|
|
360
|
+
})().catch(fail);
|
|
361
|
+
return true;
|
|
362
|
+
}
|
|
363
|
+
/** #1629: static current-placement posters, separate from the local human-only runtime API. */
|
|
364
|
+
function handleEffectPosters(res, url, ctx, path, method) {
|
|
365
|
+
res.setHeader("Cache-Control", "private, no-store");
|
|
366
|
+
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
367
|
+
const image = /^\/ui\/effect-posters\/([0-9a-f]{32})\.png$/.exec(path);
|
|
368
|
+
const names = [...url.searchParams.keys()], instance = url.searchParams.get("instance");
|
|
369
|
+
if (method !== "GET" || path !== "/ui/effect-posters" && !image || names.length !== 1 || names[0] !== "instance" || !instance || instance.length > 128) {
|
|
370
|
+
json(res, 404, { error: "not_found" });
|
|
371
|
+
return true;
|
|
372
|
+
}
|
|
373
|
+
const service = ctx.effectService;
|
|
374
|
+
if (!service) {
|
|
375
|
+
json(res, 404, { error: "effects_unavailable" });
|
|
376
|
+
return true;
|
|
377
|
+
}
|
|
378
|
+
if (image) {
|
|
379
|
+
const r = service.placedPoster(image[1], instance);
|
|
380
|
+
if (!r.ok)
|
|
381
|
+
json(res, 404, { error: "not_found" });
|
|
382
|
+
else {
|
|
383
|
+
res.writeHead(200, { "Content-Type": "image/png", "Content-Security-Policy": "default-src 'none'; sandbox" });
|
|
384
|
+
res.end(r.bytes);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
else {
|
|
388
|
+
const result = service.posterPlacements(instance);
|
|
389
|
+
json(res, result.error === "themes_unreadable" ? 503 : result.error ? 404 : 200, result);
|
|
390
|
+
}
|
|
391
|
+
return true;
|
|
392
|
+
}
|
|
393
|
+
/** #1629 Try: separate local-admin routes. These never borrow ordinary read-tier/placement authority. */
|
|
394
|
+
function handleEffectTrials(req, res, url, ctx, path, method) {
|
|
395
|
+
res.setHeader("Cache-Control", "private, no-store");
|
|
396
|
+
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
397
|
+
const reading = method === "GET";
|
|
398
|
+
if (gatewayRequestContext(req)) {
|
|
399
|
+
json(res, reading ? 404 : 403, { error: "trials_not_over_public_link" });
|
|
400
|
+
return true;
|
|
401
|
+
}
|
|
402
|
+
const who = () => {
|
|
403
|
+
const sessions = ctx.webSessions;
|
|
404
|
+
const v = evaluateWebRequest(req, url, ctx.webToken ?? null, sessions, { touch: false });
|
|
405
|
+
if (v.kind !== "allow")
|
|
406
|
+
return { error: WEB_TOKEN_INVALID_MESSAGE, status: 401 };
|
|
407
|
+
if (v.via !== "session" || v.session.tier !== "admin" || v.session.surface !== "local")
|
|
408
|
+
return { error: "trials_need_a_local_admin", status: 403 };
|
|
409
|
+
const person = webPersonKey(v.session.owner);
|
|
410
|
+
let css = "unknown";
|
|
411
|
+
try {
|
|
412
|
+
const value = ctx.ownerCss?.effectState(person);
|
|
413
|
+
if (value === "off" || value === "on")
|
|
414
|
+
css = value;
|
|
415
|
+
}
|
|
416
|
+
catch { /* unavailable is never off */ }
|
|
417
|
+
// CSS is diagnostic for Try; its host-owned shadow shell isolates controls and local paint.
|
|
418
|
+
const actor = Object.freeze({ via: "session", surface: "local", tier: "admin", sessionHash: v.session.idHash, epoch: v.session.handle, person, ownerCss: css });
|
|
419
|
+
return { actor, record: v.session, sessions };
|
|
420
|
+
};
|
|
421
|
+
const first = who();
|
|
422
|
+
if ("error" in first) {
|
|
423
|
+
json(res, first.status, { error: first.error });
|
|
424
|
+
return true;
|
|
425
|
+
}
|
|
426
|
+
const service = ctx.effectService;
|
|
427
|
+
if (!service) {
|
|
428
|
+
json(res, 404, { error: "trials_unavailable" });
|
|
429
|
+
return true;
|
|
430
|
+
}
|
|
431
|
+
const start = path === "/ui/effect-trials/start";
|
|
432
|
+
const route = /^\/ui\/effect-trials\/([0-9a-f]{32})\/(resource|poster\.png|profile|stop)$/.exec(path);
|
|
433
|
+
const resource = reading && route && ["resource", "poster.png", "profile"].includes(route[2]);
|
|
434
|
+
const stop = method === "POST" && route?.[2] === "stop";
|
|
435
|
+
if (!(resource || stop || method === "POST" && start)) {
|
|
436
|
+
json(res, 404, { error: "not_found" });
|
|
437
|
+
return true;
|
|
438
|
+
}
|
|
439
|
+
const allowed = resource ? ["ownerEpoch", "instance"] : ["ownerEpoch"];
|
|
440
|
+
if ([...url.searchParams.keys()].some(k => !allowed.includes(k)) || allowed.some(k => url.searchParams.getAll(k).length !== 1)
|
|
441
|
+
|| url.searchParams.get("ownerEpoch") !== first.actor.epoch) {
|
|
442
|
+
json(res, reading ? 404 : 409, { error: reading ? "not_found" : "trials_person_changed" });
|
|
443
|
+
return true;
|
|
444
|
+
}
|
|
445
|
+
const instance = url.searchParams.get("instance") ?? "";
|
|
446
|
+
const answer = (result) => json(res, result.error ? result.error === "not_found" ? 404
|
|
447
|
+
: ["profiles_unreadable", "profiles_busy", "profiles_write_failed"].includes(String(result.error)) ? 503 : 400 : 200, result);
|
|
448
|
+
if (resource) {
|
|
449
|
+
if (route[2] === "poster.png") {
|
|
450
|
+
const result = service.trialPoster(route[1], instance, first.actor);
|
|
451
|
+
if (!result.ok)
|
|
452
|
+
json(res, 404, { error: "not_found" });
|
|
453
|
+
else {
|
|
454
|
+
res.writeHead(200, { "Content-Type": "image/png", "Content-Security-Policy": "default-src 'none'; sandbox" });
|
|
455
|
+
res.end(result.bytes);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
else
|
|
459
|
+
answer(route[2] === "profile" ? service.trialProfile(route[1], instance, first.actor) : service.trialResource(route[1], instance, first.actor));
|
|
460
|
+
return true;
|
|
461
|
+
}
|
|
462
|
+
(async () => {
|
|
463
|
+
let bytes;
|
|
464
|
+
try {
|
|
465
|
+
bytes = await readBoundedWebBody(req, 16 * 1024);
|
|
466
|
+
}
|
|
467
|
+
catch {
|
|
468
|
+
json(res, 413, { error: "trial_body_bound" });
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
if (!permitWebContinuation(req, res, ctx))
|
|
472
|
+
return;
|
|
473
|
+
const now = who();
|
|
474
|
+
if ("error" in now) {
|
|
475
|
+
json(res, now.status, { error: now.error });
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
if (now.record !== first.record || now.sessions !== first.sessions || now.actor.sessionHash !== first.actor.sessionHash
|
|
479
|
+
|| now.actor.person !== first.actor.person || now.actor.epoch !== first.actor.epoch) {
|
|
480
|
+
json(res, 409, { error: "trials_person_changed" });
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
let body;
|
|
484
|
+
try {
|
|
485
|
+
body = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
|
|
486
|
+
}
|
|
487
|
+
catch {
|
|
488
|
+
json(res, 400, { error: "trial_json" });
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
if (start) {
|
|
492
|
+
const parsed = z.strictObject({ offer: z.string().regex(/^[0-9a-f]{32}$/), instance: z.string().min(1).max(128) }).safeParse(body);
|
|
493
|
+
if (!parsed.success) {
|
|
494
|
+
json(res, 400, { error: "trial_args" });
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
answer(service.trialStart(parsed.data.offer, parsed.data.instance, now.actor));
|
|
498
|
+
}
|
|
499
|
+
else {
|
|
500
|
+
if (!z.strictObject({}).safeParse(body).success) {
|
|
501
|
+
json(res, 400, { error: "trial_args" });
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
answer(service.trialStop(route[1], now.actor));
|
|
505
|
+
}
|
|
506
|
+
})().catch(() => { try {
|
|
507
|
+
json(res, 500, { error: "trial_request_failed" });
|
|
508
|
+
}
|
|
509
|
+
catch { /* response ended */ } });
|
|
510
|
+
return true;
|
|
511
|
+
}
|
|
512
|
+
/** #1629 B2: effect routes require a human session; route bodies never supply an actor or reference holder. */
|
|
513
|
+
function handleEffects(req, res, url, ctx, path, method) {
|
|
514
|
+
res.setHeader("Cache-Control", "private, no-store");
|
|
515
|
+
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
516
|
+
const reading = method === "GET";
|
|
517
|
+
// Reports are bounded untrusted diagnostics, not an appearance or admission write (r2 §6).
|
|
518
|
+
const profilePost = method === "POST" && /^\/ui\/effects\/profile\/[0-9a-f]{32}$/.test(path);
|
|
519
|
+
const gateway = gatewayRequestContext(req), guest = guestRequestOf(req);
|
|
520
|
+
const instanceQuery = url.searchParams.get("instance"), keys = [...url.searchParams.keys()];
|
|
521
|
+
const chatRead = reading && (path === "/ui/effects/placements" || /^\/ui\/effects\/resource\/[0-9a-f]{32}$/.test(path))
|
|
522
|
+
&& keys.length === 2 && keys.includes("instance") && keys.includes("ownerEpoch") && !!instanceQuery && instanceQuery.length <= 128;
|
|
523
|
+
// Public viewing grants current Chat reads, never ordinary library/owner/Trial access or writes.
|
|
524
|
+
if (gateway && (!chatRead || !gateway.isCurrent())) {
|
|
525
|
+
json(res, reading ? 404 : 403, { error: "effects_not_over_public_link" });
|
|
526
|
+
return true;
|
|
527
|
+
}
|
|
528
|
+
const who = () => {
|
|
529
|
+
const verdict = evaluateWebRequest(req, url, ctx.webToken ?? null, ctx.webSessions, { touch: false });
|
|
530
|
+
if (verdict.kind !== "allow")
|
|
531
|
+
return { status: 401, error: WEB_TOKEN_INVALID_MESSAGE };
|
|
532
|
+
if (verdict.via !== "session" || !verdict.session)
|
|
533
|
+
return { status: 403, error: "effects_need_a_signed_in_person" };
|
|
534
|
+
if (!reading && !profilePost && verdict.session.tier !== "admin")
|
|
535
|
+
return { status: 403, error: "effects_read_only" };
|
|
536
|
+
if (verdict.session.tier === "guest" && (!chatRead || !guest || !guest.current() || !guest.scope().has(instanceQuery)))
|
|
537
|
+
return { status: 404, error: "not_found" };
|
|
538
|
+
return { person: webPersonKey(verdict.session.owner), canWrite: verdict.session.tier === "admin", epoch: verdict.session.handle, guest: verdict.session.tier === "guest" };
|
|
539
|
+
};
|
|
540
|
+
const first = who();
|
|
541
|
+
if ("status" in first) {
|
|
542
|
+
json(res, first.status, { error: first.error });
|
|
543
|
+
return true;
|
|
544
|
+
}
|
|
545
|
+
const service = ctx.effectService;
|
|
546
|
+
if (!service) {
|
|
547
|
+
json(res, 404, { error: "effects_unavailable" });
|
|
548
|
+
return true;
|
|
549
|
+
}
|
|
550
|
+
const unavailable = new Set(["themes_unreadable", "profiles_unreadable", "library_unreadable", "references_unreadable", "library_busy", "profiles_busy", "profiles_write_failed", "library_write_failed", "prefs_unreadable"]);
|
|
551
|
+
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);
|
|
552
|
+
const idPath = /^\/ui\/effects\/(resource|profile)\/([0-9a-f]{32})$/.exec(path);
|
|
553
|
+
const instance = url.searchParams.get("instance") ?? undefined, slot = url.searchParams.get("slot");
|
|
554
|
+
if (slot !== null && instance !== undefined) {
|
|
555
|
+
json(res, 404, { error: "not_found" });
|
|
556
|
+
return true;
|
|
557
|
+
}
|
|
558
|
+
const runtimeRead = reading && (slot !== null || path === "/ui/effects/owner" || path === "/ui/effects/placements" || idPath?.[1] === "resource" || instance !== undefined && /^\/ui\/effects\/image\//.test(path));
|
|
559
|
+
if ((runtimeRead || profilePost) && (url.searchParams.getAll("ownerEpoch").length !== 1 || url.searchParams.get("ownerEpoch") !== first.epoch)) {
|
|
560
|
+
const privateResource = reading && (slot !== null || idPath?.[1] === "resource" || /^\/ui\/effects\/image\//.test(path));
|
|
561
|
+
json(res, privateResource ? 404 : 409, { error: privateResource ? "not_found" : "effects_person_changed" });
|
|
562
|
+
return true;
|
|
563
|
+
}
|
|
564
|
+
if (reading) {
|
|
565
|
+
const image = /^\/ui\/effects\/image\/([0-9a-f]{32})\/(poster|cursor)\.png$/.exec(path);
|
|
566
|
+
if (image) {
|
|
567
|
+
const r = slot === null ? service.image(first.person, image[1], image[2], instance) : service.ownerImage(first.person, image[1], image[2], slot);
|
|
568
|
+
if (!r.ok)
|
|
569
|
+
json(res, 404, { error: "not_found" });
|
|
570
|
+
else {
|
|
571
|
+
res.writeHead(200, { "Content-Type": "image/png", "Content-Security-Policy": "default-src 'none'; sandbox" });
|
|
572
|
+
res.end(r.bytes);
|
|
573
|
+
}
|
|
574
|
+
return true;
|
|
575
|
+
}
|
|
576
|
+
if (path === "/ui/effects")
|
|
577
|
+
answer({ ...service.list(first.person), canWrite: first.canWrite });
|
|
578
|
+
else if (path === "/ui/effects/owner" && instance === undefined && slot === null)
|
|
579
|
+
answer(service.ownerSelections(first.person));
|
|
580
|
+
else if (path === "/ui/effects/placements" && instance !== undefined)
|
|
581
|
+
answer(service.placements(first.person, instance, !!gateway || first.guest));
|
|
582
|
+
else if (idPath?.[1] === "resource")
|
|
583
|
+
answer(slot === null ? service.resource(first.person, idPath[2], instance, !!gateway || first.guest) : service.ownerResource(first.person, idPath[2], slot));
|
|
584
|
+
else if (idPath?.[1] === "profile" && instance === undefined)
|
|
585
|
+
answer(service.profile(first.person, idPath[2]));
|
|
586
|
+
else
|
|
587
|
+
json(res, 404, { error: "not_found" });
|
|
588
|
+
return true;
|
|
589
|
+
}
|
|
590
|
+
const deletion = /^\/ui\/effects\/([0-9a-f]{32})$/.exec(path);
|
|
591
|
+
if (method === "DELETE" && deletion) {
|
|
592
|
+
answer(service.delete(first.person, deletion[1]));
|
|
593
|
+
return true;
|
|
594
|
+
}
|
|
595
|
+
if (method !== "POST" || !["/ui/effects/upload", "/ui/effects/apply", "/ui/effects/remove", "/ui/effects/share"].includes(path) && idPath?.[1] !== "profile") {
|
|
596
|
+
json(res, 404, { error: "not_found" });
|
|
597
|
+
return true;
|
|
598
|
+
}
|
|
599
|
+
(async () => {
|
|
600
|
+
let bytes;
|
|
601
|
+
try {
|
|
602
|
+
bytes = await readBoundedWebBody(req, path === "/ui/effects/upload" ? EFFECT_WIRE_LIMIT : 16 * 1024);
|
|
603
|
+
}
|
|
604
|
+
catch {
|
|
605
|
+
json(res, 413, { error: "effect_body_bound" });
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
if (!permitWebContinuation(req, res, ctx))
|
|
609
|
+
return;
|
|
610
|
+
const now = who();
|
|
611
|
+
if ("status" in now) {
|
|
612
|
+
json(res, now.status, { error: now.error });
|
|
613
|
+
return;
|
|
614
|
+
}
|
|
615
|
+
if (now.person !== first.person || now.epoch !== first.epoch) {
|
|
616
|
+
json(res, 409, { error: "effects_person_changed" });
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
if (path === "/ui/effects/upload") {
|
|
620
|
+
answer(service.upload(now.person, bytes));
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
let body;
|
|
624
|
+
try {
|
|
625
|
+
body = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
|
|
626
|
+
}
|
|
627
|
+
catch {
|
|
628
|
+
json(res, 400, { error: "effect_json" });
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
631
|
+
if (path === "/ui/effects/apply")
|
|
632
|
+
answer(service.apply(now.person, body));
|
|
633
|
+
else if (path === "/ui/effects/remove")
|
|
634
|
+
answer(service.remove(now.person, body));
|
|
635
|
+
else if (path === "/ui/effects/share")
|
|
636
|
+
answer(service.share(now.person, body));
|
|
637
|
+
else {
|
|
638
|
+
if (body && typeof body === "object" && body.kind === "playback" && (url.searchParams.getAll("instance").length !== 1 || slot !== null)) {
|
|
639
|
+
json(res, 400, { error: "playback_report" });
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
642
|
+
answer(service.report(now.person, idPath[2], body, instance, now.epoch));
|
|
643
|
+
}
|
|
644
|
+
})().catch(() => { try {
|
|
645
|
+
json(res, 500, { error: "effect_request_failed" });
|
|
646
|
+
}
|
|
647
|
+
catch { /* response ended */ } });
|
|
648
|
+
return true;
|
|
649
|
+
}
|
|
650
|
+
/**
|
|
651
|
+
* #1629 r4 §4 (r4-5): mods — a person's import (stage, then commit), uninstall and export. Every route is a signed-in
|
|
652
|
+
* session's, writes an admin's: never the CLI's header token (the way an agent could reach it) and never the public link
|
|
653
|
+
* (absent from its manifest, and refused here too). `who()` is asked first and again once a body is in.
|
|
654
|
+
*/
|
|
655
|
+
function handleMods(req, res, url, ctx, path, method) {
|
|
656
|
+
const mods = ctx.mods;
|
|
657
|
+
if (!mods) {
|
|
658
|
+
json(res, 404, { error: "Mods are not kept on this fleet" });
|
|
659
|
+
return true;
|
|
660
|
+
}
|
|
661
|
+
const reading = method === "GET";
|
|
662
|
+
if (gatewayRequestContext(req)) {
|
|
663
|
+
json(res, reading ? 404 : 403, { error: "mods_not_over_public_link" });
|
|
664
|
+
return true;
|
|
665
|
+
}
|
|
666
|
+
const who = () => {
|
|
667
|
+
const verdict = evaluateWebRequest(req, url, ctx.webToken ?? null, ctx.webSessions, { touch: false });
|
|
668
|
+
if (verdict.kind !== "allow")
|
|
669
|
+
return { status: 401, error: WEB_TOKEN_INVALID_MESSAGE };
|
|
670
|
+
if (verdict.via !== "session" || !verdict.session)
|
|
671
|
+
return { status: 403, error: "mods_need_a_signed_in_session" };
|
|
672
|
+
if (!reading && verdict.session.tier !== "admin")
|
|
673
|
+
return { status: 403, error: "mods_need_an_admin_session" };
|
|
674
|
+
return { person: webPersonKey(verdict.session.owner) };
|
|
675
|
+
};
|
|
676
|
+
const first = who();
|
|
677
|
+
if ("status" in first) {
|
|
678
|
+
json(res, first.status, { error: first.error });
|
|
679
|
+
return true;
|
|
680
|
+
}
|
|
681
|
+
const again = () => {
|
|
682
|
+
if (!permitWebContinuation(req, res, ctx))
|
|
683
|
+
return null;
|
|
684
|
+
const now = who();
|
|
685
|
+
if ("status" in now) {
|
|
686
|
+
json(res, now.status, { error: now.error });
|
|
687
|
+
return null;
|
|
688
|
+
}
|
|
689
|
+
if (now.person !== first.person) {
|
|
690
|
+
json(res, 409, { error: "mods_person_changed" });
|
|
691
|
+
return null;
|
|
692
|
+
}
|
|
693
|
+
return now.person;
|
|
694
|
+
};
|
|
695
|
+
const answer = (r, body) => {
|
|
696
|
+
if (r.ok)
|
|
697
|
+
json(res, 200, body);
|
|
698
|
+
else
|
|
699
|
+
json(res, r.status ?? 500, { error: r.error, ...(r.detail ? { detail: r.detail } : {}), ...(r.problems ? { problems: r.problems } : {}) });
|
|
700
|
+
};
|
|
701
|
+
const fail = (err) => {
|
|
702
|
+
ctx.logger.error({ err: err.message }, "Mod request failed");
|
|
703
|
+
try {
|
|
704
|
+
json(res, 500, { error: "Mod request failed" });
|
|
705
|
+
}
|
|
706
|
+
catch { /* already answered */ }
|
|
707
|
+
};
|
|
708
|
+
if (path === "/ui/mods" && method === "GET") {
|
|
709
|
+
json(res, 200, { ...mods.list(), staged: mods.staged(first.person), proposals: mods.proposals() });
|
|
710
|
+
return true;
|
|
711
|
+
}
|
|
712
|
+
// A staged mod's picture (this session's person's stage only): the plan shows the actual pictures before install.
|
|
713
|
+
const stagedPic = /^\/ui\/mods\/staged\/([0-9a-f]{32})$/.exec(path);
|
|
714
|
+
if (stagedPic && method === "GET") {
|
|
715
|
+
const pic = mods.stagedPicture(first.person, stagedPic[1]);
|
|
716
|
+
if (!pic) {
|
|
717
|
+
json(res, 404, { error: "No such image" });
|
|
718
|
+
return true;
|
|
719
|
+
}
|
|
720
|
+
res.writeHead(200, { "Content-Type": pic.mime, "X-Content-Type-Options": "nosniff", "Cache-Control": "private, no-store", "Content-Security-Policy": "default-src 'none'; sandbox" });
|
|
721
|
+
res.end(Buffer.from(pic.bytes));
|
|
722
|
+
return true;
|
|
723
|
+
}
|
|
724
|
+
const fileMatch = /^\/ui\/mods\/file\/([0-9a-f]{32})$/.exec(path);
|
|
725
|
+
if (fileMatch && method === "GET") {
|
|
726
|
+
const text = mods.bundleText(fileMatch[1]);
|
|
727
|
+
const rec = mods.list().mods.find(m => m.id === fileMatch[1]);
|
|
728
|
+
if (text === null || !rec) {
|
|
729
|
+
json(res, 404, { error: "no_such_mod" });
|
|
730
|
+
return true;
|
|
731
|
+
}
|
|
732
|
+
// The file is the installed bundle, effects and all — also effects its import left out, which nobody installed.
|
|
733
|
+
// So the export ACL judges what the file carries, not what was installed: it downloads only when this person may
|
|
734
|
+
// read every effect in it (their own claim on that content, or a shared one). A file without effects is any admin's.
|
|
735
|
+
const fx = mods.bundleEffects(fileMatch[1]);
|
|
736
|
+
if (fx === "unknown" || (fx ?? []).some(id => !ctx.modPlacedEffect?.(first.person, id))) {
|
|
737
|
+
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" });
|
|
738
|
+
return true;
|
|
739
|
+
}
|
|
740
|
+
res.writeHead(200, { "Content-Type": "application/json; charset=utf-8", "X-Content-Type-Options": "nosniff", "Cache-Control": "private, no-store",
|
|
741
|
+
"Content-Disposition": `attachment; filename="${rec.name}-${rec.version}.agendmod.json"` });
|
|
742
|
+
res.end(text);
|
|
743
|
+
return true;
|
|
744
|
+
}
|
|
745
|
+
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)) {
|
|
746
|
+
json(res, 404, { error: "not found" });
|
|
747
|
+
return true;
|
|
748
|
+
}
|
|
749
|
+
(async () => {
|
|
750
|
+
if (path === "/ui/mods/stage") {
|
|
751
|
+
// Too large by its own declared length: answered before a byte is read (the body is drained, not cut off).
|
|
752
|
+
if (Number(req.headers["content-length"]) > MOD_MAX_BYTES) {
|
|
753
|
+
req.resume();
|
|
754
|
+
json(res, 413, { error: "mod_too_large", detail: `at most ${MOD_MAX_BYTES / 1024 / 1024} MiB` });
|
|
755
|
+
return;
|
|
756
|
+
}
|
|
757
|
+
let raw;
|
|
758
|
+
try {
|
|
759
|
+
raw = await readBoundedWebBody(req, MOD_MAX_BYTES);
|
|
760
|
+
}
|
|
761
|
+
catch {
|
|
762
|
+
json(res, 413, { error: "mod_too_large", detail: `at most ${MOD_MAX_BYTES / 1024 / 1024} MiB` });
|
|
763
|
+
return;
|
|
764
|
+
}
|
|
765
|
+
const person = again();
|
|
766
|
+
if (!person)
|
|
767
|
+
return;
|
|
768
|
+
let text;
|
|
769
|
+
try {
|
|
770
|
+
text = new TextDecoder("utf-8", { fatal: true }).decode(raw);
|
|
771
|
+
}
|
|
772
|
+
catch {
|
|
773
|
+
json(res, 400, { error: "mod_invalid", problems: [{ path: "", problem: "not UTF-8" }] });
|
|
774
|
+
return;
|
|
775
|
+
}
|
|
776
|
+
const r = mods.stage(person, text);
|
|
777
|
+
answer(r, r.ok ? { stage: r.stage, plan: r.plan, plan_hash: r.plan_hash } : null);
|
|
778
|
+
return;
|
|
779
|
+
}
|
|
780
|
+
let body;
|
|
781
|
+
try {
|
|
782
|
+
body = await parseBody(req);
|
|
783
|
+
}
|
|
784
|
+
catch {
|
|
785
|
+
json(res, 400, { error: "Invalid JSON" });
|
|
786
|
+
return;
|
|
787
|
+
}
|
|
788
|
+
const person = again();
|
|
789
|
+
if (!person)
|
|
790
|
+
return;
|
|
791
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) {
|
|
792
|
+
json(res, 400, { error: "an object" });
|
|
793
|
+
return;
|
|
794
|
+
}
|
|
795
|
+
if (path === "/ui/mods/abort") {
|
|
796
|
+
const r = mods.abort(person, body.stage);
|
|
797
|
+
answer(r, { ok: true });
|
|
798
|
+
return;
|
|
799
|
+
}
|
|
800
|
+
// r4-5b: an agent's proposal, opened into the usual review (staged as this person's), or dismissed.
|
|
801
|
+
if (path === "/ui/mods/proposal/stage") {
|
|
802
|
+
const r = mods.stageProposal(person, body.instance);
|
|
803
|
+
answer(r, r.ok ? { stage: r.stage, plan: r.plan, plan_hash: r.plan_hash } : null);
|
|
804
|
+
return;
|
|
805
|
+
}
|
|
806
|
+
if (path === "/ui/mods/proposal/dismiss") {
|
|
807
|
+
const r = mods.dismiss(body.instance);
|
|
808
|
+
answer(r, { ok: true });
|
|
809
|
+
return;
|
|
810
|
+
}
|
|
811
|
+
if (path === "/ui/mods/commit") {
|
|
812
|
+
const r = mods.commit(person, body);
|
|
813
|
+
if (r.ok)
|
|
814
|
+
ctx.emitSseEvent("status", ctx.getUiStatus()); // themes_rev and prefs_rev moved: every page re-reads
|
|
815
|
+
answer(r, r.ok ? { id: r.id, applied: r.applied, ...(r.css ? { css: r.css } : {}) } : null);
|
|
816
|
+
return;
|
|
817
|
+
}
|
|
818
|
+
if (path === "/ui/mods/uninstall") {
|
|
819
|
+
const r = mods.uninstall(body.id);
|
|
820
|
+
answer(r, r.ok ? { ok: true, released: r.released } : null);
|
|
821
|
+
return;
|
|
822
|
+
}
|
|
823
|
+
// Export: what is in force now, as a bundle file.
|
|
824
|
+
const r = exportMod(ctx, person, body);
|
|
825
|
+
if (!r.ok) {
|
|
826
|
+
json(res, r.status, { error: r.error, ...(r.detail ? { detail: r.detail } : {}) });
|
|
827
|
+
return;
|
|
828
|
+
}
|
|
829
|
+
res.writeHead(200, { "Content-Type": "application/json; charset=utf-8", "X-Content-Type-Options": "nosniff", "Cache-Control": "private, no-store",
|
|
830
|
+
"Content-Disposition": `attachment; filename="${r.filename}"`, "X-AgEnD-Effects-Omitted": String(r.omitted) });
|
|
831
|
+
res.end(r.text);
|
|
832
|
+
})().catch(fail);
|
|
833
|
+
return true;
|
|
834
|
+
}
|
|
835
|
+
/** A person's export: an instance's look (theme, identity, its widgets), their sidebar widget and their own CSS. */
|
|
836
|
+
export function exportMod(ctx, person, body) {
|
|
837
|
+
const isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
838
|
+
const inc = isObj(body.include) ? body.include : null;
|
|
839
|
+
if (!inc)
|
|
840
|
+
return { ok: false, status: 400, error: "include" };
|
|
841
|
+
const meta = { name: body.name, version: body.version, title: body.title };
|
|
842
|
+
if (typeof meta.name !== "string" || !/^[a-z0-9][a-z0-9-]{0,63}$/.test(meta.name))
|
|
843
|
+
return { ok: false, status: 400, error: "name", detail: "a-z, 0-9 and - (1..64)" };
|
|
844
|
+
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))
|
|
845
|
+
return { ok: false, status: 400, error: "version", detail: "MAJOR.MINOR.PATCH" };
|
|
846
|
+
// The importer's own rule (#1897 review): what is exported always imports.
|
|
847
|
+
const title = canonicalText(meta.title, 1, 60);
|
|
848
|
+
if (title === null)
|
|
849
|
+
return { ok: false, status: 400, error: "title", detail: "1..60 characters on one line" };
|
|
850
|
+
const parts = {};
|
|
851
|
+
let omitted = 0;
|
|
852
|
+
const wantsInstance = inc.theme === true || inc.identity === true || inc.ambient === true || (isObj(inc.widgets) && AGENT_WIDGET_SLOTS.some(s => inc.widgets[s] === true));
|
|
853
|
+
let name = null;
|
|
854
|
+
if (wantsInstance) {
|
|
855
|
+
if (typeof body.instance !== "string" || !ctx.isKnownInstance?.(body.instance) || !ctx.themeStore)
|
|
856
|
+
return { ok: false, status: 400, error: "instance" };
|
|
857
|
+
name = body.instance;
|
|
858
|
+
}
|
|
859
|
+
if (name && ctx.themeStore) {
|
|
860
|
+
const store = ctx.themeStore;
|
|
861
|
+
const rec = store.get(name);
|
|
862
|
+
if (inc.theme === true) {
|
|
863
|
+
// The effects the theme places travel with it, as the library keeps them (a person may export what is placed).
|
|
864
|
+
// Only effects this person may carry away travel (Sol's ACL); the rest are counted, never packaged.
|
|
865
|
+
// #2017: an older placement of an effect that doesn't play in chat stays out (a mod can't place it): counted.
|
|
866
|
+
const effects = rec.placements.effects.flatMap(p => { const e = ctx.modPlacedEffect?.(person, p.id); if (!e || !filesPlayInChat(e.files)) {
|
|
867
|
+
omitted++;
|
|
868
|
+
return [];
|
|
869
|
+
} return [{ name: e.name, files: e.files, params: p.params ?? {} }]; });
|
|
870
|
+
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 };
|
|
871
|
+
}
|
|
872
|
+
if (inc.identity === true)
|
|
873
|
+
parts.identity = ctx.modCardText?.(name) ?? {};
|
|
874
|
+
for (const slot of AGENT_WIDGET_SLOTS) {
|
|
875
|
+
const w = rec.placements.widgets[slot];
|
|
876
|
+
if (isObj(inc.widgets) && inc.widgets[slot] === true && w)
|
|
877
|
+
(parts.widgets ??= {})[slot] = { spec: w.spec, data: w.data };
|
|
878
|
+
}
|
|
879
|
+
// #2117: its ambients, whoever set them (a person exports what is on the chat).
|
|
880
|
+
if (inc.ambient === true) {
|
|
881
|
+
const specs = Object.fromEntries(Object.entries(rec.placements.ambient).flatMap(([k, a]) => a ? [[k, a.spec]] : []));
|
|
882
|
+
parts.ambient = { specs, 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; } };
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
if (isObj(inc.widgets) && inc.widgets.sidebar === true && ctx.webPrefs) {
|
|
886
|
+
const v = ctx.webPrefs.read(OPERATOR).values["widget.sidebar"];
|
|
887
|
+
const spec = v ? checkWidgetSpec(v.spec, "sidebar") : null;
|
|
888
|
+
const data = spec?.ok ? checkWidgetData(v.data, spec.value) : null;
|
|
889
|
+
if (spec?.ok && data?.ok)
|
|
890
|
+
(parts.widgets ??= {}).sidebar = { spec: spec.value, data: { ...data.value } };
|
|
891
|
+
}
|
|
892
|
+
if (inc.ownerCss === true && ctx.ownerCss) {
|
|
893
|
+
const state = ctx.ownerCss.state(person);
|
|
894
|
+
const checked = state.text ? checkOwnerCss(state.text, new Set(state.assets.map(a => a.id))) : null;
|
|
895
|
+
if (state.text && checked?.ok) {
|
|
896
|
+
// Only the assets this text names travel (read from its tokens: url("\\61sset:<id>") names one too).
|
|
897
|
+
const named = ownerCssAssetRefs(checked.tokens);
|
|
898
|
+
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 }] : []; });
|
|
899
|
+
parts.ownerCss = { text: state.text, assets };
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
const built = buildModBundle({ name: meta.name, version: meta.version, title }, parts);
|
|
903
|
+
return built.ok ? { ...built, omitted } : { ok: false, status: built.error === "nothing_to_export" ? 400 : 409, error: built.error };
|
|
904
|
+
}
|
|
153
905
|
/**
|
|
154
906
|
* #1827: whose a request is, asked NOW (after its body arrived: a session revoked, expired or rotated away meanwhile is
|
|
155
907
|
* no one — `null`, never the operator). An authorized session is the account it was signed in as, or (an `agend web`
|
|
@@ -168,6 +920,30 @@ function webPersonOf(req, url, ctx) {
|
|
|
168
920
|
const who = webAccountOf(req, url, ctx);
|
|
169
921
|
return who ? ctx.webPerson(who.owner) : null;
|
|
170
922
|
}
|
|
923
|
+
/**
|
|
924
|
+
* #1939 S1: whether this request carries `web-session` authority, asked as it stands now — on arrival without an instance,
|
|
925
|
+
* after the body with the one it names (then confirmAfterBody joins the two). Never touches the session.
|
|
926
|
+
*/
|
|
927
|
+
function webAuthorityOf(req, url, ctx, instance) {
|
|
928
|
+
if (!ctx.webAuthority)
|
|
929
|
+
return { authority: noAuthority(0), refusal: "not_session" };
|
|
930
|
+
const v = evaluateWebRequest(req, url, ctx.webToken ?? null, ctx.webSessions, { touch: false });
|
|
931
|
+
return ctx.webAuthority({ session: v.kind === "allow" && v.via === "session" ? v.session : null, gateway: !!gatewayRequestContext(req),
|
|
932
|
+
peer: classifyWebPeer(req), ...(instance !== undefined ? { instance } : {}) });
|
|
933
|
+
}
|
|
934
|
+
/**
|
|
935
|
+
* #2122: what let this request in, asked now — its session (by handle, under the token it was signed in with) or the
|
|
936
|
+
* header token; null when nothing does any more (the gate also refuses a public link that is no longer the current
|
|
937
|
+
* one). Equal before and after an await: the same admission still holds. Never touches the session.
|
|
938
|
+
*/
|
|
939
|
+
function admissionOf(req, url, ctx) {
|
|
940
|
+
const gateway = gatewayRequestContext(req);
|
|
941
|
+
const v = evaluateWebRequest(req, url, ctx.webToken ?? null, ctx.webSessions, { touch: false });
|
|
942
|
+
if (v.kind !== "allow")
|
|
943
|
+
return null;
|
|
944
|
+
const via = v.via === "session" ? `session:${v.session.handle}:${v.session.tokenEpoch}` : "token";
|
|
945
|
+
return gateway ? `${via}@${gateway.exposureId}` : via;
|
|
946
|
+
}
|
|
171
947
|
/** #1827: the person, or the request is answered 401 here (it is no longer authorized) and null is returned. */
|
|
172
948
|
function webPersonOr401(req, res, url, ctx) {
|
|
173
949
|
const p = webPersonOf(req, url, ctx);
|
|
@@ -243,7 +1019,20 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
243
1019
|
return true;
|
|
244
1020
|
}
|
|
245
1021
|
// ── Backend detection ─────────────────────────────────
|
|
1022
|
+
// #2072: the model a Settings field offers — the cached catalog of that backend (that instance's account's). Read only.
|
|
1023
|
+
if (method === "GET" && path === "/ui/models") {
|
|
1024
|
+
const backend = url.searchParams.get("backend") ?? "";
|
|
1025
|
+
const instance = url.searchParams.get("instance");
|
|
1026
|
+
if (!/^[a-z][a-z0-9-]{0,31}$/.test(backend) || (instance !== null && (instance.length < 1 || instance.length > 256))) {
|
|
1027
|
+
json(res, 400, { error: "backend (and optional instance) required" });
|
|
1028
|
+
return true;
|
|
1029
|
+
}
|
|
1030
|
+
json(res, 200, { backend, models: ctx.modelCatalogFor?.(backend, instance) ?? [] });
|
|
1031
|
+
return true;
|
|
1032
|
+
}
|
|
246
1033
|
if (method === "GET" && path === "/ui/backends") {
|
|
1034
|
+
// #2072 (#2098 review P3-3): the backend an agent created with "fleet default" runs, so its model list can be shown.
|
|
1035
|
+
const defaultBackend = ctx.fleetConfig?.defaults?.backend ?? "claude-code";
|
|
247
1036
|
const BACKENDS = [
|
|
248
1037
|
{ name: "claude-code", binary: "claude" },
|
|
249
1038
|
{ name: "codex", binary: "codex" },
|
|
@@ -253,6 +1042,16 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
253
1042
|
{ name: "grok", binary: "grok" },
|
|
254
1043
|
{ name: "muse", binary: "muse" },
|
|
255
1044
|
];
|
|
1045
|
+
// #2072: the published availability snapshot — installed, signed in, new enough — when the fleet has one.
|
|
1046
|
+
if (ctx.backendAvailabilityRows) {
|
|
1047
|
+
const binaryOf = new Map(BACKENDS.map(b => [b.name, b]));
|
|
1048
|
+
const backends = ctx.backendAvailabilityRows().filter(r => binaryOf.has(r.backend)).map(r => ({
|
|
1049
|
+
name: r.backend, binary: binaryOf.get(r.backend).binary, installed: r.state !== "not_installed" && r.path !== null, path: r.path ?? "",
|
|
1050
|
+
deprecated: binaryOf.get(r.backend).deprecated ?? false, state: r.state, minVersion: r.minVersion, ...(r.state === "unknown" ? { unknown: true } : {}),
|
|
1051
|
+
}));
|
|
1052
|
+
json(res, 200, { backends, defaultBackend });
|
|
1053
|
+
return true;
|
|
1054
|
+
}
|
|
256
1055
|
// #1490: seven sequential 2 s `execFileSync("which")` calls used to block the event loop here. The shared probe
|
|
257
1056
|
// runs `which` as bounded async children and reuses an answer for BINARY_PROBE_TTL_MS. `unknown`: no answer in
|
|
258
1057
|
// time (shown as not installed, as the old timeout was).
|
|
@@ -262,7 +1061,7 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
262
1061
|
return { name: b.name, binary: b.binary, installed: path !== null, path: path ?? "", deprecated: b.deprecated ?? false,
|
|
263
1062
|
...(found.known ? {} : { unknown: true }) };
|
|
264
1063
|
})).then((backends) => { if (!res.headersSent)
|
|
265
|
-
json(res, 200, { backends }); }, () => { if (!res.headersSent)
|
|
1064
|
+
json(res, 200, { backends, defaultBackend }); }, () => { if (!res.headersSent)
|
|
266
1065
|
json(res, 500, { error: "backend probe failed" }); });
|
|
267
1066
|
return true;
|
|
268
1067
|
}
|
|
@@ -275,9 +1074,24 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
275
1074
|
// that arrived while the stream was silent is not skipped by the cursor this answer hands out (#1251
|
|
276
1075
|
// review). The page keeps one entry per boot+id, so what it already loaded from /ui/history is not doubled.
|
|
277
1076
|
if (method === "GET" && path === "/ui/poll") {
|
|
1077
|
+
// #1920: a guest's poll is projected for its scope (web-guest-access.ts) — the same rule as its stream.
|
|
1078
|
+
const guest = guestRequestOf(req);
|
|
278
1079
|
const history = ctx.webChatHistory;
|
|
279
1080
|
const after = url.searchParams.get("after");
|
|
280
1081
|
const fresh = after === null || after === ""; // a cursor that is not ours in shape still gets nothing
|
|
1082
|
+
if (guest) {
|
|
1083
|
+
// #2085 review: and what became the channel's after its cursor (an older row, its copy sent since), once each.
|
|
1084
|
+
json(res, 200, guestPollBody(ctx.getUiStatus(), guest.scope(), Date.now(), guest, after, () => {
|
|
1085
|
+
if (!history)
|
|
1086
|
+
return [];
|
|
1087
|
+
if (fresh)
|
|
1088
|
+
return history.after(0);
|
|
1089
|
+
const cursor = parseLastEventId(after);
|
|
1090
|
+
const rows = [...history.replayFor(cursor), ...history.seenAfter(cursor)];
|
|
1091
|
+
return rows.filter((m, i) => rows.findIndex((x) => x.boot === m.boot && x.id === m.id) === i);
|
|
1092
|
+
}));
|
|
1093
|
+
return true;
|
|
1094
|
+
}
|
|
281
1095
|
json(res, 200, {
|
|
282
1096
|
status: ctx.getUiStatus(),
|
|
283
1097
|
messages: history ? (fresh ? history.after(0) : history.replayFor(parseLastEventId(after))) : [],
|
|
@@ -310,30 +1124,49 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
310
1124
|
});
|
|
311
1125
|
// #1823 (#1830 review): with the status, the server's clock — now and with every heartbeat — that the page counts a
|
|
312
1126
|
// poll down by (one write: the frames a connect sends are unchanged in number).
|
|
313
|
-
|
|
1127
|
+
// #1920: a guest's stream: its projected status now and with each heartbeat, its replay and ticks as it may see them
|
|
1128
|
+
// (§5.2), then only what guestFrameData lets through — no prompts or Needs you.
|
|
1129
|
+
const guest = guestRequestOf(req);
|
|
1130
|
+
if (guest) {
|
|
1131
|
+
streamGuests.set(res, guest);
|
|
1132
|
+
guest.streamOpened?.();
|
|
1133
|
+
}
|
|
1134
|
+
const statusFrame = () => `event: status\ndata: ${JSON.stringify(guest ? guestStatus(ctx.getUiStatus(), guest.scope()) : ctx.getUiStatus())}\n\n`;
|
|
1135
|
+
res.write(`${statusFrame()}${sseClock()}`);
|
|
314
1136
|
// An EventSource that reconnects says what it last saw: send what it missed, before anything new.
|
|
315
1137
|
const lastSeen = parseLastEventId(req.headers["last-event-id"]);
|
|
316
1138
|
if (lastSeen !== null && ctx.webChatHistory) {
|
|
317
1139
|
const history = ctx.webChatHistory;
|
|
318
|
-
|
|
319
|
-
|
|
1140
|
+
if (guest) {
|
|
1141
|
+
// Its own projection, with no cursor: an admin's ids are not a guest's to hold (it reloads its history instead).
|
|
1142
|
+
for (const m of guestMessages(history.replayFor(lastSeen), guest, guest.scope()))
|
|
1143
|
+
res.write(sseFrame("message", m));
|
|
1144
|
+
}
|
|
1145
|
+
else {
|
|
1146
|
+
for (const m of history.replayFor(lastSeen))
|
|
1147
|
+
res.write(sseFrame("message", m, history.cursorOf(m)));
|
|
1148
|
+
}
|
|
320
1149
|
}
|
|
321
1150
|
// Ticks change messages already sent and carry no cursor: every (re)connect gets where each one is now, so a
|
|
322
1151
|
// report that fell into a gap is not lost until a reload (#1253 review). No id: it is not a message.
|
|
323
|
-
if (ctx.webChatHistory)
|
|
324
|
-
|
|
1152
|
+
if (ctx.webChatHistory) {
|
|
1153
|
+
const ticks = guest ? guestDeliveries(ctx.webChatHistory, guest, guest.scope()) : ctx.webChatHistory.deliveries();
|
|
1154
|
+
res.write(`event: deliveries\ndata: ${JSON.stringify(ticks)}\n\n`);
|
|
1155
|
+
}
|
|
325
1156
|
// The same for the fleet prompts (C4): one posted, answered or expired during a gap is caught up here.
|
|
326
|
-
if (ctx.listWebPrompts)
|
|
1157
|
+
if (!guest && ctx.listWebPrompts)
|
|
327
1158
|
res.write(`event: prompts\ndata: ${JSON.stringify(ctx.listWebPrompts())}\n\n`);
|
|
328
1159
|
// #1386: and "Needs you" as it is now; changes follow as `needs` events.
|
|
329
|
-
if (ctx.needsYouItems)
|
|
1160
|
+
if (!guest && ctx.needsYouItems)
|
|
330
1161
|
res.write(`event: needs\ndata: ${JSON.stringify({ items: ctx.needsYouItems() })}\n\n`);
|
|
331
1162
|
ctx.sseClients.add(res);
|
|
332
1163
|
const interval = setInterval(() => {
|
|
333
1164
|
// A stream authorized once must not outlive the authorization. Re-checked
|
|
334
1165
|
// without counting as activity, or an open tab would keep an idle session
|
|
335
1166
|
// alive forever; a revoked, expired or rotated-away session ends here.
|
|
336
|
-
|
|
1167
|
+
// #2049 review: a guest's stream also needs the exact session that opened it — another cookie on the same request
|
|
1168
|
+
// still authenticating does not keep a revoked device's stream (with its scope) alive.
|
|
1169
|
+
if (!isWebRequestAuthorized(req, url, ctx.webToken, ctx.webSessions, { touch: false }) || (guest && !guest.current())) {
|
|
337
1170
|
// Close the connection too, not just the response: an ended response leaves the socket idle in
|
|
338
1171
|
// keep-alive, still held open by whoever's session just ended.
|
|
339
1172
|
const socket = req.socket;
|
|
@@ -346,7 +1179,7 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
346
1179
|
}
|
|
347
1180
|
try {
|
|
348
1181
|
// Every open dashboard tab, every 10 s: the status of every instance, serialized on the fleet's loop (#1235).
|
|
349
|
-
res.write(measureSyncWork("web.sseStatus",
|
|
1182
|
+
res.write(measureSyncWork("web.sseStatus", statusFrame) + sseClock());
|
|
350
1183
|
}
|
|
351
1184
|
catch {
|
|
352
1185
|
cleanup();
|
|
@@ -380,7 +1213,10 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
380
1213
|
json(res, 400, { error: "instance (1-128 chars) and limit (1-500) required" });
|
|
381
1214
|
return true;
|
|
382
1215
|
}
|
|
383
|
-
|
|
1216
|
+
// #1920 §5.2: a guest gets what the channel showed of it, and its own messages (the gate checked the scope).
|
|
1217
|
+
const guest = guestRequestOf(req);
|
|
1218
|
+
const messages = !ctx.webChatHistory ? [] : guest ? guestHistory(ctx.webChatHistory, instance, limit, guest, guest.scope()) : ctx.webChatHistory.list(instance, limit);
|
|
1219
|
+
json(res, 200, { messages, boot: ctx.webChatHistory?.boot ?? null, lastId: ctx.webChatHistory?.lastId ?? 0 });
|
|
384
1220
|
return true;
|
|
385
1221
|
}
|
|
386
1222
|
// ── #1218: the Log tab ─────────────────────────────────
|
|
@@ -435,6 +1271,16 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
435
1271
|
const fileMatch = path.match(/^\/ui\/file\/([^/]+)$/);
|
|
436
1272
|
if (method === "GET" && fileMatch) {
|
|
437
1273
|
const id = fileMatch[1];
|
|
1274
|
+
// #1920: a guest gets a file only when its stored instance is in scope — "no such file" otherwise, like an unknown id
|
|
1275
|
+
// (an id from another instance's chat is useless).
|
|
1276
|
+
const guest = guestRequestOf(req);
|
|
1277
|
+
if (guest) {
|
|
1278
|
+
const served = isFileId(id) ? ctx.webFiles?.servedFile?.(id) ?? null : null;
|
|
1279
|
+
if (!served || !guest.scope().has(served.instance)) {
|
|
1280
|
+
json(res, 404, { error: "No such file" });
|
|
1281
|
+
return true;
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
438
1284
|
// #1589: audio and video, played in the page — one byte range at a time (206), by id only, never a whole video in
|
|
439
1285
|
// memory. Every other type is answered exactly as before.
|
|
440
1286
|
const media = isFileId(id) ? ctx.webFiles?.mediaOf?.(id) ?? null : null;
|
|
@@ -465,6 +1311,112 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
465
1311
|
res.end(got.bytes);
|
|
466
1312
|
return true;
|
|
467
1313
|
}
|
|
1314
|
+
// ── #1629: agents' themes — read by every page (the public link included: it views them), changed by a person ──
|
|
1315
|
+
// GET /ui/themes: { rev, themes, locked, widgets, ambient (#2117) } when the status frame's themes_rev moves (passive, web-auth.ts).
|
|
1316
|
+
// GET /ui/img/theme/<instance>/<id>: a theme's picture (same headers as #1630's images; the id is the content hash).
|
|
1317
|
+
// POST /ui/themes/<instance>/reset | lock | widget (r4-4: { slot } — take an agent's widget out) | ambient (#2117: { spec } sets one,
|
|
1318
|
+
// { which? } alone clears that kind or both): a person's, from an admin session — never the CLI token, never the public
|
|
1319
|
+
// link (absent from its manifest, and refused here too).
|
|
1320
|
+
if (method === "GET" && path === "/ui/themes") {
|
|
1321
|
+
const store = ctx.themeStore;
|
|
1322
|
+
if (!store) {
|
|
1323
|
+
json(res, 404, { error: "Themes are not kept on this fleet" });
|
|
1324
|
+
return true;
|
|
1325
|
+
}
|
|
1326
|
+
json(res, 200, { rev: store.rev(), themes: store.themes(), locked: store.lockedNames(), widgets: store.widgets(), ambient: store.ambients() });
|
|
1327
|
+
return true;
|
|
1328
|
+
}
|
|
1329
|
+
const themeImage = path.match(/^\/ui\/img\/theme\/([^/]+)\/([0-9a-f]{32})$/);
|
|
1330
|
+
if (method === "GET" && themeImage) {
|
|
1331
|
+
let name;
|
|
1332
|
+
try {
|
|
1333
|
+
name = decodeURIComponent(themeImage[1]);
|
|
1334
|
+
}
|
|
1335
|
+
catch {
|
|
1336
|
+
json(res, 404, { error: "No such image" });
|
|
1337
|
+
return true;
|
|
1338
|
+
}
|
|
1339
|
+
const image = ctx.themeStore?.asset(name, themeImage[2]) ?? null;
|
|
1340
|
+
if (!image) {
|
|
1341
|
+
json(res, 404, { error: "No such image" });
|
|
1342
|
+
return true;
|
|
1343
|
+
}
|
|
1344
|
+
res.setHeader("Content-Type", image.mime);
|
|
1345
|
+
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
1346
|
+
res.setHeader("Cache-Control", "private, max-age=86400");
|
|
1347
|
+
res.setHeader("Content-Security-Policy", "default-src 'none'; sandbox");
|
|
1348
|
+
res.writeHead(200);
|
|
1349
|
+
res.end(image.bytes);
|
|
1350
|
+
return true;
|
|
1351
|
+
}
|
|
1352
|
+
const themeWrite = path.match(/^\/ui\/themes\/([^/]+)\/(reset|lock|widget|ambient)$/);
|
|
1353
|
+
if (method === "POST" && themeWrite) {
|
|
1354
|
+
const service = ctx.webAppearance;
|
|
1355
|
+
if (!service) {
|
|
1356
|
+
json(res, 404, { error: "Themes are not kept on this fleet" });
|
|
1357
|
+
return true;
|
|
1358
|
+
}
|
|
1359
|
+
if (gatewayRequestContext(req)) {
|
|
1360
|
+
json(res, 403, { error: "theme_not_over_public_link" });
|
|
1361
|
+
return true;
|
|
1362
|
+
}
|
|
1363
|
+
let name;
|
|
1364
|
+
try {
|
|
1365
|
+
name = decodeURIComponent(themeWrite[1]);
|
|
1366
|
+
}
|
|
1367
|
+
catch {
|
|
1368
|
+
json(res, 400, { error: "instance" });
|
|
1369
|
+
return true;
|
|
1370
|
+
}
|
|
1371
|
+
const who = () => {
|
|
1372
|
+
const verdict = evaluateWebRequest(req, url, ctx.webToken ?? null, ctx.webSessions, { touch: false });
|
|
1373
|
+
if (verdict.kind !== "allow")
|
|
1374
|
+
return 401;
|
|
1375
|
+
return verdict.via === "session" && verdict.session?.tier === "admin" ? 200 : 403;
|
|
1376
|
+
};
|
|
1377
|
+
const first = who();
|
|
1378
|
+
if (first !== 200) {
|
|
1379
|
+
json(res, first, { error: first === 401 ? WEB_TOKEN_INVALID_MESSAGE : "theme_needs_an_admin_session" });
|
|
1380
|
+
return true;
|
|
1381
|
+
}
|
|
1382
|
+
(async () => {
|
|
1383
|
+
let body = {};
|
|
1384
|
+
try {
|
|
1385
|
+
body = await parseBody(req);
|
|
1386
|
+
}
|
|
1387
|
+
catch {
|
|
1388
|
+
json(res, 400, { error: "Invalid JSON" });
|
|
1389
|
+
return;
|
|
1390
|
+
}
|
|
1391
|
+
if (!permitWebContinuation(req, res, ctx))
|
|
1392
|
+
return;
|
|
1393
|
+
const now = who();
|
|
1394
|
+
if (now !== 200) {
|
|
1395
|
+
json(res, now, { error: now === 401 ? WEB_TOKEN_INVALID_MESSAGE : "theme_needs_an_admin_session" });
|
|
1396
|
+
return;
|
|
1397
|
+
}
|
|
1398
|
+
if (!ctx.isKnownInstance?.(name)) {
|
|
1399
|
+
json(res, 404, { error: "unknown instance" });
|
|
1400
|
+
return;
|
|
1401
|
+
}
|
|
1402
|
+
const r = themeWrite[2] === "reset" ? service.humanReset(name)
|
|
1403
|
+
: themeWrite[2] === "widget" ? service.humanRemoveWidget(name, body.slot)
|
|
1404
|
+
: themeWrite[2] === "ambient" ? service.humanSetAmbient(name, body.spec ?? null, body.which)
|
|
1405
|
+
: typeof body.locked === "boolean" ? service.humanLock(name, body.locked) : { error: "locked: true or false", status: 400 };
|
|
1406
|
+
if (typeof r.error === "string") {
|
|
1407
|
+
json(res, typeof r.status === "number" ? r.status : 503, { error: r.error });
|
|
1408
|
+
return;
|
|
1409
|
+
}
|
|
1410
|
+
json(res, 200, r);
|
|
1411
|
+
})().catch(err => {
|
|
1412
|
+
ctx.logger.error({ err: err.message }, "Theme reset/lock failed");
|
|
1413
|
+
try {
|
|
1414
|
+
json(res, 500, { error: "Theme change failed" });
|
|
1415
|
+
}
|
|
1416
|
+
catch { /* already answered */ }
|
|
1417
|
+
});
|
|
1418
|
+
return true;
|
|
1419
|
+
}
|
|
468
1420
|
// ── #1630: avatars and Discord server emoji, same-origin (the page never loads a platform's CDN; img-src stays 'self') ──
|
|
469
1421
|
const imageMatch = path.match(/^\/ui\/img\/(bot|avatar|emoji)\/([^/]+)$/);
|
|
470
1422
|
if (method === "GET" && imageMatch) {
|
|
@@ -634,6 +1586,7 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
634
1586
|
return true;
|
|
635
1587
|
}
|
|
636
1588
|
const click = ctx.clickWebReplyButton.bind(ctx);
|
|
1589
|
+
const arrival = webAuthorityOf(req, url, ctx); // #1939 §3: on arrival, and again after the body
|
|
637
1590
|
(async () => {
|
|
638
1591
|
let body;
|
|
639
1592
|
try {
|
|
@@ -650,13 +1603,29 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
650
1603
|
json(res, 400, { error: "instance, id and index required" });
|
|
651
1604
|
return;
|
|
652
1605
|
}
|
|
1606
|
+
// #1920 step 2b: a guest clicks only on a reply it may see (§5.2), of an in-scope instance, while guests may chat.
|
|
1607
|
+
const guest = guestRequestOf(req);
|
|
1608
|
+
const refused = guestChatRefusal(guest, instance);
|
|
1609
|
+
if (refused) {
|
|
1610
|
+
json(res, refused.status, { error: refused.error });
|
|
1611
|
+
return;
|
|
1612
|
+
}
|
|
1613
|
+
if (guest) {
|
|
1614
|
+
const row = guest.history?.()?.findByButtons(instance, id);
|
|
1615
|
+
if (!row || !guestSeesMessage(row, guest, guest.scope())) {
|
|
1616
|
+
json(res, 404, { error: "not found" });
|
|
1617
|
+
return;
|
|
1618
|
+
}
|
|
1619
|
+
}
|
|
653
1620
|
// #1823/#1827: the click is this page's person's — on a poll their vote (one per person, on every device of
|
|
654
1621
|
// theirs), on one-click buttons their choice — under their web id and name. A request that no longer passes the
|
|
655
1622
|
// gate (a session revoked meanwhile) clicks nothing as anyone.
|
|
656
1623
|
const person = webPersonOr401(req, res, url, ctx);
|
|
657
1624
|
if (!person)
|
|
658
1625
|
return;
|
|
659
|
-
|
|
1626
|
+
// #1920 review: a guest's choice reaches the agent marked as a guest's, as its typed messages do.
|
|
1627
|
+
const { authority } = confirmAfterBody(arrival, webAuthorityOf(req, url, ctx, instance));
|
|
1628
|
+
const r = await click(instance, id, index, { key: person.voterKey, userId: person.userId, name: person.name, ...(guest ? { guest: true } : {}), authority });
|
|
660
1629
|
json(res, r.status, r.status === 200 ? { answered: true, ...(r.options ? { options: r.options } : {}) } : { error: r.error ?? "Refused", ...(r.status === 409 ? { gone: true } : {}) });
|
|
661
1630
|
})().catch(err => {
|
|
662
1631
|
ctx.logger.error({ err: err.message }, "Web reply-button click failed");
|
|
@@ -679,15 +1648,19 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
679
1648
|
json(res, 200, { code: p.code, previewOrigin: p.previewOrigin, boot: p.previewOrigin ? p.boot : null, detail: p.detail ?? null });
|
|
680
1649
|
return true;
|
|
681
1650
|
}
|
|
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.
|
|
1651
|
+
// Host preferences preserve their scope; only five declared owner slots use the current human session bucket.
|
|
685
1652
|
if (path === "/ui/prefs" && method === "GET") {
|
|
686
1653
|
if (!ctx.webPrefs) {
|
|
687
1654
|
json(res, 404, { error: "Preferences are not kept on this fleet" });
|
|
688
1655
|
return true;
|
|
689
1656
|
}
|
|
690
|
-
|
|
1657
|
+
const viewer = prefsViewer(req, url, ctx);
|
|
1658
|
+
if (!viewer) {
|
|
1659
|
+
json(res, 401, { error: WEB_TOKEN_INVALID_MESSAGE });
|
|
1660
|
+
return true;
|
|
1661
|
+
}
|
|
1662
|
+
res.setHeader("Cache-Control", "private, no-store");
|
|
1663
|
+
json(res, 200, prefsView(ctx.webPrefs, viewer));
|
|
691
1664
|
return true;
|
|
692
1665
|
}
|
|
693
1666
|
if (path === "/ui/prefs" && method === "POST") {
|
|
@@ -700,17 +1673,9 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
700
1673
|
json(res, 403, { error: "prefs_not_over_public_link" });
|
|
701
1674
|
return true;
|
|
702
1675
|
}
|
|
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) {
|
|
1676
|
+
const refuse = (viewer) => json(res, viewer ? 403 : 401, { error: viewer ? "prefs_read_only" : WEB_TOKEN_INVALID_MESSAGE });
|
|
1677
|
+
const first = prefsViewer(req, url, ctx);
|
|
1678
|
+
if (!first?.canWrite) {
|
|
714
1679
|
refuse(first);
|
|
715
1680
|
return true;
|
|
716
1681
|
}
|
|
@@ -734,21 +1699,39 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
734
1699
|
json(res, 400, { error: parsed.error });
|
|
735
1700
|
return;
|
|
736
1701
|
}
|
|
737
|
-
const now =
|
|
738
|
-
if (now
|
|
1702
|
+
const now = prefsViewer(req, url, ctx);
|
|
1703
|
+
if (!now?.canWrite) {
|
|
739
1704
|
refuse(now);
|
|
740
1705
|
return;
|
|
741
1706
|
}
|
|
742
|
-
|
|
743
|
-
|
|
1707
|
+
if (now.person !== first.person || now.epoch !== first.epoch || now.via !== first.via) {
|
|
1708
|
+
json(res, 409, { error: "prefs_person_changed" });
|
|
1709
|
+
return;
|
|
1710
|
+
}
|
|
1711
|
+
const personal = [...parsed.patch.keys()].some(isOwnerEffectKey);
|
|
1712
|
+
if (personal && now.person === null) {
|
|
1713
|
+
json(res, 403, { error: "prefs_need_a_signed_in_person" });
|
|
1714
|
+
return;
|
|
1715
|
+
}
|
|
1716
|
+
// A scope marker rejects a queued old page after a different login, including the same person's new session.
|
|
1717
|
+
// It supplies neither identity nor authority; the current verified session still chooses the bucket.
|
|
1718
|
+
if (personal && body.ownerEpoch !== now.epoch) {
|
|
1719
|
+
json(res, 409, { error: "prefs_person_changed" });
|
|
1720
|
+
return;
|
|
1721
|
+
}
|
|
1722
|
+
const before = prefs.readForViewer(now.person);
|
|
1723
|
+
const written = prefs.writeForViewer(now.person, parsed.patch, { onlyIfUnset: body.onlyIfUnset === true,
|
|
1724
|
+
validateEffect: (person, key, id) => ctx.effectService ? ctx.effectService.validateOwnerSelection(person, key, id) : "effects_unavailable" });
|
|
744
1725
|
if (!written.ok) {
|
|
745
1726
|
json(res, written.status, { error: written.error });
|
|
746
1727
|
return;
|
|
747
1728
|
}
|
|
748
|
-
|
|
1729
|
+
const dropped = written.changed.filter(isOwnerEffectKey).flatMap(key => typeof before.values[key] === "string" && before.values[key] !== written.view.values[key] ? [before.values[key]] : []);
|
|
1730
|
+
if (dropped.length)
|
|
1731
|
+
ctx.effectService?.collectOwnerReplaced(dropped);
|
|
749
1732
|
if (written.changed.length)
|
|
750
1733
|
ctx.emitSseEvent("status", ctx.getUiStatus());
|
|
751
|
-
json(res, 200, { ...written.view, changed: written.changed });
|
|
1734
|
+
json(res, 200, { ...written.view, ...prefsScope(now), changed: written.changed });
|
|
752
1735
|
})().catch(err => {
|
|
753
1736
|
ctx.logger.error({ err: err.message }, "Saving web preferences failed");
|
|
754
1737
|
try {
|
|
@@ -758,6 +1741,24 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
758
1741
|
});
|
|
759
1742
|
return true;
|
|
760
1743
|
}
|
|
1744
|
+
// #1629 r4 §3: owner CSS — one person's own stylesheet, for their own screens. Every route is a signed-in human's
|
|
1745
|
+
// session: never the CLI's header token (no person, and the way an agent could reach it), never the public link
|
|
1746
|
+
// (absent from its manifest, and refused here too), reads included. The stylesheet and its assets are that session's
|
|
1747
|
+
// person's only: no name or identity in any URL.
|
|
1748
|
+
if (path === "/ui/owner-css.css" || path === "/ui/owner-css" || path.startsWith("/ui/owner-css/")) {
|
|
1749
|
+
return handleOwnerCss(req, res, url, ctx, path, method);
|
|
1750
|
+
}
|
|
1751
|
+
// #1629 r4-5: mods — a person's import, uninstall and export (handleMods: sessions only, never the public link).
|
|
1752
|
+
if (path === "/ui/mods" || path.startsWith("/ui/mods/"))
|
|
1753
|
+
return handleMods(req, res, url, ctx, path, method);
|
|
1754
|
+
// Posters remain behind the common session gate, with a closed GET-only public manifest.
|
|
1755
|
+
if (path === "/ui/effect-posters" || path.startsWith("/ui/effect-posters/"))
|
|
1756
|
+
return handleEffectPosters(res, url, ctx, path, method);
|
|
1757
|
+
// Canvas effects are absent from the public gateway manifest and independently refused above.
|
|
1758
|
+
if (path === "/ui/effect-trials" || path.startsWith("/ui/effect-trials/"))
|
|
1759
|
+
return handleEffectTrials(req, res, url, ctx, path, method);
|
|
1760
|
+
if (path === "/ui/effects" || path.startsWith("/ui/effects/"))
|
|
1761
|
+
return handleEffects(req, res, url, ctx, path, method);
|
|
761
1762
|
// #1386: Acknowledge a "Needs you" delivery item — a session write (CSRF, checked by the gate) or the CLI's header
|
|
762
1763
|
// token. Who did it is recorded by principal: the session's public handle, never the session id or a credential.
|
|
763
1764
|
if (method === "POST" && path === "/ui/needs/ack") {
|
|
@@ -802,12 +1803,57 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
802
1803
|
// #1269: an instance's chat command (/ctx, /compact, /clear, /model, …) — a session write (CSRF, checked by the
|
|
803
1804
|
// gate), run through the platforms' own handlers and command table. The public link is told it is one (/save is
|
|
804
1805
|
// refused there).
|
|
1806
|
+
// ── #1939 S2b: this browser's authorization — what the app shows (GET), and "Authorize this browser" (POST: posts the
|
|
1807
|
+
// Confirm card in the account's DM). Judged on this request (§3), never touching the session.
|
|
1808
|
+
if (path === "/ui/authorization" && (method === "GET" || method === "POST")) {
|
|
1809
|
+
const judge = () => {
|
|
1810
|
+
const v = evaluateWebRequest(req, url, ctx.webToken ?? null, ctx.webSessions, { touch: false });
|
|
1811
|
+
return { session: v.kind === "allow" && v.via === "session" ? v.session : null, gateway: !!gatewayRequestContext(req), peer: classifyWebPeer(req) };
|
|
1812
|
+
};
|
|
1813
|
+
res.setHeader("Cache-Control", "no-store");
|
|
1814
|
+
if (method === "GET") {
|
|
1815
|
+
if (!ctx.webSessionAuthorization) {
|
|
1816
|
+
json(res, 404, { error: "not available" });
|
|
1817
|
+
return true;
|
|
1818
|
+
}
|
|
1819
|
+
json(res, 200, ctx.webSessionAuthorization(judge()));
|
|
1820
|
+
return true;
|
|
1821
|
+
}
|
|
1822
|
+
if (!ctx.requestSessionAuthorization) {
|
|
1823
|
+
json(res, 404, { error: "not available" });
|
|
1824
|
+
return true;
|
|
1825
|
+
}
|
|
1826
|
+
const ask = ctx.requestSessionAuthorization.bind(ctx);
|
|
1827
|
+
(async () => {
|
|
1828
|
+
try {
|
|
1829
|
+
await parseBody(req);
|
|
1830
|
+
}
|
|
1831
|
+
catch {
|
|
1832
|
+
json(res, 400, { error: "Invalid JSON" });
|
|
1833
|
+
return;
|
|
1834
|
+
}
|
|
1835
|
+
if (!permitWebContinuation(req, res, ctx))
|
|
1836
|
+
return;
|
|
1837
|
+
const r = await ask(judge()); // asked again now the body is in
|
|
1838
|
+
// A refusal is an answer the page shows (pending, three this hour, why not), not a failed request: 200 with
|
|
1839
|
+
// ok: false — a 4xx would be logged as an error in the browser's console every time.
|
|
1840
|
+
json(res, 200, r);
|
|
1841
|
+
})().catch(err => {
|
|
1842
|
+
ctx.logger.error({ err: err.message }, "Web authorization request failed");
|
|
1843
|
+
try {
|
|
1844
|
+
json(res, 500, { error: "Request failed" });
|
|
1845
|
+
}
|
|
1846
|
+
catch { /* already answered */ }
|
|
1847
|
+
});
|
|
1848
|
+
return true;
|
|
1849
|
+
}
|
|
805
1850
|
if (method === "POST" && path === "/ui/command") {
|
|
806
1851
|
if (!ctx.webCommand) {
|
|
807
1852
|
json(res, 404, { error: "Not available" });
|
|
808
1853
|
return true;
|
|
809
1854
|
}
|
|
810
1855
|
const run = ctx.webCommand.bind(ctx);
|
|
1856
|
+
const arrival = webAuthorityOf(req, url, ctx); // #1939 §3: on arrival, and again after the body
|
|
811
1857
|
(async () => {
|
|
812
1858
|
let body;
|
|
813
1859
|
try {
|
|
@@ -836,7 +1882,8 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
836
1882
|
const person = webPersonOr401(req, res, url, ctx); // #1827: asked again, now the body is in
|
|
837
1883
|
if (!person)
|
|
838
1884
|
return;
|
|
839
|
-
const
|
|
1885
|
+
const { authority } = confirmAfterBody(arrival, webAuthorityOf(req, url, ctx, instance));
|
|
1886
|
+
const r = await run({ instance, command, args: str(body.args, MAX_COMMAND_ARGS), confirm: body.confirm }, { publicLink: !!gatewayRequestContext(req), person, authority });
|
|
840
1887
|
json(res, r.status, r.body);
|
|
841
1888
|
})().catch(err => {
|
|
842
1889
|
ctx.logger.error({ err: err.message }, "Web command failed");
|
|
@@ -866,6 +1913,12 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
866
1913
|
json(res, 404, { error: `Instance not found: ${name}` });
|
|
867
1914
|
return true;
|
|
868
1915
|
}
|
|
1916
|
+
// #1920 step 2b: a guest's Cancel is the platform button's (anyone allowed there), while guests may chat.
|
|
1917
|
+
const cancelRefused = guestChatRefusal(guestRequestOf(req), name);
|
|
1918
|
+
if (cancelRefused) {
|
|
1919
|
+
json(res, cancelRefused.status, { error: cancelRefused.error });
|
|
1920
|
+
return true;
|
|
1921
|
+
}
|
|
869
1922
|
if (!ctx.cancelInstance(name)) {
|
|
870
1923
|
json(res, 409, { error: `${name} is not running` });
|
|
871
1924
|
return true;
|
|
@@ -1275,6 +2328,47 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
1275
2328
|
}
|
|
1276
2329
|
return true;
|
|
1277
2330
|
}
|
|
2331
|
+
// #2122: the usage report. Signed-in sessions by default (guests never: not in GUEST_ROUTES); hidden with the rest of
|
|
2332
|
+
// usage by web.usage_panel: false; over the public link only when web.usage_report_on_public_link is true. Answered
|
|
2333
|
+
// from the ledgers and rollups as they are; a catch-up runs behind. The page's CSV is built from this same answer.
|
|
2334
|
+
if (method === "GET" && path === "/ui/usage-report") {
|
|
2335
|
+
// Who may have it, asked on arrival and again before it is sent (the report is awaited: a switch turned off, a
|
|
2336
|
+
// session signed out or a public link ended meanwhile gets nothing). The same boundary both times, no new gate.
|
|
2337
|
+
const refusal = () => {
|
|
2338
|
+
if (!ctx.usageReport || ctx.fleetConfig?.web?.usage_panel === false)
|
|
2339
|
+
return [404, "Not available"];
|
|
2340
|
+
if (gatewayRequestContext(req) && ctx.fleetConfig?.web?.usage_report_on_public_link !== true)
|
|
2341
|
+
return [403, "usage_report_not_over_public_link"];
|
|
2342
|
+
return null;
|
|
2343
|
+
};
|
|
2344
|
+
// Over the public link, where to turn it on: Settings → Web on the dashboard reached directly (this computer's own
|
|
2345
|
+
// address — the public link's Settings only reads). Never the public origin's own Settings, which cannot change it.
|
|
2346
|
+
const answer = ([status, error]) => json(res, status, {
|
|
2347
|
+
error, ...(status === 403 ? { settings: `http://localhost:${ctx.fleetConfig?.health_port ?? 19280}/settings/web` } : {})
|
|
2348
|
+
});
|
|
2349
|
+
const first = refusal();
|
|
2350
|
+
if (first) {
|
|
2351
|
+
answer(first);
|
|
2352
|
+
return true;
|
|
2353
|
+
}
|
|
2354
|
+
const admitted = admissionOf(req, url, ctx);
|
|
2355
|
+
if (!admitted) {
|
|
2356
|
+
json(res, 401, { error: WEB_TOKEN_INVALID_MESSAGE });
|
|
2357
|
+
return true;
|
|
2358
|
+
}
|
|
2359
|
+
void import("./usage-report.js").then(({ usageRange, usageGroup }) => ctx.usageReport({
|
|
2360
|
+
range: usageRange(url.searchParams.get("range"), url.searchParams.get("from") ?? undefined, url.searchParams.get("to") ?? undefined),
|
|
2361
|
+
group: usageGroup(url.searchParams.get("group")),
|
|
2362
|
+
})).then((report) => {
|
|
2363
|
+
const now = refusal();
|
|
2364
|
+
if (now)
|
|
2365
|
+
return answer(now);
|
|
2366
|
+
if (admissionOf(req, url, ctx) !== admitted)
|
|
2367
|
+
return json(res, 401, { error: WEB_TOKEN_INVALID_MESSAGE });
|
|
2368
|
+
json(res, 200, report);
|
|
2369
|
+
}, (err) => { ctx.logger.error({ err }, "usage report failed"); json(res, 500, { error: "Usage report failed" }); });
|
|
2370
|
+
return true;
|
|
2371
|
+
}
|
|
1278
2372
|
// #1468: the prompt-cache expiry analysis. Opening it is a person's navigation, like any Fleet tab (it counts as
|
|
1279
2373
|
// use); the page never re-reads it on a timer. Answered from the ledgers as they are; a catch-up runs behind.
|
|
1280
2374
|
if (method === "GET" && path === "/ui/cache") {
|
|
@@ -1292,6 +2386,12 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
1292
2386
|
json(res, 200, ctx.orgChart?.() ?? { general: [], teams: [], instances: {} });
|
|
1293
2387
|
return true;
|
|
1294
2388
|
}
|
|
2389
|
+
// #1918: the org canvas's traffic on opening, and the public link's (which has no stream) on its poll cadence. The
|
|
2390
|
+
// fleet works it out at most once every two seconds and caps it, whoever asks how often.
|
|
2391
|
+
if (method === "GET" && path === "/ui/traffic") {
|
|
2392
|
+
json(res, 200, ctx.trafficSnapshot?.() ?? { now: new Date().toISOString(), items: [], delegations: [] });
|
|
2393
|
+
return true;
|
|
2394
|
+
}
|
|
1295
2395
|
if (method === "GET" && path === "/ui/teams") {
|
|
1296
2396
|
const teams = ctx.fleetConfig?.teams ?? {};
|
|
1297
2397
|
json(res, 200, { teams });
|
|
@@ -1480,6 +2580,7 @@ function webEchoPreview(message, names) {
|
|
|
1480
2580
|
}
|
|
1481
2581
|
/** Handle POST /ui/send — extracted for readability. */
|
|
1482
2582
|
function handleSendMessage(req, res, ctx) {
|
|
2583
|
+
const arrival = webAuthorityOf(req, new URL(req.url ?? "/", "http://localhost"), ctx); // #1939 §3: again after the body
|
|
1483
2584
|
let body = "";
|
|
1484
2585
|
let size = 0;
|
|
1485
2586
|
req.on("data", (chunk) => {
|
|
@@ -1503,6 +2604,17 @@ function handleSendMessage(req, res, ctx) {
|
|
|
1503
2604
|
return;
|
|
1504
2605
|
}
|
|
1505
2606
|
const { instance, message: typed, attachments = [] } = parsed.data;
|
|
2607
|
+
// #1920 step 2b: a guest writes only to an in-scope instance, while guests may chat, and never a command.
|
|
2608
|
+
const guest = guestRequestOf(req);
|
|
2609
|
+
const refused = guestChatRefusal(guest, instance);
|
|
2610
|
+
if (refused) {
|
|
2611
|
+
json(res, refused.status, { error: refused.error });
|
|
2612
|
+
return;
|
|
2613
|
+
}
|
|
2614
|
+
if (guest && guestCommandText(typed)) {
|
|
2615
|
+
json(res, 403, { error: "guest_no_commands" });
|
|
2616
|
+
return;
|
|
2617
|
+
}
|
|
1506
2618
|
const ipc = ctx.instanceIpcClients.get(instance);
|
|
1507
2619
|
if (!ipc) {
|
|
1508
2620
|
json(res, 404, { error: `Instance not found: ${instance}` });
|
|
@@ -1536,6 +2648,8 @@ function handleSendMessage(req, res, ctx) {
|
|
|
1536
2648
|
json(res, 401, { error: WEB_TOKEN_INVALID_MESSAGE });
|
|
1537
2649
|
return;
|
|
1538
2650
|
}
|
|
2651
|
+
// #1939 S1: what the receipt records about who authorized it (observe only: agents still read web as recognition).
|
|
2652
|
+
const { authority } = confirmAfterBody(arrival, webAuthorityOf(req, new URL(req.url ?? "/", "http://localhost"), ctx, instance));
|
|
1539
2653
|
const ts = new Date().toISOString();
|
|
1540
2654
|
// The id the agent is given, and the one its delivery reports come back under: the page's ticks.
|
|
1541
2655
|
const messageId = newWebMessageId();
|
|
@@ -1550,11 +2664,13 @@ function handleSendMessage(req, res, ctx) {
|
|
|
1550
2664
|
&& !ctx.isClassicInstance?.(instance) && syncAdapter && groupId && topicId != null;
|
|
1551
2665
|
// Add no runtime credentials or local attachment paths to the display copy.
|
|
1552
2666
|
const preview = webEchoPreview(message, files.map(f => f.name));
|
|
1553
|
-
// ClassicBot echo (#1320 part B): same ordering lane,
|
|
1554
|
-
//
|
|
2667
|
+
// ClassicBot echo (#1320 part B): same ordering lane, each room's setting (#2054: on unless it says false, or
|
|
2668
|
+
// the fleet-wide switch is off for a room that is unset) resolved at send time inside sendClassicWebEcho.
|
|
1555
2669
|
const canClassicEcho = ctx.isClassicInstance?.(instance) === true && !!ctx.sendClassicWebEcho;
|
|
1556
2670
|
const settleClassicEcho = canClassicEcho ? ctx.reserveWebChannelEcho?.(instance, async () => {
|
|
1557
|
-
await ctx.sendClassicWebEcho(instance, formatWebChannelEcho(person.name, preview, t("web.echo_full_text")), person.name);
|
|
2671
|
+
const posted = await ctx.sendClassicWebEcho(instance, formatWebChannelEcho(person.name, preview, t("web.echo_full_text"), { guest: !!guest }), person.name);
|
|
2672
|
+
if (posted > 0)
|
|
2673
|
+
ctx.webMessageInChannel?.(instance, messageId); // #1920 §5.2: the room saw it
|
|
1558
2674
|
}) : undefined;
|
|
1559
2675
|
const settleEcho = canEcho ? ctx.reserveWebChannelEcho?.(instance, async () => {
|
|
1560
2676
|
// A replacement adapter or edited binding is not the route we reserved.
|
|
@@ -1562,12 +2678,14 @@ function handleSendMessage(req, res, ctx) {
|
|
|
1562
2678
|
|| (ctx.getGroupIdForInstance?.(instance) ?? String(ctx.fleetConfig?.channel?.group_id ?? "")) !== groupId
|
|
1563
2679
|
|| ctx.fleetConfig?.instances[instance]?.topic_id !== topicId)
|
|
1564
2680
|
return;
|
|
1565
|
-
|
|
2681
|
+
const sent = await syncAdapter.sendText(String(groupId), formatWebChannelEcho(person.name, preview, t("web.echo_full_text"), { guest: !!guest }), { threadId: String(topicId), format: "text", allowedMentions: { parse: [] } });
|
|
2682
|
+
ctx.webMessageInChannel?.(instance, messageId); // #1920 §5.2: the topic saw it
|
|
2683
|
+
return sent;
|
|
1566
2684
|
}) : undefined;
|
|
1567
2685
|
let delivered;
|
|
1568
2686
|
try {
|
|
1569
2687
|
const deliver = (name, payload) => ctx.deliverHumanToInstance
|
|
1570
|
-
? ctx.deliverHumanToInstance(name, payload, webHumanOrigin(), typed)
|
|
2688
|
+
? ctx.deliverHumanToInstance(name, payload, webHumanOrigin(false, authority), typed)
|
|
1571
2689
|
: ctx.deliverToInstance(name, payload);
|
|
1572
2690
|
delivered = await deliver(instance, {
|
|
1573
2691
|
type: "fleet_inbound",
|
|
@@ -1580,6 +2698,7 @@ function handleSendMessage(req, res, ctx) {
|
|
|
1580
2698
|
ts,
|
|
1581
2699
|
thread_id: topicId != null ? String(topicId) : "",
|
|
1582
2700
|
source: "web",
|
|
2701
|
+
...(guest ? { web_guest: "1" } : {}), // #1920: "[user:… via web (guest), id:…]" (authority none)
|
|
1583
2702
|
adapter_id: syncAdapter?.id,
|
|
1584
2703
|
...delivery.meta,
|
|
1585
2704
|
},
|
|
@@ -1616,7 +2735,8 @@ function handleSendMessage(req, res, ctx) {
|
|
|
1616
2735
|
ctx.webFiles?.commit(files);
|
|
1617
2736
|
ctx.lastInboundUser.set(instance, person.name);
|
|
1618
2737
|
ctx.eventLog?.logActivity("message", person.name, (message || `[${files.length} file(s)]`).slice(0, 200), instance);
|
|
1619
|
-
|
|
2738
|
+
// #1920: the account that wrote it (a guest sees its own); channel-visible once its copy was sent (above).
|
|
2739
|
+
ctx.emitSseEvent("message", { instance, sender: person.name, role: "user", text: message, ts, attachments: files.map(publicAttachment), messageId, ...(person.bound ? { from: person.voterKey } : {}) });
|
|
1620
2740
|
json(res, 200, { sent: true, messageId });
|
|
1621
2741
|
}
|
|
1622
2742
|
catch {
|
|
@@ -1640,6 +2760,13 @@ function handleUpload(req, res, url, ctx) {
|
|
|
1640
2760
|
json(res, 404, { error: "uploads are not available" });
|
|
1641
2761
|
return;
|
|
1642
2762
|
}
|
|
2763
|
+
// #1920 step 2b: a guest's upload is for an in-scope instance's message, while guests may chat.
|
|
2764
|
+
const guestRefused = guestChatRefusal(guestRequestOf(req), instance);
|
|
2765
|
+
if (guestRefused) {
|
|
2766
|
+
req.resume();
|
|
2767
|
+
json(res, guestRefused.status, { error: guestRefused.error });
|
|
2768
|
+
return;
|
|
2769
|
+
}
|
|
1643
2770
|
if (!instance || instance.length > 128 || !safeInstanceSegment(instance) || !ctx.instanceIpcClients.has(instance)) {
|
|
1644
2771
|
json(res, 404, { error: `Instance not found: ${instance}` });
|
|
1645
2772
|
return;
|
|
@@ -1830,32 +2957,73 @@ function serveMediaRange(req, res, files, id, file) {
|
|
|
1830
2957
|
res.writeHead(ranged ? 206 : 200);
|
|
1831
2958
|
res.end(got.bytes);
|
|
1832
2959
|
}
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
2960
|
+
function prefsViewer(req, url, ctx) {
|
|
2961
|
+
const v = evaluateWebRequest(req, url, ctx.webToken ?? null, ctx.webSessions, { touch: false });
|
|
2962
|
+
if (v.kind !== "allow")
|
|
2963
|
+
return null;
|
|
2964
|
+
// #1920: a guest is a guest on every listener. The public link's page and reads too — before, a guest signed in through
|
|
2965
|
+
// it (#2094) read the host's preferences unfiltered: the sidebar's order and favourites, naming instances out of its
|
|
2966
|
+
// scope. Its account (phase 3) names it on its page.
|
|
2967
|
+
const guest = v.via === "session" && v.session?.tier === "guest" ? { guest: true, ...(v.session.owner ? { owner: v.session.owner } : {}) } : {};
|
|
2968
|
+
if (!gatewayRequestContext(req) && v.via === "session" && v.session) {
|
|
2969
|
+
const person = webPersonKey(v.session.owner);
|
|
2970
|
+
return { person, epoch: v.session.handle, scope: person === OPERATOR ? "operator" : "person", canWrite: v.session.tier === "admin", via: "session", ...guest };
|
|
2971
|
+
}
|
|
2972
|
+
return { person: null, epoch: null, scope: "none", canWrite: v.via !== "session" || v.session?.tier === "admin", via: v.via, ...guest };
|
|
2973
|
+
}
|
|
2974
|
+
const prefsScope = (v) => ({ ownerScope: v?.scope ?? "none", ownerEpoch: v?.epoch ?? null });
|
|
2975
|
+
// #1920: a guest reads the host's look and its own, never a preference that names an instance (guestPrefs).
|
|
2976
|
+
const prefsView = (prefs, v) => {
|
|
2977
|
+
const view = { ...prefs.readForViewer(v?.person ?? null), ...prefsScope(v) };
|
|
2978
|
+
return v?.guest ? guestPrefs(view) : view;
|
|
2979
|
+
};
|
|
2980
|
+
function shellHtmlTag(ctx, mode, viewer) {
|
|
1841
2981
|
if (mode !== "full" || !ctx.webPrefs)
|
|
1842
2982
|
return '<html lang="en">';
|
|
1843
|
-
const view = ctx.webPrefs
|
|
2983
|
+
const view = prefsView(ctx.webPrefs, viewer);
|
|
1844
2984
|
const lang = view.values.lang === "zh-TW" ? "zh-Hant" : "en";
|
|
1845
2985
|
const attr = (v) => v.replace(/[&<>"']/g, c => `&#${c.charCodeAt(0)};`);
|
|
1846
|
-
return `<html lang="${lang}" data-prefs="${attr(JSON.stringify(
|
|
2986
|
+
return `<html lang="${lang}" data-prefs="${attr(JSON.stringify(view))}">`;
|
|
2987
|
+
}
|
|
2988
|
+
/**
|
|
2989
|
+
* #1629 r4 §3: the owner CSS this load links (its sha), or null. Only the full app of a signed-in human session — never
|
|
2990
|
+
* the public link, the anonymous View reader, the CLI's header token or the sign-in and setup pages (other panels).
|
|
2991
|
+
*/
|
|
2992
|
+
function shellOwnerCss(ctx, mode, viewer) {
|
|
2993
|
+
return mode === "full" && !viewer?.guest && viewer?.person !== null && viewer?.person !== undefined && ctx.ownerCss ? ctx.ownerCss.active(viewer.person) : null;
|
|
2994
|
+
}
|
|
2995
|
+
function guestBodyAttrs(ctx, owner, attr) {
|
|
2996
|
+
const badge = ctx.guestBadge?.(owner);
|
|
2997
|
+
return ` data-tier="guest"${badge ? ` data-guest-name="${attr(badge.name)}" data-guest-platform="${attr(badge.platform)}"` : ""}`;
|
|
2998
|
+
}
|
|
2999
|
+
/** A separate session fence for Chat playback: public/guest viewers acquire no owner-pref identity. */
|
|
3000
|
+
function shellEffect(req, ctx, mode) {
|
|
3001
|
+
if (mode !== "full" || typeof ctx.effectBoot !== "string" || !/^[0-9a-f]{32}$/.test(ctx.effectBoot))
|
|
3002
|
+
return null;
|
|
3003
|
+
const v = evaluateWebRequest(req, new URL(req.url ?? "/", "http://localhost"), ctx.webToken ?? null, ctx.webSessions, { touch: false });
|
|
3004
|
+
if (v.kind !== "allow" || v.via !== "session" || !v.session || !/^[a-f0-9]{16}$/.test(v.session.handle))
|
|
3005
|
+
return null;
|
|
3006
|
+
return { boot: ctx.effectBoot, epoch: v.session.handle, scope: gatewayRequestContext(req) || v.session.tier === "guest" ? "chat" : "owner" };
|
|
1847
3007
|
}
|
|
1848
|
-
function shellBodyTag(req, ctx, mode) {
|
|
3008
|
+
function shellBodyTag(req, ctx, mode, viewer) {
|
|
1849
3009
|
const p = shellPreview(req, ctx, mode);
|
|
3010
|
+
const ownerCss = shellOwnerCss(ctx, mode, viewer), effect = shellEffect(req, ctx, mode);
|
|
3011
|
+
let ownerCssState = mode === "full" && (gatewayRequestContext(req) || viewer?.guest) ? "off" : "unknown";
|
|
3012
|
+
if (mode === "full" && !viewer?.guest && viewer?.person != null && ctx.ownerCss) {
|
|
3013
|
+
ownerCssState = ctx.ownerCss.effectState(viewer.person);
|
|
3014
|
+
if (ownerCssState === "off" && ownerCss !== null || ownerCssState === "on" && ownerCss === null)
|
|
3015
|
+
ownerCssState = "unknown";
|
|
3016
|
+
}
|
|
1850
3017
|
const attr = (v) => String(v ?? "").replace(/[&<>"']/g, c => `&#${c.charCodeAt(0)};`);
|
|
1851
3018
|
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)}"`
|
|
3019
|
+
+ `${effect ? ` data-effect-boot="${effect.boot}" data-effect-epoch="${effect.epoch}" data-effect-scope="${effect.scope}"` : ""}`
|
|
1852
3020
|
+ ` data-preview-boot="${attr(p?.previewOrigin ? p.boot : "")}" data-preview-reason="${attr(p ? p.reason : "Previews are not available on this fleet.")}"`
|
|
1853
3021
|
// #1554: no availability asked (the public link, or not the full app) still says why, in the page's language.
|
|
1854
3022
|
+ ` data-preview-reason-code="${attr(p ? p.code ?? "" : gatewayRequestContext(req) ? "publicLink" : "notOffered")}"`
|
|
1855
3023
|
// #1723: why the public link's preview tunnel failed (a fixed reason the page words), when it did.
|
|
1856
3024
|
+ `${p?.detail ? ` data-preview-detail="${attr(p.detail)}"` : ""}`
|
|
1857
|
-
// #1523 N3: whether the fleet serves /api/ai-usage (web.usage_panel), for every page's
|
|
1858
|
-
+ ` data-usage-panel="${ctx.fleetConfig?.web?.usage_panel === false ? "0" : "1"}"`
|
|
3025
|
+
// #1523 N3: whether the fleet serves /api/ai-usage (web.usage_panel), for every page's ◔ — never to a guest (#1920).
|
|
3026
|
+
+ ` data-usage-panel="${ctx.fleetConfig?.web?.usage_panel === false || viewer?.guest ? "0" : "1"}"`
|
|
1859
3027
|
// #1218: whether the Log tab is on (web.log_tab; off by default): the tab says so and offers to turn it on.
|
|
1860
3028
|
+ ` data-log-tab="${ctx.fleetConfig?.web?.log_tab === true ? "1" : "0"}"`
|
|
1861
3029
|
// #1937: the upload limits in force for this page (the config's; the public link's own cap on it) — what the chat
|
|
@@ -1863,14 +3031,19 @@ function shellBodyTag(req, ctx, mode) {
|
|
|
1863
3031
|
+ ` data-upload-limits="${attr(JSON.stringify(uploadLimits(ctx.fleetConfig?.web, { publicLink: gatewayRequestContext(req) !== undefined })))}"`
|
|
1864
3032
|
// #1770: the fleet's zone (chat-logs' and fleet.log's), in which the chat reads its message times and days.
|
|
1865
3033
|
+ ` data-fleet-tz="${attr(resolveTz())}"`
|
|
3034
|
+
// #1629 r4 §3: the person's owner CSS in force (a sha), linked by the page unless this device or this load says not.
|
|
3035
|
+
+ ` data-owner-css-state="${ownerCssState}"${ownerCss ? ` data-owner-css="${attr(ownerCss)}"` : ""}`
|
|
1866
3036
|
// #1589: the public link shows attachments as downloads only (no inline text or media): its scope is unchanged.
|
|
1867
|
-
+ `${gatewayRequestContext(req) ? ' data-public-link="1"' : ""}
|
|
3037
|
+
+ `${gatewayRequestContext(req) ? ' data-public-link="1"' : ""}`
|
|
3038
|
+
// #1920 phase 3: a guest's page — the shell shows only what a guest may use, and who it is signed in as.
|
|
3039
|
+
+ `${mode === "full" && viewer?.guest && viewer.owner ? guestBodyAttrs(ctx, viewer.owner, attr) : ""}>`;
|
|
1868
3040
|
}
|
|
1869
3041
|
/** The app shell page for one entry, under the panels' CSP; it may frame exactly <preview origin>/frame, and only
|
|
1870
3042
|
* when this load chose one. */
|
|
1871
3043
|
export function serveAppShell(req, res, ctx, mode) {
|
|
1872
3044
|
try {
|
|
1873
3045
|
const html = readFileSync(join(__dirname, "ui", "app.html"), "utf-8");
|
|
3046
|
+
const viewer = mode === "full" ? prefsViewer(req, new URL(req.url ?? "/", "http://localhost"), ctx) : null;
|
|
1874
3047
|
const p = shellPreview(req, ctx, mode);
|
|
1875
3048
|
// #1589: media plays only from this listener's own file route (path-scoped, like frame-src), at the address this page
|
|
1876
3049
|
// was loaded from (a Host the host guard accepted); never on the public link, nor where no chat is shown.
|
|
@@ -1879,8 +3052,12 @@ export function serveAppShell(req, res, ctx, mode) {
|
|
|
1879
3052
|
const mediaSrc = own ? `${own}/ui/file/` : "'none'";
|
|
1880
3053
|
// A public-link page frames what its exposure's preview names (the quick-tunnel wildcard: its host is not known when
|
|
1881
3054
|
// the page may load); every other page frames exactly the origin this load chose (#1723).
|
|
1882
|
-
const
|
|
1883
|
-
|
|
3055
|
+
const previewFrames = p && "frameSrc" in p ? p.frameSrc : p?.previewOrigin ? `${p.previewOrigin}/frame` : null;
|
|
3056
|
+
// This fixed trusted document is independent of HTML preview. Its own response confines all Worker execution.
|
|
3057
|
+
const effectOrigin = gatewayRequestContext(req)?.expectedOrigin ?? dashboardOriginFor(typeof req.headers.host === "string" ? req.headers.host : undefined, isSecureRequest(req));
|
|
3058
|
+
const effectFrame = shellEffect(req, ctx, mode) && effectOrigin ? `${effectOrigin}/effect-bootstrap` : null;
|
|
3059
|
+
const frameSrc = [previewFrames, effectFrame].filter(Boolean).join(" ");
|
|
3060
|
+
sendPanelHtml(res, html.replace('<html lang="en">', shellHtmlTag(ctx, mode, viewer)).replace("<body>", shellBodyTag(req, ctx, mode, viewer)), 200, {}, { ...(frameSrc ? { frameSrc } : {}), mediaSrc });
|
|
1884
3061
|
}
|
|
1885
3062
|
catch {
|
|
1886
3063
|
json(res, 500, { error: "app.html not found" });
|