@1agh/maude 0.43.0 → 0.45.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.
Files changed (126) hide show
  1. package/apps/studio/acp/bridge.ts +44 -10
  2. package/apps/studio/acp/env.ts +23 -0
  3. package/apps/studio/acp/login-state.ts +263 -0
  4. package/apps/studio/acp/plugin-bootstrap.ts +31 -25
  5. package/apps/studio/acp/probe.ts +152 -1
  6. package/apps/studio/annotations-layer.tsx +396 -46
  7. package/apps/studio/api.ts +395 -2
  8. package/apps/studio/bin/_audio-search.mjs +140 -0
  9. package/apps/studio/bin/_import-asset-pdf-worker.mjs +34 -0
  10. package/apps/studio/bin/_import-asset.mjs +815 -0
  11. package/apps/studio/bin/_import-brand.mjs +635 -0
  12. package/apps/studio/bin/_import-tokens-alias-resolver.mjs +95 -0
  13. package/apps/studio/bin/_import-tokens.mjs +1201 -0
  14. package/apps/studio/bin/_transcribe.mjs +419 -0
  15. package/apps/studio/bin/_transcribe.test.mjs +80 -0
  16. package/apps/studio/bin/audio-search.sh +28 -0
  17. package/apps/studio/bin/generate.sh +154 -0
  18. package/apps/studio/bin/import-asset.sh +34 -0
  19. package/apps/studio/bin/import-brand.sh +29 -0
  20. package/apps/studio/bin/import-tokens.sh +33 -0
  21. package/apps/studio/bin/photo-bg-remove.sh +119 -21
  22. package/apps/studio/bin/transcribe.sh +39 -0
  23. package/apps/studio/canvas-edit.ts +197 -2
  24. package/apps/studio/canvas-lib.tsx +115 -6
  25. package/apps/studio/client/app.jsx +1954 -685
  26. package/apps/studio/client/generate-dialog.jsx +352 -0
  27. package/apps/studio/client/github.js +14 -0
  28. package/apps/studio/client/inspector-controls.jsx +781 -0
  29. package/apps/studio/client/panels/BrandUploadPanel.jsx +233 -0
  30. package/apps/studio/client/panels/ChatPanel.jsx +15 -33
  31. package/apps/studio/client/panels/IntroVideoDialog.jsx +44 -0
  32. package/apps/studio/client/panels/OnboardingWizard.jsx +12 -0
  33. package/apps/studio/client/panels/ReadinessList.jsx +227 -7
  34. package/apps/studio/client/panels/SettingsPanel.jsx +828 -0
  35. package/apps/studio/client/panels/SetupChecklist.jsx +223 -0
  36. package/apps/studio/client/photo-knobs.jsx +167 -172
  37. package/apps/studio/client/styles/3-shell-maude.css +334 -28
  38. package/apps/studio/client/styles/4-components.css +223 -0
  39. package/apps/studio/client/styles/6-acp-chat.css +86 -0
  40. package/apps/studio/client/tour/quick-setup-tour.js +39 -0
  41. package/apps/studio/commands/annotation-strokes-command.ts +13 -3
  42. package/apps/studio/config.schema.json +70 -0
  43. package/apps/studio/context-menu.tsx +11 -2
  44. package/apps/studio/context.ts +16 -0
  45. package/apps/studio/design-setup-readiness.ts +115 -0
  46. package/apps/studio/dist/client.bundle.js +2097 -2088
  47. package/apps/studio/dist/styles.css +1 -1
  48. package/apps/studio/draw/palette.ts +34 -0
  49. package/apps/studio/footage/schema.test.ts +92 -0
  50. package/apps/studio/footage/schema.ts +179 -1
  51. package/apps/studio/generation/adapters/elevenlabs.ts +370 -0
  52. package/apps/studio/generation/adapters/gemini.ts +542 -0
  53. package/apps/studio/generation/adapters/groq.test.ts +80 -0
  54. package/apps/studio/generation/adapters/groq.ts +247 -0
  55. package/apps/studio/generation/audio-library.test.ts +95 -0
  56. package/apps/studio/generation/audio-library.ts +156 -0
  57. package/apps/studio/generation/captions.test.ts +78 -0
  58. package/apps/studio/generation/captions.ts +132 -0
  59. package/apps/studio/generation/download.ts +76 -0
  60. package/apps/studio/generation/elevenlabs.test.ts +223 -0
  61. package/apps/studio/generation/gemini.test.ts +350 -0
  62. package/apps/studio/generation/jobs.test.ts +128 -0
  63. package/apps/studio/generation/jobs.ts +243 -0
  64. package/apps/studio/generation/keys.test.ts +75 -0
  65. package/apps/studio/generation/keys.ts +162 -0
  66. package/apps/studio/generation/prefs.test.ts +95 -0
  67. package/apps/studio/generation/prefs.ts +82 -0
  68. package/apps/studio/generation/registry.ts +88 -0
  69. package/apps/studio/generation/types.test.ts +123 -0
  70. package/apps/studio/generation/types.ts +266 -0
  71. package/apps/studio/generation/whisper-models.test.ts +152 -0
  72. package/apps/studio/generation/whisper-models.ts +257 -0
  73. package/apps/studio/generation/whisper-spike-results.md +61 -0
  74. package/apps/studio/git/service.ts +3 -1
  75. package/apps/studio/http.ts +867 -9
  76. package/apps/studio/media/intro.mp4 +0 -0
  77. package/apps/studio/media-commit-chain.ts +119 -0
  78. package/apps/studio/paths.ts +10 -0
  79. package/apps/studio/readiness.ts +108 -20
  80. package/apps/studio/scaffold-design.ts +1145 -0
  81. package/apps/studio/server.ts +13 -1
  82. package/apps/studio/test/_helpers.ts +15 -2
  83. package/apps/studio/test/acp-bridge.test.ts +34 -1
  84. package/apps/studio/test/acp-env.test.ts +30 -0
  85. package/apps/studio/test/acp-plugin-bootstrap.test.ts +23 -57
  86. package/apps/studio/test/acp-session-plugins.test.ts +72 -1
  87. package/apps/studio/test/annotation-strokes-command.test.ts +17 -0
  88. package/apps/studio/test/annotations-layer.test.ts +174 -0
  89. package/apps/studio/test/canvas-origin-gate.test.ts +37 -0
  90. package/apps/studio/test/csp-canvas-shell.test.ts +11 -0
  91. package/apps/studio/test/csrf-write-guard.test.ts +23 -1
  92. package/apps/studio/test/design-setup-readiness.test.ts +155 -0
  93. package/apps/studio/test/element-structural-edit.test.ts +108 -0
  94. package/apps/studio/test/fixtures/fake-claude-auth.mjs +13 -0
  95. package/apps/studio/test/generate-route.test.ts +106 -0
  96. package/apps/studio/test/import-asset-browser.test.ts +64 -0
  97. package/apps/studio/test/import-asset-route.test.ts +71 -0
  98. package/apps/studio/test/import-asset.test.ts +399 -0
  99. package/apps/studio/test/import-brand-browser.test.ts +88 -0
  100. package/apps/studio/test/import-brand-route.test.ts +120 -0
  101. package/apps/studio/test/import-brand.test.ts +206 -0
  102. package/apps/studio/test/import-tokens.test.ts +722 -0
  103. package/apps/studio/test/media-commit-chain.test.ts +210 -0
  104. package/apps/studio/test/photo-bg-remove-validation.test.ts +71 -0
  105. package/apps/studio/test/readiness.test.ts +13 -11
  106. package/apps/studio/test/scaffold-design.test.ts +122 -0
  107. package/apps/studio/test/tour-overlay.test.tsx +16 -0
  108. package/apps/studio/tool-palette.tsx +11 -6
  109. package/apps/studio/ui-prefs.ts +130 -0
  110. package/apps/studio/use-canvas-media-drop.tsx +4 -2
  111. package/apps/studio/use-chrome-visibility.tsx +8 -2
  112. package/apps/studio/whats-new.json +96 -0
  113. package/apps/studio/ws.ts +5 -0
  114. package/cli/bin/maude.mjs +6 -6
  115. package/cli/commands/design.mjs +33 -0
  116. package/cli/commands/init.mjs +7 -1
  117. package/cli/lib/gitignore-block.mjs +1 -0
  118. package/cli/lib/pkg-root.mjs +107 -0
  119. package/cli/lib/pkg-root.test.mjs +79 -0
  120. package/cli/lib/reconstruct-toolset.test.mjs +194 -0
  121. package/cli/lib/update-check.mjs +8 -0
  122. package/package.json +12 -10
  123. package/plugins/design/dependencies.json +18 -0
  124. package/plugins/flow/.claude-plugin/config.schema.json +5 -0
  125. package/plugins/flow/templates/ai-skeleton/scenario-guide.md +39 -0
  126. package/plugins/flow/templates/ai-skeleton/scenarios/README.md +2 -0
@@ -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 { probeAcpAvailability } from './acp/probe.ts';
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
- '/_api/acp/status': () =>
789
- Response.json(probeAcpAvailability(), { headers: { 'Cache-Control': 'no-store' } }),
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
- if (
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 });