@1agh/maude 0.43.0 → 0.44.0
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/apps/studio/acp/bridge.ts +44 -10
- package/apps/studio/acp/env.ts +23 -0
- package/apps/studio/acp/login-state.ts +263 -0
- package/apps/studio/acp/plugin-bootstrap.ts +31 -25
- package/apps/studio/acp/probe.ts +152 -1
- package/apps/studio/annotations-layer.tsx +396 -46
- package/apps/studio/api.ts +395 -2
- package/apps/studio/bin/_audio-search.mjs +140 -0
- package/apps/studio/bin/_import-asset-pdf-worker.mjs +34 -0
- package/apps/studio/bin/_import-asset.mjs +815 -0
- package/apps/studio/bin/_import-brand.mjs +635 -0
- package/apps/studio/bin/_import-tokens-alias-resolver.mjs +95 -0
- package/apps/studio/bin/_import-tokens.mjs +1201 -0
- package/apps/studio/bin/_transcribe.mjs +419 -0
- package/apps/studio/bin/_transcribe.test.mjs +80 -0
- package/apps/studio/bin/audio-search.sh +28 -0
- package/apps/studio/bin/generate.sh +154 -0
- package/apps/studio/bin/import-asset.sh +34 -0
- package/apps/studio/bin/import-brand.sh +29 -0
- package/apps/studio/bin/import-tokens.sh +33 -0
- package/apps/studio/bin/photo-bg-remove.sh +119 -21
- package/apps/studio/bin/transcribe.sh +39 -0
- package/apps/studio/canvas-edit.ts +197 -2
- package/apps/studio/canvas-lib.tsx +115 -6
- package/apps/studio/client/app.jsx +1954 -685
- package/apps/studio/client/generate-dialog.jsx +352 -0
- package/apps/studio/client/github.js +14 -0
- package/apps/studio/client/inspector-controls.jsx +781 -0
- package/apps/studio/client/panels/BrandUploadPanel.jsx +233 -0
- package/apps/studio/client/panels/ChatPanel.jsx +15 -33
- package/apps/studio/client/panels/IntroVideoDialog.jsx +44 -0
- package/apps/studio/client/panels/OnboardingWizard.jsx +12 -0
- package/apps/studio/client/panels/ReadinessList.jsx +227 -7
- package/apps/studio/client/panels/SettingsPanel.jsx +828 -0
- package/apps/studio/client/panels/SetupChecklist.jsx +223 -0
- package/apps/studio/client/photo-knobs.jsx +167 -172
- package/apps/studio/client/styles/3-shell-maude.css +334 -28
- package/apps/studio/client/styles/4-components.css +223 -0
- package/apps/studio/client/styles/6-acp-chat.css +86 -0
- package/apps/studio/client/tour/quick-setup-tour.js +39 -0
- package/apps/studio/commands/annotation-strokes-command.ts +13 -3
- package/apps/studio/config.schema.json +70 -0
- package/apps/studio/context-menu.tsx +11 -2
- package/apps/studio/context.ts +16 -0
- package/apps/studio/design-setup-readiness.ts +115 -0
- package/apps/studio/dist/client.bundle.js +2097 -2088
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/draw/palette.ts +34 -0
- package/apps/studio/footage/schema.test.ts +92 -0
- package/apps/studio/footage/schema.ts +179 -1
- package/apps/studio/generation/adapters/elevenlabs.ts +370 -0
- package/apps/studio/generation/adapters/gemini.ts +542 -0
- package/apps/studio/generation/adapters/groq.test.ts +80 -0
- package/apps/studio/generation/adapters/groq.ts +247 -0
- package/apps/studio/generation/audio-library.test.ts +95 -0
- package/apps/studio/generation/audio-library.ts +156 -0
- package/apps/studio/generation/captions.test.ts +78 -0
- package/apps/studio/generation/captions.ts +132 -0
- package/apps/studio/generation/download.ts +76 -0
- package/apps/studio/generation/elevenlabs.test.ts +223 -0
- package/apps/studio/generation/gemini.test.ts +350 -0
- package/apps/studio/generation/jobs.test.ts +128 -0
- package/apps/studio/generation/jobs.ts +243 -0
- package/apps/studio/generation/keys.test.ts +75 -0
- package/apps/studio/generation/keys.ts +162 -0
- package/apps/studio/generation/prefs.test.ts +95 -0
- package/apps/studio/generation/prefs.ts +82 -0
- package/apps/studio/generation/registry.ts +88 -0
- package/apps/studio/generation/types.test.ts +123 -0
- package/apps/studio/generation/types.ts +266 -0
- package/apps/studio/generation/whisper-models.test.ts +152 -0
- package/apps/studio/generation/whisper-models.ts +257 -0
- package/apps/studio/generation/whisper-spike-results.md +61 -0
- package/apps/studio/git/service.ts +3 -1
- package/apps/studio/http.ts +867 -9
- package/apps/studio/media/intro.mp4 +0 -0
- package/apps/studio/media-commit-chain.ts +119 -0
- package/apps/studio/paths.ts +10 -0
- package/apps/studio/readiness.ts +108 -20
- package/apps/studio/scaffold-design.ts +1145 -0
- package/apps/studio/server.ts +13 -1
- package/apps/studio/test/_helpers.ts +15 -2
- package/apps/studio/test/acp-bridge.test.ts +34 -1
- package/apps/studio/test/acp-env.test.ts +30 -0
- package/apps/studio/test/acp-plugin-bootstrap.test.ts +23 -57
- package/apps/studio/test/acp-session-plugins.test.ts +72 -1
- package/apps/studio/test/annotation-strokes-command.test.ts +17 -0
- package/apps/studio/test/annotations-layer.test.ts +174 -0
- package/apps/studio/test/canvas-origin-gate.test.ts +37 -0
- package/apps/studio/test/csp-canvas-shell.test.ts +11 -0
- package/apps/studio/test/csrf-write-guard.test.ts +23 -1
- package/apps/studio/test/design-setup-readiness.test.ts +155 -0
- package/apps/studio/test/element-structural-edit.test.ts +108 -0
- package/apps/studio/test/fixtures/fake-claude-auth.mjs +13 -0
- package/apps/studio/test/generate-route.test.ts +106 -0
- package/apps/studio/test/import-asset-browser.test.ts +64 -0
- package/apps/studio/test/import-asset-route.test.ts +71 -0
- package/apps/studio/test/import-asset.test.ts +399 -0
- package/apps/studio/test/import-brand-browser.test.ts +88 -0
- package/apps/studio/test/import-brand-route.test.ts +120 -0
- package/apps/studio/test/import-brand.test.ts +206 -0
- package/apps/studio/test/import-tokens.test.ts +722 -0
- package/apps/studio/test/media-commit-chain.test.ts +210 -0
- package/apps/studio/test/photo-bg-remove-validation.test.ts +71 -0
- package/apps/studio/test/readiness.test.ts +13 -11
- package/apps/studio/test/scaffold-design.test.ts +122 -0
- package/apps/studio/test/tour-overlay.test.tsx +16 -0
- package/apps/studio/tool-palette.tsx +11 -6
- package/apps/studio/ui-prefs.ts +130 -0
- package/apps/studio/use-canvas-media-drop.tsx +4 -2
- package/apps/studio/use-chrome-visibility.tsx +8 -2
- package/apps/studio/whats-new.json +96 -0
- package/apps/studio/ws.ts +5 -0
- package/cli/bin/maude.mjs +6 -6
- package/cli/commands/design.mjs +33 -0
- package/cli/lib/gitignore-block.mjs +1 -0
- package/cli/lib/pkg-root.mjs +107 -0
- package/cli/lib/pkg-root.test.mjs +79 -0
- package/cli/lib/reconstruct-toolset.test.mjs +194 -0
- package/cli/lib/update-check.mjs +8 -0
- package/package.json +12 -10
- package/plugins/design/dependencies.json +18 -0
package/apps/studio/http.ts
CHANGED
|
@@ -8,28 +8,75 @@ import { createHash } from 'node:crypto';
|
|
|
8
8
|
import { existsSync, readFileSync, watch } from 'node:fs';
|
|
9
9
|
import { dirname, join, posix, relative, resolve, sep } from 'node:path';
|
|
10
10
|
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
cancelSignin,
|
|
13
|
+
getClaudeAuthStatus,
|
|
14
|
+
getInstallState,
|
|
15
|
+
isSigninInFlight,
|
|
16
|
+
startInstall,
|
|
17
|
+
startSignin,
|
|
18
|
+
} from './acp/login-state.ts';
|
|
19
|
+
import { probeAcpAvailabilityAuthed } from './acp/probe.ts';
|
|
12
20
|
import { deleteChat, listChats, readChatMessages } from './acp/transcript.ts';
|
|
13
21
|
import { type Api, ASSET_MAX_BYTES, ASSET_MAX_VIDEO_BYTES } from './api.ts';
|
|
22
|
+
import { ImportAssetError, importSvg, SVG_MAX_BYTES } from './bin/_import-asset.mjs';
|
|
23
|
+
import { ImportBrandError, importBrand } from './bin/_import-brand.mjs';
|
|
14
24
|
import { buildCanvasModule } from './canvas-build.ts';
|
|
15
25
|
import { canvasLibPath } from './canvas-lib-resolver.ts';
|
|
16
26
|
import { TranspileError } from './canvas-pipeline.ts';
|
|
17
27
|
import type { AiActivity } from './collab/ai-activity.ts';
|
|
18
28
|
import type { Context } from './context.ts';
|
|
29
|
+
import { reloadConfig } from './context.ts';
|
|
30
|
+
import { probeSetupReadiness } from './design-setup-readiness.ts';
|
|
19
31
|
import { type Format, isFormat, isScope, type Scope } from './exporters/index.ts';
|
|
20
32
|
import { type ExportJobQueue, ExportQueueFullError } from './exporters/jobs.ts';
|
|
21
33
|
import type { ActiveJsonShape } from './exporters/scope.ts';
|
|
34
|
+
import { generatedClipAnalysis } from './footage/schema.ts';
|
|
22
35
|
import { createFootageStore, FOOTAGE_MAX_BYTES } from './footage-store.ts';
|
|
36
|
+
import {
|
|
37
|
+
type AudioMatch,
|
|
38
|
+
type Candidate,
|
|
39
|
+
rankMatches,
|
|
40
|
+
sanitizeReuseText,
|
|
41
|
+
} from './generation/audio-library.ts';
|
|
42
|
+
import { localizeGenAsset } from './generation/download.ts';
|
|
43
|
+
import { type GenerationJobQueue, GenerationQueueFullError } from './generation/jobs.ts';
|
|
44
|
+
import {
|
|
45
|
+
configuredProviders,
|
|
46
|
+
deleteProviderKey,
|
|
47
|
+
getProviderKey,
|
|
48
|
+
isConfigured,
|
|
49
|
+
setProviderKey,
|
|
50
|
+
} from './generation/keys.ts';
|
|
51
|
+
import {
|
|
52
|
+
isTranscriptionProvider,
|
|
53
|
+
readTranscriptionProvider,
|
|
54
|
+
writeTranscriptionProvider,
|
|
55
|
+
} from './generation/prefs.ts';
|
|
56
|
+
import {
|
|
57
|
+
createAdapter,
|
|
58
|
+
getProviderDescriptor,
|
|
59
|
+
hasProvider,
|
|
60
|
+
listProviders,
|
|
61
|
+
} from './generation/registry.ts';
|
|
62
|
+
import { validateGenRequest } from './generation/types.ts';
|
|
63
|
+
import {
|
|
64
|
+
downloadWhisperModel,
|
|
65
|
+
getWhisperModel,
|
|
66
|
+
listWhisperModels,
|
|
67
|
+
removeWhisperModel,
|
|
68
|
+
} from './generation/whisper-models.ts';
|
|
23
69
|
import { createGitEndpoints } from './git/endpoints.ts';
|
|
24
70
|
import { gitShowFile } from './git/service.ts';
|
|
25
71
|
import { createGitHubEndpoints } from './github/endpoints.ts';
|
|
26
72
|
import type { Inspect } from './inspect.ts';
|
|
27
73
|
import { canvasSlug, writeLocator } from './locator.ts';
|
|
28
|
-
import { DEV_SERVER_ROOT, STICKERS_DIR } from './paths.ts';
|
|
74
|
+
import { DEV_SERVER_ROOT, MEDIA_DIR, STICKERS_DIR } from './paths.ts';
|
|
29
75
|
import { createPhotoStore, PHOTO_EDIT_MAX_BYTES } from './photo-store.ts';
|
|
30
76
|
import { probeReadiness } from './readiness.ts';
|
|
31
77
|
import { getRuntimeBundle, packageForSlug } from './runtime-bundle.ts';
|
|
32
78
|
import { linkHub } from './sync/hub-link.ts';
|
|
79
|
+
import { readUiPrefs, type UiPrefs, writeUiPrefs } from './ui-prefs.ts';
|
|
33
80
|
import { loadWhatsNew } from './whats-new.ts';
|
|
34
81
|
import { isLoopbackHost } from './ws.ts';
|
|
35
82
|
|
|
@@ -106,6 +153,17 @@ function ext(p: string): string {
|
|
|
106
153
|
* external page can no longer reframe the canvas. When `mainOrigin` is unknown
|
|
107
154
|
* (tests / pre-boot) the directive is OMITTED rather than set to `'self'` —
|
|
108
155
|
* `'self'` alone would forbid the legit cross-origin embed and blank the canvas.
|
|
156
|
+
*
|
|
157
|
+
* `connect-src` narrow exception (fix-photo-editor-followup-debt, Task 7):
|
|
158
|
+
* `@imgly/background-removal`'s model weights (~11-44 MB) fetch from IMG.LY's
|
|
159
|
+
* own CDN, `https://staticimgly.com`, on first client-side use — there is no
|
|
160
|
+
* self-hosting mechanism today (a separate, larger follow-up; see DDR-054's
|
|
161
|
+
* dated addendum for this decision, and `.ai/state/STATE.md` for tracking).
|
|
162
|
+
* The inference itself stays 100% client-side (pixels never leave the
|
|
163
|
+
* browser) — only the weight DOWNLOAD needs this one extra origin. This is
|
|
164
|
+
* the ONLY documented exception to the `connect-src 'self'` invariant above —
|
|
165
|
+
* do not read it as precedent for adding further hosts without the same
|
|
166
|
+
* scrutiny (exact hostname, no wildcard subdomain, a DDR record).
|
|
109
167
|
*/
|
|
110
168
|
export function cspForCanvasShell(html: string, mainOrigin?: string): string {
|
|
111
169
|
const hashes: string[] = [];
|
|
@@ -122,7 +180,7 @@ export function cspForCanvasShell(html: string, mainOrigin?: string): string {
|
|
|
122
180
|
const directives = [
|
|
123
181
|
"default-src 'none'",
|
|
124
182
|
`script-src ${scriptSrc}`,
|
|
125
|
-
"connect-src 'self'",
|
|
183
|
+
"connect-src 'self' https://staticimgly.com",
|
|
126
184
|
"img-src 'self' data: blob:",
|
|
127
185
|
// DDR-148 — a video-comp's <Video>/<Audio> loads media from the canvas
|
|
128
186
|
// origin's own designRoot (assets/). Same inert-bytes trust as img-src
|
|
@@ -202,6 +260,24 @@ export function sameOriginWrite(req: Request): boolean {
|
|
|
202
260
|
}
|
|
203
261
|
}
|
|
204
262
|
|
|
263
|
+
/**
|
|
264
|
+
* CSRF guard for a key-bearing / side-effecting GET (Task 2.5 audio-search —
|
|
265
|
+
* ethical-hacker F1). A cross-site page can issue a `no-cors` GET to
|
|
266
|
+
* `127.0.0.1:<port>` that passes the `isLoopbackHost` DNS-rebind guard AND may
|
|
267
|
+
* omit `Origin` (browsers don't reliably stamp Origin on a simple cross-origin
|
|
268
|
+
* GET), so `sameOriginWrite` alone can't fend it off. Browsers DO always send
|
|
269
|
+
* the Fetch-Metadata `Sec-Fetch-Site` header, and non-browser clients (the CLI,
|
|
270
|
+
* curl, bun:test) never do — so reject any request whose `Sec-Fetch-Site` is
|
|
271
|
+
* present and is not `same-origin`/`none`. A missing header (CLI) is allowed.
|
|
272
|
+
* Prevents a cross-site page from driving the user's key-bearing provider
|
|
273
|
+
* fan-out + disk scan as a confused deputy.
|
|
274
|
+
*/
|
|
275
|
+
export function sameOriginRead(req: Request): boolean {
|
|
276
|
+
const site = req.headers.get('sec-fetch-site');
|
|
277
|
+
if (!site) return true; // non-browser client (CLI / curl) → allow
|
|
278
|
+
return site === 'same-origin' || site === 'none';
|
|
279
|
+
}
|
|
280
|
+
|
|
205
281
|
function safePathUnderRoot(reqUrl: string, repoRoot: string): string | null {
|
|
206
282
|
let pathname: string;
|
|
207
283
|
try {
|
|
@@ -602,8 +678,19 @@ export function createHttp(
|
|
|
602
678
|
api: Api,
|
|
603
679
|
inspect: Inspect,
|
|
604
680
|
ai: AiActivity,
|
|
605
|
-
exportJobs: ExportJobQueue
|
|
681
|
+
exportJobs: ExportJobQueue,
|
|
682
|
+
generateJobs: GenerationJobQueue
|
|
606
683
|
): Http {
|
|
684
|
+
// Task 2.7 (approach A) — in-flight whisper-model download state (one at a
|
|
685
|
+
// time), polled by the Settings "Download model" card via GET
|
|
686
|
+
// /_api/generate/whisper-model. Closure-scoped: one server, one download.
|
|
687
|
+
let whisperDownload: {
|
|
688
|
+
id: string;
|
|
689
|
+
received: number;
|
|
690
|
+
total: number;
|
|
691
|
+
error?: string;
|
|
692
|
+
} | null = null;
|
|
693
|
+
|
|
607
694
|
// Cache invalidation — when canvas-lib changes, every cached canvas bundle
|
|
608
695
|
// is stale because canvas-lib is inlined into each one via the resolver
|
|
609
696
|
// plugin. Drop the whole cache so the next request rebuilds with the fresh
|
|
@@ -785,8 +872,19 @@ export function createHttp(
|
|
|
785
872
|
// MAIN-ORIGIN ONLY — absent from CANVAS_SAFE_API + startCanvasServer routes,
|
|
786
873
|
// so the untrusted canvas iframe is 403'd. The native shell reads this to
|
|
787
874
|
// decide between the enabled panel and the not-connected explainer.
|
|
788
|
-
|
|
789
|
-
|
|
875
|
+
// Explicit CSRF + DNS-rebind gate added alongside DDR-166: this route's
|
|
876
|
+
// `claudePath` field can now reveal a Maude-auto-installed binary's exact
|
|
877
|
+
// path, and this file's other ACP-adjacent routes all double-gate —
|
|
878
|
+
// security-review finding that this one had none at all, pre-existing
|
|
879
|
+
// but newly worth closing given what this diff makes it able to reveal.
|
|
880
|
+
'/_api/acp/status': async (req: Request) => {
|
|
881
|
+
if (!sameOriginRead(req)) return new Response('cross-origin rejected', { status: 403 });
|
|
882
|
+
if (!isLoopbackHost(req.headers.get('host')))
|
|
883
|
+
return new Response('local request required (DNS-rebinding guard)', { status: 403 });
|
|
884
|
+
return Response.json(await probeAcpAvailabilityAuthed(), {
|
|
885
|
+
headers: { 'Cache-Control': 'no-store' },
|
|
886
|
+
});
|
|
887
|
+
},
|
|
790
888
|
|
|
791
889
|
// DDR-128 — first-open AI-editing readiness. Read-only probe of the AI-editing
|
|
792
890
|
// dependency chain (claude CLI · maude CLI · maude marketplace + plugins in the
|
|
@@ -804,6 +902,100 @@ export function createHttp(
|
|
|
804
902
|
return Response.json(await probeReadiness(), { headers: { 'Cache-Control': 'no-store' } });
|
|
805
903
|
},
|
|
806
904
|
|
|
905
|
+
// DDR-166 plan, Phase 2 (T6) — design-setup readiness (project ✓ / design
|
|
906
|
+
// system ✓ / first canvas ✓ / brand assets ✓), distinct from the AI-editing
|
|
907
|
+
// dependency probe above. Read-only, cheap (a few existsSync/readdir calls
|
|
908
|
+
// scoped to designRoot) — same double gate as every other privileged
|
|
909
|
+
// read here even though it can't shell out, for consistency with the rest
|
|
910
|
+
// of this file's onboarding-surface routes. The quick-setup checklist
|
|
911
|
+
// (SetupChecklist.jsx) polls this.
|
|
912
|
+
'/_api/setup-readiness': async (req: Request) => {
|
|
913
|
+
if (!sameOriginWrite(req)) return new Response('cross-origin rejected', { status: 403 });
|
|
914
|
+
if (!isLoopbackHost(req.headers.get('host')))
|
|
915
|
+
return new Response('local request required (DNS-rebinding guard)', { status: 403 });
|
|
916
|
+
return Response.json(await probeSetupReadiness(ctx), {
|
|
917
|
+
headers: { 'Cache-Control': 'no-store' },
|
|
918
|
+
});
|
|
919
|
+
},
|
|
920
|
+
|
|
921
|
+
// DDR-166 T0c (Addendum 2) — install Claude Code from Maude by running the
|
|
922
|
+
// exact official one-liner once, ephemerally (no persistent Maude-managed
|
|
923
|
+
// cache — that design was rejected on security review; see
|
|
924
|
+
// installClaudeCli()'s own doc comment for the fix). MAIN-ORIGIN ONLY,
|
|
925
|
+
// same CSRF + DNS-rebind double gate as every other privileged route.
|
|
926
|
+
'/_api/claude/install': (req: Request) => {
|
|
927
|
+
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
928
|
+
if (!sameOriginWrite(req))
|
|
929
|
+
return new Response('cross-origin write rejected', { status: 403 });
|
|
930
|
+
if (!isLoopbackHost(req.headers.get('host')))
|
|
931
|
+
return new Response('local request required (DNS-rebinding guard)', { status: 403 });
|
|
932
|
+
// Fire-and-forget — the install can run past Bun's default 10s idle
|
|
933
|
+
// timeout on a slow network, so this returns as soon as the spawn is
|
|
934
|
+
// confirmed started; the panel polls /_api/claude/install-status for
|
|
935
|
+
// the result, the same shape /_api/claude/signin-status already uses.
|
|
936
|
+
const result = startInstall();
|
|
937
|
+
return Response.json(result, {
|
|
938
|
+
status: result.ok ? 200 : 409,
|
|
939
|
+
headers: { 'Cache-Control': 'no-store' },
|
|
940
|
+
});
|
|
941
|
+
},
|
|
942
|
+
// GET route with a real subprocess side effect downstream (signin-status
|
|
943
|
+
// spawns `claude auth status`) — sameOriginWrite alone doesn't gate a GET
|
|
944
|
+
// (browsers don't reliably stamp Origin on a simple cross-origin GET,
|
|
945
|
+
// per this file's own sameOriginRead doc comment, written for exactly
|
|
946
|
+
// this class of route after a prior ethical-hacker finding on
|
|
947
|
+
// audio-search). Security-review finding against the first cut of these
|
|
948
|
+
// two routes, which used the wrong guard.
|
|
949
|
+
'/_api/claude/install-status': (req: Request) => {
|
|
950
|
+
if (!sameOriginRead(req)) return new Response('cross-origin rejected', { status: 403 });
|
|
951
|
+
if (!isLoopbackHost(req.headers.get('host')))
|
|
952
|
+
return new Response('local request required (DNS-rebinding guard)', { status: 403 });
|
|
953
|
+
return Response.json(getInstallState(), { headers: { 'Cache-Control': 'no-store' } });
|
|
954
|
+
},
|
|
955
|
+
|
|
956
|
+
// DDR-166 T0d — sign in to Claude from Maude, driving the user's OWN `claude`
|
|
957
|
+
// CLI through its own `auth login`/`auth status` subcommands (never Maude's
|
|
958
|
+
// own OAuth, never a token Maude holds). MAIN-ORIGIN ONLY — absent from
|
|
959
|
+
// CANVAS_SAFE_API + startCanvasServer routes, same CSRF + DNS-rebind double
|
|
960
|
+
// gate as every other privileged POST route (DDR-088). Zero new Tauri
|
|
961
|
+
// commands (Decision 0) — this lives in the same Bun process as bridge.ts/
|
|
962
|
+
// env.ts, so it reuses the literal scrubAgentEnv/resolveClaudePath the chat
|
|
963
|
+
// spawn already uses.
|
|
964
|
+
'/_api/claude/signin': (req: Request) => {
|
|
965
|
+
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
966
|
+
if (!sameOriginWrite(req))
|
|
967
|
+
return new Response('cross-origin write rejected', { status: 403 });
|
|
968
|
+
if (!isLoopbackHost(req.headers.get('host')))
|
|
969
|
+
return new Response('local request required (DNS-rebinding guard)', { status: 403 });
|
|
970
|
+
const result = startSignin();
|
|
971
|
+
return Response.json(result, {
|
|
972
|
+
status: result.ok ? 200 : 409,
|
|
973
|
+
headers: { 'Cache-Control': 'no-store' },
|
|
974
|
+
});
|
|
975
|
+
},
|
|
976
|
+
'/_api/claude/signin-cancel': (req: Request) => {
|
|
977
|
+
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
978
|
+
if (!sameOriginWrite(req))
|
|
979
|
+
return new Response('cross-origin write rejected', { status: 403 });
|
|
980
|
+
if (!isLoopbackHost(req.headers.get('host')))
|
|
981
|
+
return new Response('local request required (DNS-rebinding guard)', { status: 403 });
|
|
982
|
+
cancelSignin();
|
|
983
|
+
return Response.json({ ok: true }, { headers: { 'Cache-Control': 'no-store' } });
|
|
984
|
+
},
|
|
985
|
+
// Read-only poll target — the panel calls this (or /_api/preflight, which
|
|
986
|
+
// folds the same status in) on an interval while a sign-in is in flight.
|
|
987
|
+
// Narrowed fields only (never email/orgId/orgName — see login-state.ts).
|
|
988
|
+
'/_api/claude/signin-status': async (req: Request) => {
|
|
989
|
+
if (!sameOriginRead(req)) return new Response('cross-origin rejected', { status: 403 });
|
|
990
|
+
if (!isLoopbackHost(req.headers.get('host')))
|
|
991
|
+
return new Response('local request required (DNS-rebinding guard)', { status: 403 });
|
|
992
|
+
const status = await getClaudeAuthStatus();
|
|
993
|
+
return Response.json(
|
|
994
|
+
{ ...status, inFlight: isSigninInFlight() },
|
|
995
|
+
{ headers: { 'Cache-Control': 'no-store' } }
|
|
996
|
+
);
|
|
997
|
+
},
|
|
998
|
+
|
|
807
999
|
// Phase 31 (DDR-123) — `/design:chat` focus hook. `maude design chat-open`
|
|
808
1000
|
// POSTs here; we emit a bus event the shell turns into "open the Assistant
|
|
809
1001
|
// panel" (app.jsx, native-only). MAIN-ORIGIN ONLY (off the canvas allowlist).
|
|
@@ -2133,6 +2325,71 @@ export function createHttp(
|
|
|
2133
2325
|
);
|
|
2134
2326
|
},
|
|
2135
2327
|
|
|
2328
|
+
'/_api/set-artboard-hug': async (req: Request) => {
|
|
2329
|
+
// Artboard "hug height" default — Hug ⇄ Fixed toggle in the CSS panel.
|
|
2330
|
+
// POST { canvas, artboardId, fixed, freezeHeight? } → api.setArtboardHugOp
|
|
2331
|
+
// (writes/removes the bare `fixed` prop on <DCArtboard id="…">; freezeHeight
|
|
2332
|
+
// optionally pins `height` at the board's current measured size so pinning
|
|
2333
|
+
// to Fixed doesn't snap the box). Whole-file undo seq. MAIN-ORIGIN ONLY;
|
|
2334
|
+
// sameOriginWrite + loopback-Host gated, mirrors /_api/resize-artboard.
|
|
2335
|
+
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
2336
|
+
if (!sameOriginWrite(req))
|
|
2337
|
+
return new Response('cross-origin write rejected', { status: 403 });
|
|
2338
|
+
if (!isLoopbackHost(req.headers.get('host')))
|
|
2339
|
+
return new Response('local request required (DNS-rebinding guard)', { status: 403 });
|
|
2340
|
+
const body = await readJson<{
|
|
2341
|
+
canvas?: unknown;
|
|
2342
|
+
artboardId?: unknown;
|
|
2343
|
+
fixed?: unknown;
|
|
2344
|
+
freezeHeight?: unknown;
|
|
2345
|
+
}>(req, 8 * 1024);
|
|
2346
|
+
if (!body) return new Response('body required', { status: 400 });
|
|
2347
|
+
const result = await api.setArtboardHugOp(body);
|
|
2348
|
+
if (!result.ok) {
|
|
2349
|
+
return Response.json(
|
|
2350
|
+
{ ok: false, error: result.error },
|
|
2351
|
+
{ status: result.status, headers: { 'Cache-Control': 'no-store' } }
|
|
2352
|
+
);
|
|
2353
|
+
}
|
|
2354
|
+
return Response.json(
|
|
2355
|
+
{ ok: true, seq: result.seq },
|
|
2356
|
+
{ status: 200, headers: { 'Cache-Control': 'no-store' } }
|
|
2357
|
+
);
|
|
2358
|
+
},
|
|
2359
|
+
|
|
2360
|
+
'/_api/set-artboard-style': async (req: Request) => {
|
|
2361
|
+
// Artboard "more settings" — background / padding / layout / gap, applied
|
|
2362
|
+
// to .dc-artboard-body. POST { canvas, artboardId, background?, padding?,
|
|
2363
|
+
// layout?, gap? } (each key: value to set, `null` to reset, absent to leave
|
|
2364
|
+
// untouched) → api.setArtboardStyleOp. Whole-file undo seq. MAIN-ORIGIN
|
|
2365
|
+
// ONLY; sameOriginWrite + loopback-Host gated, mirrors /_api/resize-artboard.
|
|
2366
|
+
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
2367
|
+
if (!sameOriginWrite(req))
|
|
2368
|
+
return new Response('cross-origin write rejected', { status: 403 });
|
|
2369
|
+
if (!isLoopbackHost(req.headers.get('host')))
|
|
2370
|
+
return new Response('local request required (DNS-rebinding guard)', { status: 403 });
|
|
2371
|
+
const body = await readJson<{
|
|
2372
|
+
canvas?: unknown;
|
|
2373
|
+
artboardId?: unknown;
|
|
2374
|
+
background?: unknown;
|
|
2375
|
+
padding?: unknown;
|
|
2376
|
+
layout?: unknown;
|
|
2377
|
+
gap?: unknown;
|
|
2378
|
+
}>(req, 8 * 1024);
|
|
2379
|
+
if (!body) return new Response('body required', { status: 400 });
|
|
2380
|
+
const result = await api.setArtboardStyleOp(body);
|
|
2381
|
+
if (!result.ok) {
|
|
2382
|
+
return Response.json(
|
|
2383
|
+
{ ok: false, error: result.error },
|
|
2384
|
+
{ status: result.status, headers: { 'Cache-Control': 'no-store' } }
|
|
2385
|
+
);
|
|
2386
|
+
}
|
|
2387
|
+
return Response.json(
|
|
2388
|
+
{ ok: true, seq: result.seq },
|
|
2389
|
+
{ status: 200, headers: { 'Cache-Control': 'no-store' } }
|
|
2390
|
+
);
|
|
2391
|
+
},
|
|
2392
|
+
|
|
2136
2393
|
'/_api/delete-artboard': async (req: Request) => {
|
|
2137
2394
|
// Delete an artboard by its `id` prop (Backspace / context-menu on a frame).
|
|
2138
2395
|
// POST { canvas, artboardId } → api.deleteArtboardOp (removes the
|
|
@@ -2194,6 +2451,140 @@ export function createHttp(
|
|
|
2194
2451
|
);
|
|
2195
2452
|
},
|
|
2196
2453
|
|
|
2454
|
+
// DDR-167 (Phase 3 / T10) — hardened LOCAL-file SVG ingestion for the
|
|
2455
|
+
// in-app Brand-upload panel (T12). Privileged + main-origin-only (Decision
|
|
2456
|
+
// 4): absent from CANVAS_SAFE_API + startCanvasServer's routes map — the
|
|
2457
|
+
// untrusted canvas iframe must never reach it. Raw-bytes body only, never
|
|
2458
|
+
// multipart (this dev server has no multipart parsing anywhere). The
|
|
2459
|
+
// `X-Import-Kind` header is a dispatch HINT ONLY — never trusted for the
|
|
2460
|
+
// security-relevant decision; the sanitize pipeline's own pre-parse
|
|
2461
|
+
// structural sniff decides. PDF import is wired but not yet available —
|
|
2462
|
+
// see the DDR's addendum on why the planned rasterization mechanism
|
|
2463
|
+
// (headless-Chromium navigating a file:// PDF) doesn't render content
|
|
2464
|
+
// under browser automation; a `kind: pdf` request 501s naming that.
|
|
2465
|
+
'/_api/import-asset': async (req: Request) => {
|
|
2466
|
+
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
2467
|
+
if (!sameOriginWrite(req))
|
|
2468
|
+
return new Response('cross-origin write rejected', { status: 403 });
|
|
2469
|
+
if (!isLoopbackHost(req.headers.get('host')))
|
|
2470
|
+
return new Response('local request required (DNS-rebinding guard)', { status: 403 });
|
|
2471
|
+
const kind = (req.headers.get('x-import-kind') || 'svg').toLowerCase();
|
|
2472
|
+
if (kind !== 'svg') {
|
|
2473
|
+
return Response.json(
|
|
2474
|
+
{
|
|
2475
|
+
ok: false,
|
|
2476
|
+
error: 'PDF import is not yet available (DDR-167 addendum) — only SVG import is wired.',
|
|
2477
|
+
},
|
|
2478
|
+
{ status: 501, headers: { 'Cache-Control': 'no-store' } }
|
|
2479
|
+
);
|
|
2480
|
+
}
|
|
2481
|
+
const declared = Number(req.headers.get('content-length') || '0');
|
|
2482
|
+
if (Number.isFinite(declared) && declared > SVG_MAX_BYTES) {
|
|
2483
|
+
return Response.json(
|
|
2484
|
+
{ ok: false, error: `SVG exceeds the ${SVG_MAX_BYTES / (1024 * 1024)} MB cap` },
|
|
2485
|
+
{ status: 413, headers: { 'Cache-Control': 'no-store' } }
|
|
2486
|
+
);
|
|
2487
|
+
}
|
|
2488
|
+
if (!req.body) return new Response('empty body', { status: 400 });
|
|
2489
|
+
let svgText: string;
|
|
2490
|
+
try {
|
|
2491
|
+
svgText = await new Response(req.body).text();
|
|
2492
|
+
} catch {
|
|
2493
|
+
return new Response('could not read body', { status: 400 });
|
|
2494
|
+
}
|
|
2495
|
+
try {
|
|
2496
|
+
const result = await importSvg(svgText, {
|
|
2497
|
+
root: ctx.paths.repoRoot,
|
|
2498
|
+
designRootRel: ctx.paths.designRel,
|
|
2499
|
+
});
|
|
2500
|
+
return Response.json(
|
|
2501
|
+
{ ok: true, path: result.path },
|
|
2502
|
+
{ status: 201, headers: { 'Cache-Control': 'no-store' } }
|
|
2503
|
+
);
|
|
2504
|
+
} catch (err) {
|
|
2505
|
+
const status =
|
|
2506
|
+
err instanceof ImportAssetError
|
|
2507
|
+
? err.code === 3
|
|
2508
|
+
? 400
|
|
2509
|
+
: err.code === 5
|
|
2510
|
+
? 415
|
|
2511
|
+
: err.code === 6
|
|
2512
|
+
? 500
|
|
2513
|
+
: 400
|
|
2514
|
+
: 500;
|
|
2515
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2516
|
+
return Response.json(
|
|
2517
|
+
{ ok: false, error: message },
|
|
2518
|
+
{ status, headers: { 'Cache-Control': 'no-store' } }
|
|
2519
|
+
);
|
|
2520
|
+
}
|
|
2521
|
+
},
|
|
2522
|
+
|
|
2523
|
+
// DDR-173 (Phase 3 / T12) — brand-file typed-cue extraction for the
|
|
2524
|
+
// in-app Brand-upload panel. Privileged + main-origin-only, same posture
|
|
2525
|
+
// as /_api/import-asset: absent from CANVAS_SAFE_API + startCanvasServer's
|
|
2526
|
+
// routes map. Takes a SERVER-GENERATED asset path from a prior
|
|
2527
|
+
// /_api/import-asset response — never a client-supplied filesystem path
|
|
2528
|
+
// (DDR-173 Decision 2: extraction operates only on DDR-167's already-
|
|
2529
|
+
// gated, already-sanitized output, no parallel ungated read). The path is
|
|
2530
|
+
// still realpath-contained + charset-asserted here, never trusted blindly
|
|
2531
|
+
// just because it LOOKS server-shaped.
|
|
2532
|
+
'/_api/import-brand': async (req: Request) => {
|
|
2533
|
+
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
2534
|
+
if (!sameOriginWrite(req))
|
|
2535
|
+
return new Response('cross-origin write rejected', { status: 403 });
|
|
2536
|
+
if (!isLoopbackHost(req.headers.get('host')))
|
|
2537
|
+
return new Response('local request required (DNS-rebinding guard)', { status: 403 });
|
|
2538
|
+
let body: { assetPath?: unknown };
|
|
2539
|
+
try {
|
|
2540
|
+
body = (await req.json()) as { assetPath?: unknown };
|
|
2541
|
+
} catch {
|
|
2542
|
+
return new Response('invalid JSON body', { status: 400 });
|
|
2543
|
+
}
|
|
2544
|
+
const assetPath = body.assetPath;
|
|
2545
|
+
if (typeof assetPath !== 'string' || !/^assets\/[a-z0-9]{8}\.svg$/.test(assetPath)) {
|
|
2546
|
+
return Response.json(
|
|
2547
|
+
{ ok: false, error: 'assetPath must be a server-generated assets/<sha8>.svg path' },
|
|
2548
|
+
{ status: 400, headers: { 'Cache-Control': 'no-store' } }
|
|
2549
|
+
);
|
|
2550
|
+
}
|
|
2551
|
+
const designAbs = resolve(ctx.paths.repoRoot, ctx.paths.designRel);
|
|
2552
|
+
const resolvedAsset = resolve(designAbs, assetPath);
|
|
2553
|
+
if (!resolvedAsset.startsWith(resolve(designAbs, 'assets') + sep)) {
|
|
2554
|
+
return Response.json(
|
|
2555
|
+
{ ok: false, error: 'assetPath escapes the assets directory' },
|
|
2556
|
+
{ status: 400, headers: { 'Cache-Control': 'no-store' } }
|
|
2557
|
+
);
|
|
2558
|
+
}
|
|
2559
|
+
try {
|
|
2560
|
+
const result = await importBrand({
|
|
2561
|
+
sanitizedSvgPath: resolvedAsset,
|
|
2562
|
+
root: ctx.paths.repoRoot,
|
|
2563
|
+
designRootRel: ctx.paths.designRel,
|
|
2564
|
+
});
|
|
2565
|
+
return Response.json(
|
|
2566
|
+
{ ok: true, ...result },
|
|
2567
|
+
{ status: 201, headers: { 'Cache-Control': 'no-store' } }
|
|
2568
|
+
);
|
|
2569
|
+
} catch (err) {
|
|
2570
|
+
const status =
|
|
2571
|
+
err instanceof ImportBrandError
|
|
2572
|
+
? err.code === 3
|
|
2573
|
+
? 400
|
|
2574
|
+
: err.code === 4
|
|
2575
|
+
? 404
|
|
2576
|
+
: err.code === 6
|
|
2577
|
+
? 500
|
|
2578
|
+
: 400
|
|
2579
|
+
: 500;
|
|
2580
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2581
|
+
return Response.json(
|
|
2582
|
+
{ ok: false, error: message },
|
|
2583
|
+
{ status, headers: { 'Cache-Control': 'no-store' } }
|
|
2584
|
+
);
|
|
2585
|
+
}
|
|
2586
|
+
},
|
|
2587
|
+
|
|
2197
2588
|
'/_api/export-history': async (req: Request) => {
|
|
2198
2589
|
// Phase 6.5 T10 — read-only recent-exports feed for the dialog's
|
|
2199
2590
|
// Recent tab. Writes happen as a side-effect of job completion
|
|
@@ -2328,6 +2719,433 @@ export function createHttp(
|
|
|
2328
2719
|
});
|
|
2329
2720
|
},
|
|
2330
2721
|
|
|
2722
|
+
// feature-ai-media-generation (DDR-16x) — the background AI-media generation
|
|
2723
|
+
// job queue. The privileged sibling of /_api/export-jobs: MAIN-ORIGIN ONLY
|
|
2724
|
+
// (absent from CANVAS_SAFE_API + startCanvasServer's routes — the untrusted
|
|
2725
|
+
// canvas iframe must never reach a route that resolves a provider KEY and
|
|
2726
|
+
// makes an outbound provider call; it sees only the produced /assets/<sha8>).
|
|
2727
|
+
// loopback-Host gated on every method; the mutating POST is additionally
|
|
2728
|
+
// sameOriginWrite CSRF-gated. See DDR-16x + the canvas-origin-gate test.
|
|
2729
|
+
'/_api/generate-jobs': async (req: Request) => {
|
|
2730
|
+
if (!isLoopbackHost(req.headers.get('host')))
|
|
2731
|
+
return new Response('local request required (DNS-rebinding guard)', { status: 403 });
|
|
2732
|
+
if (req.method === 'GET') {
|
|
2733
|
+
return Response.json(
|
|
2734
|
+
{ jobs: generateJobs.list() },
|
|
2735
|
+
{ headers: { 'Cache-Control': 'no-store' } }
|
|
2736
|
+
);
|
|
2737
|
+
}
|
|
2738
|
+
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
2739
|
+
if (!sameOriginWrite(req))
|
|
2740
|
+
return new Response('cross-origin write rejected', { status: 403 });
|
|
2741
|
+
const body = await readJson<Record<string, unknown>>(req, 64 * 1024);
|
|
2742
|
+
if (!body) return new Response('body required', { status: 400 });
|
|
2743
|
+
const check = validateGenRequest(body);
|
|
2744
|
+
if (!check.ok) return new Response(check.errors.join('; '), { status: 400 });
|
|
2745
|
+
const genReq = body as unknown as import('./generation/types.ts').GenRequest;
|
|
2746
|
+
if (!hasProvider(genReq.provider))
|
|
2747
|
+
return new Response(`unknown provider: ${genReq.provider}`, { status: 400 });
|
|
2748
|
+
const descriptor = getProviderDescriptor(genReq.provider);
|
|
2749
|
+
if (descriptor && !descriptor.modalities.includes(genReq.modality))
|
|
2750
|
+
return new Response(`provider ${genReq.provider} cannot do ${genReq.modality}`, {
|
|
2751
|
+
status: 400,
|
|
2752
|
+
});
|
|
2753
|
+
|
|
2754
|
+
try {
|
|
2755
|
+
const { id } = generateJobs.enqueue({
|
|
2756
|
+
provider: genReq.provider,
|
|
2757
|
+
modality: genReq.modality,
|
|
2758
|
+
model: genReq.model,
|
|
2759
|
+
// The job's work: resolve the key AT RUN TIME (never cached), build the
|
|
2760
|
+
// per-request adapter context with the saveAsset-backed localizer, and
|
|
2761
|
+
// submit → result → localize each produced asset into assets/<sha8>.
|
|
2762
|
+
run: async (signal) => {
|
|
2763
|
+
const apiKey = await getProviderKey(genReq.provider);
|
|
2764
|
+
const adapter = createAdapter(genReq.provider, {
|
|
2765
|
+
apiKey,
|
|
2766
|
+
signal,
|
|
2767
|
+
localize: (asset) => localizeGenAsset(asset, { saveAsset: api.saveAsset }),
|
|
2768
|
+
// Task 1.2 — source-asset access for the maskless-edit / i2v flows
|
|
2769
|
+
// (Nano Banana reads the source into an inlineData part). Contained
|
|
2770
|
+
// to assets/ host-side; the adapter never touches the filesystem.
|
|
2771
|
+
readSourceAsset: (rel) => api.readAssetBytes(rel),
|
|
2772
|
+
});
|
|
2773
|
+
const job = await adapter.submit(genReq);
|
|
2774
|
+
const result = await job.result();
|
|
2775
|
+
const assets: string[] = [];
|
|
2776
|
+
for (const asset of result.assets) {
|
|
2777
|
+
// Task 2.6 — a CLOUD STT result (ElevenLabs Scribe / Groq) is
|
|
2778
|
+
// caption TEXT, not media: it lands as a `assets/<sha8>.srt|.vtt`
|
|
2779
|
+
// sidecar next to its source (the SAME path the local whisper verb
|
|
2780
|
+
// writes), never through the magic-byte media store. Everything
|
|
2781
|
+
// else (image/video/audio) localizes into the content-addressed
|
|
2782
|
+
// media store as before.
|
|
2783
|
+
if (asset.kind === 'transcription' && typeof asset.text === 'string') {
|
|
2784
|
+
if (!genReq.sourceAsset)
|
|
2785
|
+
throw new Error('transcription result has no source asset to sidecar');
|
|
2786
|
+
const fmt = asset.mime === 'text/vtt' ? 'vtt' : 'srt';
|
|
2787
|
+
const saved = await api.writeCaptionSidecar(genReq.sourceAsset, fmt, asset.text);
|
|
2788
|
+
if (!saved.ok || !saved.path)
|
|
2789
|
+
throw new Error(`caption sidecar write failed: ${saved.error ?? 'unknown'}`);
|
|
2790
|
+
assets.push(saved.path);
|
|
2791
|
+
} else {
|
|
2792
|
+
assets.push(await localizeGenAsset(asset, { saveAsset: api.saveAsset }));
|
|
2793
|
+
}
|
|
2794
|
+
}
|
|
2795
|
+
// Task 2.5 — record the AUDIO INTENT next to a generated audio asset
|
|
2796
|
+
// (the durable, semantic reuse index) so a later near-identical
|
|
2797
|
+
// request can offer the existing track instead of paying again.
|
|
2798
|
+
if (genReq.modality === 'audio' && assets.length > 0) {
|
|
2799
|
+
const kind = (genReq.params as Record<string, unknown> | undefined)?.audioKind;
|
|
2800
|
+
await api.writeAudioIntent(assets[0], {
|
|
2801
|
+
kind: typeof kind === 'string' ? kind : undefined,
|
|
2802
|
+
prompt: genReq.prompt,
|
|
2803
|
+
provider: genReq.provider,
|
|
2804
|
+
model: genReq.model,
|
|
2805
|
+
});
|
|
2806
|
+
}
|
|
2807
|
+
// Task 3.2 — a generated VIDEO clip gets a provenance FootageAnalysis
|
|
2808
|
+
// stub next to it (assets/<sha8>.footage.json), so it is immediately
|
|
2809
|
+
// KNOWN to the footage/reel pipeline: the director sees the clip is
|
|
2810
|
+
// synthetic (the `ai-generated` tag) and what it was made for (the
|
|
2811
|
+
// prompt), and the footage-analyst later fills the real shots. A
|
|
2812
|
+
// sidecar hiccup must NOT lose the (expensive) clip — best-effort.
|
|
2813
|
+
if (genReq.modality === 'video' && assets.length > 0) {
|
|
2814
|
+
try {
|
|
2815
|
+
await footageStore.saveAnalysis(
|
|
2816
|
+
assets[0],
|
|
2817
|
+
generatedClipAnalysis(assets[0], {
|
|
2818
|
+
provider: genReq.provider,
|
|
2819
|
+
model: genReq.model,
|
|
2820
|
+
prompt: genReq.prompt,
|
|
2821
|
+
})
|
|
2822
|
+
);
|
|
2823
|
+
} catch {
|
|
2824
|
+
/* provenance-only — the clip already landed in assets/ */
|
|
2825
|
+
}
|
|
2826
|
+
}
|
|
2827
|
+
return { assets, usage: result.usage };
|
|
2828
|
+
},
|
|
2829
|
+
});
|
|
2830
|
+
// Don't await the run — the job's own status (GET + the generate:job WS
|
|
2831
|
+
// push) is the completion signal. A failure is recorded on the job
|
|
2832
|
+
// record, so swallow the rejection here to avoid an unhandled rejection.
|
|
2833
|
+
return Response.json(
|
|
2834
|
+
{ jobId: id },
|
|
2835
|
+
{ status: 202, headers: { 'Cache-Control': 'no-store' } }
|
|
2836
|
+
);
|
|
2837
|
+
} catch (err) {
|
|
2838
|
+
if (err instanceof GenerationQueueFullError)
|
|
2839
|
+
return new Response(err.message, { status: 429 });
|
|
2840
|
+
throw err;
|
|
2841
|
+
}
|
|
2842
|
+
},
|
|
2843
|
+
|
|
2844
|
+
// feature-ai-media-generation (DDR-16x) — inert provider catalogue for the
|
|
2845
|
+
// Settings panel + generate dialog: descriptors (id/label/modalities/notes/
|
|
2846
|
+
// keyUrl) + presence-only `configured` flags. NO secret ever crosses here.
|
|
2847
|
+
// MAIN-ORIGIN ONLY, loopback-gated.
|
|
2848
|
+
'/_api/generate/providers': async (req: Request) => {
|
|
2849
|
+
if (!isLoopbackHost(req.headers.get('host')))
|
|
2850
|
+
return new Response('local request required (DNS-rebinding guard)', { status: 403 });
|
|
2851
|
+
if (req.method !== 'GET') return new Response('Method not allowed', { status: 405 });
|
|
2852
|
+
const configured = new Set(configuredProviders());
|
|
2853
|
+
return Response.json(
|
|
2854
|
+
{
|
|
2855
|
+
providers: listProviders().map((d) => ({ ...d, configured: configured.has(d.id) })),
|
|
2856
|
+
},
|
|
2857
|
+
{ headers: { 'Cache-Control': 'no-store' } }
|
|
2858
|
+
);
|
|
2859
|
+
},
|
|
2860
|
+
|
|
2861
|
+
// feature-ai-media-generation (DDR-16x) — key management. WRITE-ONLY from the
|
|
2862
|
+
// main origin: POST sets a key, DELETE removes one, GET reports presence only
|
|
2863
|
+
// ({configured:[...]}) — a key value is NEVER echoed back (mirrors
|
|
2864
|
+
// github_is_signed_in). MAIN-ORIGIN ONLY, loopback + sameOriginWrite gated.
|
|
2865
|
+
'/_api/generate/keys': async (req: Request) => {
|
|
2866
|
+
if (!isLoopbackHost(req.headers.get('host')))
|
|
2867
|
+
return new Response('local request required (DNS-rebinding guard)', { status: 403 });
|
|
2868
|
+
if (req.method === 'GET') {
|
|
2869
|
+
return Response.json(
|
|
2870
|
+
{ configured: configuredProviders() },
|
|
2871
|
+
{ headers: { 'Cache-Control': 'no-store' } }
|
|
2872
|
+
);
|
|
2873
|
+
}
|
|
2874
|
+
if (!sameOriginWrite(req))
|
|
2875
|
+
return new Response('cross-origin write rejected', { status: 403 });
|
|
2876
|
+
const body = await readJson<{ provider?: unknown; key?: unknown }>(req, 16 * 1024);
|
|
2877
|
+
if (!body || typeof body.provider !== 'string')
|
|
2878
|
+
return new Response('provider required', { status: 400 });
|
|
2879
|
+
if (!hasProvider(body.provider))
|
|
2880
|
+
return new Response(`unknown provider: ${body.provider}`, { status: 400 });
|
|
2881
|
+
try {
|
|
2882
|
+
if (req.method === 'DELETE') {
|
|
2883
|
+
deleteProviderKey(body.provider);
|
|
2884
|
+
return Response.json({ configured: false }, { headers: { 'Cache-Control': 'no-store' } });
|
|
2885
|
+
}
|
|
2886
|
+
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
2887
|
+
if (typeof body.key !== 'string' || !body.key.trim())
|
|
2888
|
+
return new Response('key required', { status: 400 });
|
|
2889
|
+
setProviderKey(body.provider, body.key);
|
|
2890
|
+
// Presence flag only — never the key. Deliberately does not read the
|
|
2891
|
+
// value back so a proxy/log between here and the client can't capture it.
|
|
2892
|
+
return Response.json(
|
|
2893
|
+
{ configured: isConfigured(body.provider) },
|
|
2894
|
+
{ headers: { 'Cache-Control': 'no-store' } }
|
|
2895
|
+
);
|
|
2896
|
+
} catch (err) {
|
|
2897
|
+
return new Response(err instanceof Error ? err.message : 'key write failed', {
|
|
2898
|
+
status: 400,
|
|
2899
|
+
});
|
|
2900
|
+
}
|
|
2901
|
+
},
|
|
2902
|
+
|
|
2903
|
+
// feature-ai-media-generation (Task 2.6, DDR-164) — NON-SECRET generation
|
|
2904
|
+
// preferences (the transcription-engine choice). GET returns the current
|
|
2905
|
+
// choice; POST persists it into `.design/config.json` and hot-reloads the
|
|
2906
|
+
// config so the next transcribe picks it up without a restart. MAIN-ORIGIN
|
|
2907
|
+
// ONLY, loopback + sameOriginWrite gated. NEVER touches a key — that's the
|
|
2908
|
+
// separate /_api/generate/keys route.
|
|
2909
|
+
'/_api/generate/prefs': async (req: Request) => {
|
|
2910
|
+
if (!isLoopbackHost(req.headers.get('host')))
|
|
2911
|
+
return new Response('local request required (DNS-rebinding guard)', { status: 403 });
|
|
2912
|
+
if (req.method === 'GET') {
|
|
2913
|
+
return Response.json(
|
|
2914
|
+
{ transcriptionProvider: readTranscriptionProvider(ctx.paths.repoRoot) },
|
|
2915
|
+
{ headers: { 'Cache-Control': 'no-store' } }
|
|
2916
|
+
);
|
|
2917
|
+
}
|
|
2918
|
+
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
2919
|
+
if (!sameOriginWrite(req))
|
|
2920
|
+
return new Response('cross-origin write rejected', { status: 403 });
|
|
2921
|
+
const body = await readJson<{ transcriptionProvider?: unknown }>(req, 4 * 1024);
|
|
2922
|
+
const provider = body?.transcriptionProvider;
|
|
2923
|
+
if (!isTranscriptionProvider(provider))
|
|
2924
|
+
return new Response('transcriptionProvider must be whisper|elevenlabs|groq', {
|
|
2925
|
+
status: 400,
|
|
2926
|
+
});
|
|
2927
|
+
try {
|
|
2928
|
+
await writeTranscriptionProvider(ctx.paths.repoRoot, provider);
|
|
2929
|
+
reloadConfig(ctx);
|
|
2930
|
+
return Response.json(
|
|
2931
|
+
{ transcriptionProvider: provider },
|
|
2932
|
+
{ headers: { 'Cache-Control': 'no-store' } }
|
|
2933
|
+
);
|
|
2934
|
+
} catch (err) {
|
|
2935
|
+
return new Response(err instanceof Error ? err.message : 'prefs write failed', {
|
|
2936
|
+
status: 400,
|
|
2937
|
+
});
|
|
2938
|
+
}
|
|
2939
|
+
},
|
|
2940
|
+
|
|
2941
|
+
// feature-unified-settings-modal — NON-SECRET UI / view preferences (theme +
|
|
2942
|
+
// the Canvas & View toggles), persisted to `~/.config/maude/prefs.json` so
|
|
2943
|
+
// they survive a restart and a cleared localStorage. GET returns the merged
|
|
2944
|
+
// prefs; POST merge-patches only the provided keys. MAIN-ORIGIN ONLY, loopback
|
|
2945
|
+
// + sameOriginWrite gated (never reachable from the untrusted canvas origin).
|
|
2946
|
+
'/_api/ui-prefs': async (req: Request) => {
|
|
2947
|
+
if (!isLoopbackHost(req.headers.get('host')))
|
|
2948
|
+
return new Response('local request required (DNS-rebinding guard)', { status: 403 });
|
|
2949
|
+
if (req.method === 'GET') {
|
|
2950
|
+
return Response.json(readUiPrefs(), { headers: { 'Cache-Control': 'no-store' } });
|
|
2951
|
+
}
|
|
2952
|
+
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
2953
|
+
if (!sameOriginWrite(req))
|
|
2954
|
+
return new Response('cross-origin write rejected', { status: 403 });
|
|
2955
|
+
const body = await readJson<Record<string, unknown>>(req, 4 * 1024);
|
|
2956
|
+
if (!body || typeof body !== 'object')
|
|
2957
|
+
return new Response('body must be a JSON object', { status: 400 });
|
|
2958
|
+
// Build a clean patch — only well-typed known keys pass through, so a bad
|
|
2959
|
+
// field is rejected rather than silently resetting a stored value.
|
|
2960
|
+
const patch: Partial<UiPrefs> = {};
|
|
2961
|
+
if ('theme' in body) {
|
|
2962
|
+
if (body.theme !== 'light' && body.theme !== 'dark')
|
|
2963
|
+
return new Response('theme must be light|dark', { status: 400 });
|
|
2964
|
+
patch.theme = body.theme;
|
|
2965
|
+
}
|
|
2966
|
+
for (const k of ['minimap', 'zoom', 'annotations', 'autoOpenInspector'] as const) {
|
|
2967
|
+
if (k in body) {
|
|
2968
|
+
if (typeof body[k] !== 'boolean')
|
|
2969
|
+
return new Response(`${k} must be a boolean`, { status: 400 });
|
|
2970
|
+
patch[k] = body[k] as boolean;
|
|
2971
|
+
}
|
|
2972
|
+
}
|
|
2973
|
+
if ('layersMode' in body) {
|
|
2974
|
+
if (body.layersMode !== 'separate' && body.layersMode !== 'in-inspector')
|
|
2975
|
+
return new Response('layersMode must be separate|in-inspector', { status: 400 });
|
|
2976
|
+
patch.layersMode = body.layersMode;
|
|
2977
|
+
}
|
|
2978
|
+
if ('panelSides' in body) {
|
|
2979
|
+
const ps = body.panelSides;
|
|
2980
|
+
if (!ps || typeof ps !== 'object' || Array.isArray(ps))
|
|
2981
|
+
return new Response('panelSides must be an object', { status: 400 });
|
|
2982
|
+
for (const v of Object.values(ps as Record<string, unknown>)) {
|
|
2983
|
+
if (v !== 'left' && v !== 'right')
|
|
2984
|
+
return new Response('panelSides values must be left|right', { status: 400 });
|
|
2985
|
+
}
|
|
2986
|
+
// writeUiPrefs → coerce keeps only known ids, so unknown keys are dropped.
|
|
2987
|
+
patch.panelSides = ps as UiPrefs['panelSides'];
|
|
2988
|
+
}
|
|
2989
|
+
try {
|
|
2990
|
+
return Response.json(writeUiPrefs(patch), { headers: { 'Cache-Control': 'no-store' } });
|
|
2991
|
+
} catch (err) {
|
|
2992
|
+
return new Response(err instanceof Error ? err.message : 'ui-prefs write failed', {
|
|
2993
|
+
status: 500,
|
|
2994
|
+
});
|
|
2995
|
+
}
|
|
2996
|
+
},
|
|
2997
|
+
|
|
2998
|
+
// feature-ai-media-generation (Task 2.5, DDR-164) — reuse-before-you-pay for
|
|
2999
|
+
// AUDIO. GET searches the project's OWN generated audio (intent sidecars) and,
|
|
3000
|
+
// when ElevenLabs is configured, the user's re-downloadable History (free —
|
|
3001
|
+
// already paid), returning ranked reuse candidates. MAIN-ORIGIN ONLY,
|
|
3002
|
+
// loopback-gated (it resolves the provider key server-side).
|
|
3003
|
+
'/_api/generate/audio-search': async (req: Request) => {
|
|
3004
|
+
if (!isLoopbackHost(req.headers.get('host')))
|
|
3005
|
+
return new Response('local request required (DNS-rebinding guard)', { status: 403 });
|
|
3006
|
+
if (req.method !== 'GET') return new Response('Method not allowed', { status: 405 });
|
|
3007
|
+
// F1 (ethical-hacker) — this GET resolves the user's key + fans out to the
|
|
3008
|
+
// provider, so it must NOT be cross-site-triggerable (a no-cors GET can slip
|
|
3009
|
+
// past the loopback guard). Fetch-Metadata gate: browser cross-site → 403;
|
|
3010
|
+
// the CLI (no Sec-Fetch-Site) → allowed.
|
|
3011
|
+
if (!sameOriginRead(req)) return new Response('cross-site read rejected', { status: 403 });
|
|
3012
|
+
const q = new URL(req.url).searchParams.get('q') ?? '';
|
|
3013
|
+
if (!q.trim()) return new Response('q query param required', { status: 400 });
|
|
3014
|
+
const local: AudioMatch[] = await api.searchAudioLibrary(q, 10);
|
|
3015
|
+
let history: AudioMatch[] = [];
|
|
3016
|
+
// Only reach the provider when its key is present (no key → local-only).
|
|
3017
|
+
if (isConfigured('elevenlabs')) {
|
|
3018
|
+
try {
|
|
3019
|
+
const apiKey = await getProviderKey('elevenlabs');
|
|
3020
|
+
const adapter = createAdapter('elevenlabs', {
|
|
3021
|
+
apiKey,
|
|
3022
|
+
localize: (asset) => localizeGenAsset(asset, { saveAsset: api.saveAsset }),
|
|
3023
|
+
});
|
|
3024
|
+
if (adapter.listHistory) {
|
|
3025
|
+
const items = await adapter.listHistory();
|
|
3026
|
+
const candidates: Candidate[] = items.map((it) => ({
|
|
3027
|
+
source: 'history' as const,
|
|
3028
|
+
ref: it.id,
|
|
3029
|
+
// History `text` is the user's own generation source, but a TTS of
|
|
3030
|
+
// attacker-supplied text could carry injection — sanitize before it
|
|
3031
|
+
// reaches the agent-facing output (F3).
|
|
3032
|
+
text: sanitizeReuseText(it.text),
|
|
3033
|
+
provider: 'elevenlabs',
|
|
3034
|
+
at: it.at,
|
|
3035
|
+
}));
|
|
3036
|
+
history = rankMatches(q, candidates, { limit: 10 });
|
|
3037
|
+
}
|
|
3038
|
+
} catch {
|
|
3039
|
+
// History is best-effort (rate limits / transient) — degrade to local.
|
|
3040
|
+
history = [];
|
|
3041
|
+
}
|
|
3042
|
+
}
|
|
3043
|
+
return Response.json({ local, history }, { headers: { 'Cache-Control': 'no-store' } });
|
|
3044
|
+
},
|
|
3045
|
+
|
|
3046
|
+
// feature-ai-media-generation (Task 2.5) — reuse a History item: re-download
|
|
3047
|
+
// its bytes (NO credit — already paid) through the host's magic-byte-sniffed
|
|
3048
|
+
// saveAsset, localize into assets/<sha8>, and record the audio intent so the
|
|
3049
|
+
// reused track is itself searchable. MAIN-ORIGIN ONLY, loopback +
|
|
3050
|
+
// sameOriginWrite gated.
|
|
3051
|
+
'/_api/generate/audio-reuse': async (req: Request) => {
|
|
3052
|
+
if (!isLoopbackHost(req.headers.get('host')))
|
|
3053
|
+
return new Response('local request required (DNS-rebinding guard)', { status: 403 });
|
|
3054
|
+
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
3055
|
+
if (!sameOriginWrite(req))
|
|
3056
|
+
return new Response('cross-origin write rejected', { status: 403 });
|
|
3057
|
+
const body = await readJson<{ id?: unknown }>(req, 8 * 1024);
|
|
3058
|
+
const id = body?.id;
|
|
3059
|
+
if (typeof id !== 'string' || !id.trim())
|
|
3060
|
+
return new Response('history item id required', { status: 400 });
|
|
3061
|
+
if (!isConfigured('elevenlabs'))
|
|
3062
|
+
return new Response('no ElevenLabs key configured', { status: 400 });
|
|
3063
|
+
try {
|
|
3064
|
+
const apiKey = await getProviderKey('elevenlabs');
|
|
3065
|
+
const adapter = createAdapter('elevenlabs', {
|
|
3066
|
+
apiKey,
|
|
3067
|
+
localize: (asset) => localizeGenAsset(asset, { saveAsset: api.saveAsset }),
|
|
3068
|
+
});
|
|
3069
|
+
if (!adapter.fetchHistoryAudio)
|
|
3070
|
+
return new Response('provider has no history re-download', { status: 400 });
|
|
3071
|
+
const asset = await adapter.fetchHistoryAudio(id);
|
|
3072
|
+
const rel = await localizeGenAsset(asset, { saveAsset: api.saveAsset });
|
|
3073
|
+
await api.writeAudioIntent(rel, {
|
|
3074
|
+
kind: 'tts',
|
|
3075
|
+
prompt: `reused from ElevenLabs history ${id}`,
|
|
3076
|
+
provider: 'elevenlabs',
|
|
3077
|
+
});
|
|
3078
|
+
return Response.json({ asset: rel }, { headers: { 'Cache-Control': 'no-store' } });
|
|
3079
|
+
} catch (err) {
|
|
3080
|
+
return new Response(err instanceof Error ? err.message : 'reuse failed', { status: 400 });
|
|
3081
|
+
}
|
|
3082
|
+
},
|
|
3083
|
+
|
|
3084
|
+
// feature-ai-media-generation (Task 2.7 approach A, DDR-164) — managed local
|
|
3085
|
+
// whisper.cpp GGML models: GET lists the registry + which are downloaded +
|
|
3086
|
+
// any in-flight download; POST {id} downloads one (SSRF-hardened, from the
|
|
3087
|
+
// frozen ggerganov/whisper.cpp allowlist); DELETE {id} reclaims disk. This is
|
|
3088
|
+
// the "one-click local subtitles" model half — after a download,
|
|
3089
|
+
// `maude design transcribe --provider whisper` auto-resolves it. MAIN-ORIGIN
|
|
3090
|
+
// ONLY, loopback + sameOriginWrite gated.
|
|
3091
|
+
'/_api/generate/whisper-model': async (req: Request) => {
|
|
3092
|
+
if (!isLoopbackHost(req.headers.get('host')))
|
|
3093
|
+
return new Response('local request required (DNS-rebinding guard)', { status: 403 });
|
|
3094
|
+
if (req.method === 'GET') {
|
|
3095
|
+
return Response.json(
|
|
3096
|
+
{ models: listWhisperModels(), downloading: whisperDownload },
|
|
3097
|
+
{ headers: { 'Cache-Control': 'no-store' } }
|
|
3098
|
+
);
|
|
3099
|
+
}
|
|
3100
|
+
if (!sameOriginWrite(req))
|
|
3101
|
+
return new Response('cross-origin write rejected', { status: 403 });
|
|
3102
|
+
const body = await readJson<{ id?: unknown }>(req, 4 * 1024);
|
|
3103
|
+
const id = typeof body?.id === 'string' ? body.id : '';
|
|
3104
|
+
if (!getWhisperModel(id)) return new Response('unknown model id', { status: 400 });
|
|
3105
|
+
|
|
3106
|
+
if (req.method === 'DELETE') {
|
|
3107
|
+
const removed = await removeWhisperModel(id);
|
|
3108
|
+
return Response.json({ removed }, { headers: { 'Cache-Control': 'no-store' } });
|
|
3109
|
+
}
|
|
3110
|
+
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
3111
|
+
if (whisperDownload && !whisperDownload.error)
|
|
3112
|
+
return new Response(`a download is already in progress (${whisperDownload.id})`, {
|
|
3113
|
+
status: 409,
|
|
3114
|
+
});
|
|
3115
|
+
// Kick off the streamed download; progress is polled via GET. Swallow the
|
|
3116
|
+
// rejection here (recorded on the state) to avoid an unhandled rejection.
|
|
3117
|
+
// A generous wall-clock timeout (ethical-hacker Finding 3) so a stalled
|
|
3118
|
+
// connection aborts → errors → frees the single slot instead of wedging it
|
|
3119
|
+
// forever (large models on a slow link can legitimately take many minutes).
|
|
3120
|
+
whisperDownload = { id, received: 0, total: 0 };
|
|
3121
|
+
void downloadWhisperModel(
|
|
3122
|
+
id,
|
|
3123
|
+
(received, total) => {
|
|
3124
|
+
if (whisperDownload && whisperDownload.id === id) {
|
|
3125
|
+
whisperDownload.received = received;
|
|
3126
|
+
whisperDownload.total = total;
|
|
3127
|
+
}
|
|
3128
|
+
},
|
|
3129
|
+
AbortSignal.timeout(45 * 60_000)
|
|
3130
|
+
)
|
|
3131
|
+
.then(() => {
|
|
3132
|
+
if (whisperDownload?.id === id) whisperDownload = null; // done → drops out of GET
|
|
3133
|
+
})
|
|
3134
|
+
.catch((err) => {
|
|
3135
|
+
if (whisperDownload?.id === id)
|
|
3136
|
+
whisperDownload = {
|
|
3137
|
+
id,
|
|
3138
|
+
received: whisperDownload.received,
|
|
3139
|
+
total: whisperDownload.total,
|
|
3140
|
+
error: err instanceof Error ? err.message : 'download failed',
|
|
3141
|
+
};
|
|
3142
|
+
});
|
|
3143
|
+
return Response.json(
|
|
3144
|
+
{ started: id },
|
|
3145
|
+
{ status: 202, headers: { 'Cache-Control': 'no-store' } }
|
|
3146
|
+
);
|
|
3147
|
+
},
|
|
3148
|
+
|
|
2331
3149
|
'/_canvas-state': async (req: Request) => {
|
|
2332
3150
|
const url = new URL(req.url);
|
|
2333
3151
|
if (req.method === 'GET') {
|
|
@@ -2456,14 +3274,21 @@ export function createHttp(
|
|
|
2456
3274
|
} catch {
|
|
2457
3275
|
return new Response('Bad request', { status: 400 });
|
|
2458
3276
|
}
|
|
2459
|
-
|
|
3277
|
+
// DDR-173 (Phase 3 / T12) — brand-logo assets live one level down at
|
|
3278
|
+
// `assets/logos/<sha8>.<ext>` (DDR-141's own convention). This is the
|
|
3279
|
+
// ONE legitimate subdirectory allowed here, matched by its own exact,
|
|
3280
|
+
// closed-charset shape — not a general "any subdirectory" opt-in,
|
|
3281
|
+
// which would widen this route's containment beyond what DDR-141
|
|
3282
|
+
// actually calls for.
|
|
3283
|
+
const isFlat =
|
|
2460
3284
|
name &&
|
|
2461
3285
|
!name.includes('/') &&
|
|
2462
3286
|
!name.includes('\\') &&
|
|
2463
3287
|
!name.includes('..') &&
|
|
2464
3288
|
!name.startsWith('_') &&
|
|
2465
|
-
CANVAS_ASSET_EXTS.has(ext(name))
|
|
2466
|
-
|
|
3289
|
+
CANVAS_ASSET_EXTS.has(ext(name));
|
|
3290
|
+
const isLogoSubdir = /^logos\/[a-z0-9]{8}\.(svg|png)$/.test(name);
|
|
3291
|
+
if (isFlat || isLogoSubdir) {
|
|
2467
3292
|
const abs = join(ctx.paths.designRoot, 'assets', name);
|
|
2468
3293
|
// Range-aware for video/audio (scrubbing + WKWebView compat).
|
|
2469
3294
|
if (RANGE_MEDIA_EXTS.has(ext(name))) {
|
|
@@ -2525,6 +3350,39 @@ export function createHttp(
|
|
|
2525
3350
|
return new Response('Not found', { status: 404 });
|
|
2526
3351
|
}
|
|
2527
3352
|
|
|
3353
|
+
// DDR-166 Phase 1 / T2 — bundled Maude-product media (the intro
|
|
3354
|
+
// showreel), served from MAUDE's OWN MEDIA_DIR (paths.ts, DDR-045),
|
|
3355
|
+
// never the served project's designRoot — every user of the canvas
|
|
3356
|
+
// browser sees the same intro regardless of which project is open.
|
|
3357
|
+
// Same shape as /_stickers/ above: MAIN-ORIGIN ONLY (absent from
|
|
3358
|
+
// CANVAS_SAFE_API + startCanvasServer's routes), Range-capable via
|
|
3359
|
+
// serveMediaFile (the wizard/Help player needs to scrub).
|
|
3360
|
+
if (pathname.startsWith('/_media/')) {
|
|
3361
|
+
const name = pathname.slice('/_media/'.length);
|
|
3362
|
+
let decoded = '';
|
|
3363
|
+
try {
|
|
3364
|
+
decoded = decodeURIComponent(name);
|
|
3365
|
+
} catch {
|
|
3366
|
+
return new Response('Bad request', { status: 400 });
|
|
3367
|
+
}
|
|
3368
|
+
if (
|
|
3369
|
+
decoded &&
|
|
3370
|
+
!decoded.includes('..') &&
|
|
3371
|
+
!decoded.includes('/') &&
|
|
3372
|
+
!decoded.includes('\\') &&
|
|
3373
|
+
RANGE_MEDIA_EXTS.has(ext(decoded))
|
|
3374
|
+
) {
|
|
3375
|
+
const abs = join(MEDIA_DIR, decoded);
|
|
3376
|
+
if (await Bun.file(abs).exists()) {
|
|
3377
|
+
return serveMediaFile(abs, req, {
|
|
3378
|
+
'Cache-Control': 'public, max-age=31536000, immutable', // bundled with this maude version, never changes at this URL
|
|
3379
|
+
'X-Content-Type-Options': 'nosniff',
|
|
3380
|
+
});
|
|
3381
|
+
}
|
|
3382
|
+
}
|
|
3383
|
+
return new Response('Not found', { status: 404 });
|
|
3384
|
+
}
|
|
3385
|
+
|
|
2528
3386
|
// Fall-through: serve user repo files (designRoot + everything under repoRoot).
|
|
2529
3387
|
const fp = safePathUnderRoot(req.url, ctx.paths.repoRoot);
|
|
2530
3388
|
if (!fp) return new Response('Forbidden', { status: 403 });
|