@1agh/maude 0.39.0 → 0.40.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 (87) hide show
  1. package/README.md +1 -1
  2. package/apps/studio/acp/bridge.ts +144 -5
  3. package/apps/studio/acp/index.ts +11 -1
  4. package/apps/studio/annotations-context-toolbar.tsx +44 -1
  5. package/apps/studio/annotations-layer.tsx +253 -3
  6. package/apps/studio/annotations-model.ts +107 -6
  7. package/apps/studio/api.ts +812 -64
  8. package/apps/studio/bin/_html-playwright.mjs +9 -1
  9. package/apps/studio/bin/_pdf-playwright.mjs +8 -1
  10. package/apps/studio/bin/_png-playwright.mjs +8 -1
  11. package/apps/studio/bin/_pw-launch.mjs +54 -0
  12. package/apps/studio/bin/_svg-playwright.mjs +8 -1
  13. package/apps/studio/bin/_video-playwright.mjs +452 -0
  14. package/apps/studio/bin/prep.sh +8 -1
  15. package/apps/studio/canvas-edit.ts +1885 -104
  16. package/apps/studio/canvas-lib.tsx +19 -0
  17. package/apps/studio/canvas-list-watch.ts +6 -2
  18. package/apps/studio/canvas-shell.tsx +27 -0
  19. package/apps/studio/client/app.jsx +1029 -30
  20. package/apps/studio/client/github.js +7 -0
  21. package/apps/studio/client/panels/TimelinePanel.jsx +860 -0
  22. package/apps/studio/client/panels/timeline-parse.js +229 -0
  23. package/apps/studio/client/panels/timeline-snap.js +55 -0
  24. package/apps/studio/client/styles/3-shell-maude.css +107 -0
  25. package/apps/studio/config.schema.json +14 -0
  26. package/apps/studio/context-menu.tsx +1 -1
  27. package/apps/studio/context.ts +113 -1
  28. package/apps/studio/dist/client.bundle.js +88 -16
  29. package/apps/studio/dist/comment-mount.js +1 -1
  30. package/apps/studio/dist/runtime/.min-sizes.json +11 -1
  31. package/apps/studio/dist/runtime/@remotion_media.js +491 -0
  32. package/apps/studio/dist/runtime/@remotion_player.js +56 -0
  33. package/apps/studio/dist/runtime/@remotion_transitions.js +300 -0
  34. package/apps/studio/dist/runtime/@remotion_transitions_clock-wipe.js +1 -0
  35. package/apps/studio/dist/runtime/@remotion_transitions_fade.js +1 -0
  36. package/apps/studio/dist/runtime/@remotion_transitions_flip.js +1 -0
  37. package/apps/studio/dist/runtime/@remotion_transitions_none.js +1 -0
  38. package/apps/studio/dist/runtime/@remotion_transitions_slide.js +1 -0
  39. package/apps/studio/dist/runtime/@remotion_transitions_wipe.js +1 -0
  40. package/apps/studio/dist/runtime/REMOTION-LICENSE.md +28 -0
  41. package/apps/studio/dist/runtime/remotion.js +42 -0
  42. package/apps/studio/dist/styles.css +1 -1
  43. package/apps/studio/exporters/_browser-bundles.ts +117 -0
  44. package/apps/studio/exporters/_runtime.ts +69 -0
  45. package/apps/studio/exporters/html.ts +4 -3
  46. package/apps/studio/exporters/index.ts +28 -2
  47. package/apps/studio/exporters/pdf.ts +4 -3
  48. package/apps/studio/exporters/png.ts +5 -3
  49. package/apps/studio/exporters/pptx.ts +6 -4
  50. package/apps/studio/exporters/svg.ts +9 -5
  51. package/apps/studio/exporters/video-encode-lib.ts +200 -0
  52. package/apps/studio/exporters/video-render-lib.ts +108 -0
  53. package/apps/studio/exporters/video.ts +184 -0
  54. package/apps/studio/http.ts +535 -27
  55. package/apps/studio/input-router.tsx +7 -1
  56. package/apps/studio/runtime-bundle.ts +30 -0
  57. package/apps/studio/server.ts +34 -9
  58. package/apps/studio/test/acp-bridge.test.ts +132 -0
  59. package/apps/studio/test/acp-commands.test.ts +8 -3
  60. package/apps/studio/test/annotations-roundtrip.test.ts +47 -0
  61. package/apps/studio/test/canvas-create-api.test.ts +76 -0
  62. package/apps/studio/test/canvas-edit.test.ts +90 -0
  63. package/apps/studio/test/canvas-list-watch.test.ts +49 -0
  64. package/apps/studio/test/canvas-media-drop.test.ts +30 -1
  65. package/apps/studio/test/canvas-origin-gate.test.ts +36 -0
  66. package/apps/studio/test/clip-addressing.test.ts +732 -0
  67. package/apps/studio/test/config-reload.test.ts +230 -0
  68. package/apps/studio/test/dns-rebinding-guard.test.ts +74 -0
  69. package/apps/studio/test/edit-persistence.test.ts +91 -0
  70. package/apps/studio/test/exporters/runtime.test.ts +59 -0
  71. package/apps/studio/test/file-lock.test.ts +84 -0
  72. package/apps/studio/test/fixtures/mock-acp-agent.mjs +24 -1
  73. package/apps/studio/test/fixtures/video-comp-fixture.tsx +82 -0
  74. package/apps/studio/test/timeline-parse.test.ts +127 -0
  75. package/apps/studio/test/timeline-snap.test.ts +85 -0
  76. package/apps/studio/test/video-asset.test.ts +163 -0
  77. package/apps/studio/test/video-comp-fixture.test.ts +50 -0
  78. package/apps/studio/test/video-comp.test.ts +168 -0
  79. package/apps/studio/test/video-render-bridge.test.ts +149 -0
  80. package/apps/studio/use-annotation-resize.tsx +6 -3
  81. package/apps/studio/use-canvas-media-drop.tsx +66 -4
  82. package/apps/studio/video-comp.tsx +444 -0
  83. package/apps/studio/whats-new.json +36 -0
  84. package/apps/studio/ws.ts +5 -0
  85. package/package.json +8 -8
  86. package/plugins/design/templates/_shell.html +25 -2
  87. package/plugins/design/templates/design-system-inspiration/_MAPPING.md +1 -1
@@ -10,7 +10,7 @@ import { dirname, join, posix, relative, resolve, sep } from 'node:path';
10
10
 
11
11
  import { probeAcpAvailability } from './acp/probe.ts';
12
12
  import { deleteChat, listChats, readChatMessages } from './acp/transcript.ts';
13
- import type { Api } from './api.ts';
13
+ import { type Api, ASSET_MAX_VIDEO_BYTES } from './api.ts';
14
14
  import { buildCanvasModule } from './canvas-build.ts';
15
15
  import { canvasLibPath } from './canvas-lib-resolver.ts';
16
16
  import { TranspileError } from './canvas-pipeline.ts';
@@ -55,6 +55,15 @@ export const MIME: Record<string, string> = {
55
55
  '.woff2': 'font/woff2',
56
56
  '.ttf': 'font/ttf',
57
57
  '.otf': 'font/otf',
58
+ // DDR-148 — video/audio media referenced by a video-comp `<Video>`/`<Audio>`.
59
+ '.mp4': 'video/mp4',
60
+ '.m4v': 'video/mp4',
61
+ '.mov': 'video/quicktime',
62
+ '.webm': 'video/webm',
63
+ '.mp3': 'audio/mpeg',
64
+ '.wav': 'audio/wav',
65
+ '.m4a': 'audio/mp4',
66
+ '.ogg': 'audio/ogg',
58
67
  };
59
68
 
60
69
  function ext(p: string): string {
@@ -112,6 +121,11 @@ export function cspForCanvasShell(html: string, mainOrigin?: string): string {
112
121
  `script-src ${scriptSrc}`,
113
122
  "connect-src 'self'",
114
123
  "img-src 'self' data: blob:",
124
+ // DDR-148 — a video-comp's <Video>/<Audio> loads media from the canvas
125
+ // origin's own designRoot (assets/). Same inert-bytes trust as img-src
126
+ // 'self'; without it default-src 'none' blocks all media (black video, no
127
+ // sound). `blob:` covers Remotion's processed-media URLs.
128
+ "media-src 'self' blob:",
115
129
  "style-src 'self' 'unsafe-inline'",
116
130
  "font-src 'self' data:",
117
131
  "frame-src 'self'",
@@ -124,6 +138,39 @@ export function cspForCanvasShell(html: string, mainOrigin?: string): string {
124
138
  return directives.join('; ');
125
139
  }
126
140
 
141
+ /**
142
+ * CSP for the EXPORT/CAPTURE render (DDR-148 security, attacker F1). The export
143
+ * renders the (untrusted, DDR-054) canvas on the MAIN origin — where the normal
144
+ * shell CSP is env-gated off — and the capture shim ACTIVELY primes+seeks every
145
+ * `<video>`, so a canvas with `<Video src="http://169.254.169.254/…">` or comp
146
+ * JS doing `fetch(internal)` would turn ⌘E into read-SSRF + exfil-via-artifact.
147
+ *
148
+ * The comp is arbitrary JS by design and the in-page encoder is injected via
149
+ * `addScriptTag`, so restricting SCRIPT execution is neither possible nor the
150
+ * threat — the threat is NETWORK EGRESS. This CSP therefore locks every network
151
+ * sink to `'self'` (media/img/connect/font, no frames/objects/form posts) while
152
+ * leaving script permissive. Legit designRoot media is same-origin → still
153
+ * loads; every off-origin fetch/media/beacon is refused.
154
+ */
155
+ export function cspForCapture(): string {
156
+ return [
157
+ "default-src 'self'",
158
+ "script-src 'self' 'unsafe-inline' 'unsafe-eval' blob:",
159
+ "style-src 'self' 'unsafe-inline'",
160
+ "img-src 'self' data: blob:",
161
+ "media-src 'self' blob:",
162
+ "font-src 'self' data:",
163
+ "connect-src 'self'",
164
+ "worker-src 'self' blob:",
165
+ "child-src 'self' blob:",
166
+ "frame-src 'none'",
167
+ "object-src 'none'",
168
+ "base-uri 'none'",
169
+ "form-action 'none'",
170
+ "webrtc 'block'",
171
+ ].join('; ');
172
+ }
173
+
127
174
  /**
128
175
  * CSRF guard for the main-origin source-write routes (edit-css / edit-text /
129
176
  * edit-attr / reorder). Those routes are reachable only from the shell, which is
@@ -475,6 +522,57 @@ async function serveFile(absPath: string, headers: Record<string, string> = {}):
475
522
  });
476
523
  }
477
524
 
525
+ /** Media (video/audio) extensions that get HTTP Range support when served. */
526
+ const RANGE_MEDIA_EXTS = new Set(['.mp4', '.m4v', '.mov', '.webm', '.mp3', '.wav', '.m4a', '.ogg']);
527
+
528
+ /**
529
+ * DDR-150 dogfood (team finding) — serve a media file with HTTP Range support.
530
+ * The plain `new Response(Bun.file(..))` lane answered `Range:` requests with a
531
+ * 200 + full body: Chrome copes for small clips (buffers everything) but large
532
+ * MP4s scrub terribly, and WKWebView (the native desktop app) can refuse to
533
+ * play media from servers without Range support at all. Single-range only
534
+ * (`bytes=a-b`, suffix `bytes=-n`, open `bytes=a-`); malformed ranges fall back
535
+ * to a full 200; an unsatisfiable one gets an honest 416.
536
+ */
537
+ async function serveMediaFile(
538
+ absPath: string,
539
+ req: Request,
540
+ headers: Record<string, string> = {}
541
+ ): Promise<Response> {
542
+ const file = Bun.file(absPath);
543
+ if (!(await file.exists())) return new Response('Not found', { status: 404 });
544
+ const size = file.size;
545
+ const base = {
546
+ 'Content-Type': MIME[ext(absPath)] || 'application/octet-stream',
547
+ 'Cache-Control': 'no-store',
548
+ 'Accept-Ranges': 'bytes',
549
+ ...headers,
550
+ };
551
+ const range = req.headers.get('range');
552
+ const m = range ? /^bytes=(\d*)-(\d*)$/.exec(range.trim()) : null;
553
+ if (m && (m[1] || m[2]) && size > 0) {
554
+ const start = m[1]
555
+ ? Number.parseInt(m[1], 10)
556
+ : Math.max(0, size - Number.parseInt(m[2] as string, 10));
557
+ const end = m[1] && m[2] ? Math.min(Number.parseInt(m[2], 10), size - 1) : size - 1;
558
+ if (Number.isFinite(start) && start >= 0 && start <= end && start < size) {
559
+ return new Response(file.slice(start, end + 1), {
560
+ status: 206,
561
+ headers: {
562
+ ...base,
563
+ 'Content-Range': `bytes ${start}-${end}/${size}`,
564
+ 'Content-Length': String(end - start + 1),
565
+ },
566
+ });
567
+ }
568
+ return new Response(null, {
569
+ status: 416,
570
+ headers: { ...base, 'Content-Range': `bytes */${size}` },
571
+ });
572
+ }
573
+ return new Response(file, { headers: base });
574
+ }
575
+
478
576
  export interface Http {
479
577
  routes: Record<string, (req: Request) => Response | Promise<Response>>;
480
578
  fetch(req: Request): Promise<Response>;
@@ -484,7 +582,7 @@ export interface Http {
484
582
  * origin keeps it env-gated for the POC). Shared so both listeners produce
485
583
  * byte-identical HTML.
486
584
  */
487
- serveCanvasShell(applyCsp: boolean): Promise<Response>;
585
+ serveCanvasShell(applyCsp: boolean, capture?: boolean): Promise<Response>;
488
586
  /**
489
587
  * T2 (9.1-A) — allowlist gate for the segregated canvas origin. Returns true
490
588
  * only for the routes the canvas runtime legitimately needs (shell, runtime
@@ -568,8 +666,43 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
568
666
 
569
667
  async function readJson<T = unknown>(req: Request, max = 256 * 1024): Promise<T | null> {
570
668
  try {
571
- const text = await req.text();
572
- if (text.length > max) throw new Error('body too large');
669
+ // DDR-148 security (attacker F2): the global maxRequestBodySize is raised
670
+ // to ~108 MB so the STREAMING /_api/asset route can accept large videos —
671
+ // but a JSON route must never buffer that. `req.text()` would materialize
672
+ // the whole body BEFORE the length check (a 107 MB body from the untrusted
673
+ // canvas origin = memory-amplification DoS, reverting DDR-088). So: honest
674
+ // content-length fast-reject, then read the stream and ABORT past `max`,
675
+ // buffering at most ~max + one chunk.
676
+ const cl = Number(req.headers.get('content-length'));
677
+ if (Number.isFinite(cl) && cl > max) return null;
678
+ const body = req.body;
679
+ if (!body) return null;
680
+ const reader = body.getReader();
681
+ const chunks: Uint8Array[] = [];
682
+ let size = 0;
683
+ for (;;) {
684
+ const { done, value } = await reader.read();
685
+ if (done) break;
686
+ if (!value) continue;
687
+ size += value.byteLength;
688
+ if (size > max) {
689
+ try {
690
+ await reader.cancel();
691
+ } catch {
692
+ /* stream already closing */
693
+ }
694
+ return null;
695
+ }
696
+ chunks.push(value);
697
+ }
698
+ if (size === 0) return null;
699
+ const buf = new Uint8Array(size);
700
+ let off = 0;
701
+ for (const c of chunks) {
702
+ buf.set(c, off);
703
+ off += c.byteLength;
704
+ }
705
+ const text = new TextDecoder().decode(buf);
573
706
  return text ? (JSON.parse(text) as T) : null;
574
707
  } catch {
575
708
  return null;
@@ -619,6 +752,8 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
619
752
  // ChatPanel not-connected explainer + the Help-menu modal read this.
620
753
  '/_api/preflight': async (req: Request) => {
621
754
  if (!sameOriginWrite(req)) return new Response('cross-origin rejected', { status: 403 });
755
+ if (!isLoopbackHost(req.headers.get('host')))
756
+ return new Response('local request required (DNS-rebinding guard)', { status: 403 });
622
757
  return Response.json(await probeReadiness(), { headers: { 'Cache-Control': 'no-store' } });
623
758
  },
624
759
 
@@ -631,6 +766,8 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
631
766
  // chat-open` driver omits Origin (allowed); a browser drive-by can't forge it.
632
767
  if (!sameOriginWrite(req))
633
768
  return new Response('cross-origin write rejected', { status: 403 });
769
+ if (!isLoopbackHost(req.headers.get('host')))
770
+ return new Response('local request required (DNS-rebinding guard)', { status: 403 });
634
771
  ctx.bus.emit('acp-focus', {});
635
772
  return Response.json({ ok: true }, { headers: { 'Cache-Control': 'no-store' } });
636
773
  },
@@ -685,6 +822,8 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
685
822
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
686
823
  if (!sameOriginWrite(req))
687
824
  return new Response('cross-origin write rejected', { status: 403 });
825
+ if (!isLoopbackHost(req.headers.get('host')))
826
+ return new Response('local request required (DNS-rebinding guard)', { status: 403 });
688
827
  const declared = Number(req.headers.get('content-length') || '0');
689
828
  if (Number.isFinite(declared) && declared > 10 * 1024 * 1024) {
690
829
  return Response.json(
@@ -791,6 +930,34 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
791
930
  return new Response('Method not allowed', { status: 405 });
792
931
  },
793
932
 
933
+ '/_api/canvas-source': async (req: Request) => {
934
+ // DDR-148 — read-only raw .tsx source for the Timeline panel's sequence/
935
+ // keyframe parser. GET ?file=<canvas rel or slug> → { source }.
936
+ // MAIN-ORIGIN ONLY: intentionally absent from CANVAS_SAFE_API +
937
+ // startCanvasServer's routes (dual-allowlist, DDR-054) — the untrusted
938
+ // canvas iframe must never read arbitrary project source. Containment via
939
+ // api.loadCanvasSource → resolveCanvasAbs (stays under designRoot).
940
+ if (req.method !== 'GET') return new Response('Method not allowed', { status: 405 });
941
+ // DNS-rebinding guard — a GET read of project source is CORS-readable once
942
+ // a rebound page shares this origin; the dev-server always binds 127.0.0.1,
943
+ // so a real browser (Host: localhost:<port>) passes and only a rebound
944
+ // foreign hostname 403s. (No sameOriginWrite here — GET.)
945
+ if (!isLoopbackHost(req.headers.get('host')))
946
+ return new Response('local request required (DNS-rebinding guard)', { status: 403 });
947
+ const file = new URL(req.url).searchParams.get('file');
948
+ const r = await api.loadCanvasSource(file);
949
+ if (!r.ok) {
950
+ return Response.json(
951
+ { ok: false, error: r.error },
952
+ { status: r.status, headers: { 'Cache-Control': 'no-store' } }
953
+ );
954
+ }
955
+ return Response.json(
956
+ { ok: true, source: r.source },
957
+ { headers: { 'Cache-Control': 'no-store' } }
958
+ );
959
+ },
960
+
794
961
  '/_api/git-committers': async (req: Request) => {
795
962
  // Phase 6 — feed for the @mention autocomplete in composer + reply box.
796
963
  // GET → top-20 committers on HEAD (`git shortlog -sne | head -20`)
@@ -829,6 +996,8 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
829
996
  // slash-command driver omits Origin (→ allowed); a browser drive-by can't.
830
997
  if (!sameOriginWrite(req))
831
998
  return new Response('cross-origin write rejected', { status: 403 });
999
+ if (!isLoopbackHost(req.headers.get('host')))
1000
+ return new Response('local request required (DNS-rebinding guard)', { status: 403 });
832
1001
  const body = await readJson<{ file?: string; author?: string }>(req);
833
1002
  if (!body || typeof body.file !== 'string' || !body.file.trim()) {
834
1003
  return new Response('body.file required', { status: 400 });
@@ -847,6 +1016,8 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
847
1016
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
848
1017
  if (!sameOriginWrite(req))
849
1018
  return new Response('cross-origin write rejected', { status: 403 });
1019
+ if (!isLoopbackHost(req.headers.get('host')))
1020
+ return new Response('local request required (DNS-rebinding guard)', { status: 403 });
850
1021
  const body = await readJson<{ file?: string }>(req);
851
1022
  if (!body || typeof body.file !== 'string' || !body.file.trim()) {
852
1023
  return new Response('body.file required', { status: 400 });
@@ -861,6 +1032,8 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
861
1032
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
862
1033
  if (!sameOriginWrite(req))
863
1034
  return new Response('cross-origin write rejected', { status: 403 });
1035
+ if (!isLoopbackHost(req.headers.get('host')))
1036
+ return new Response('local request required (DNS-rebinding guard)', { status: 403 });
864
1037
  const body = await readJson<{ file?: string }>(req);
865
1038
  if (!body || typeof body.file !== 'string' || !body.file.trim()) {
866
1039
  return new Response('body.file required', { status: 400 });
@@ -912,6 +1085,17 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
912
1085
  // startCanvasServer's allowlist (DDR-054) — the untrusted canvas iframe
913
1086
  // origin must never reach a file-write/-delete endpoint. Validation lives in
914
1087
  // api.createCanvas / api.deleteCanvas (containment + group allowlist).
1088
+ // DDR-150 P4 Task 12 — this route creates/deletes source `.tsx` (the
1089
+ // video-comp assemble writes real comp source), so it's CSRF-gated like the
1090
+ // other source-write routes: a cross-origin top-level page is rejected (the
1091
+ // DDR-054 split only blocks the canvas iframe). No-Origin (curl/tests) + the
1092
+ // same-origin shell pass. Closes a pre-existing gap on create/delete.
1093
+ if (req.method === 'DELETE' || req.method === 'POST') {
1094
+ if (!sameOriginWrite(req))
1095
+ return new Response('cross-origin write rejected', { status: 403 });
1096
+ if (!isLoopbackHost(req.headers.get('host')))
1097
+ return new Response('local request required (DNS-rebinding guard)', { status: 403 });
1098
+ }
915
1099
  if (req.method === 'DELETE') {
916
1100
  const file = new URL(req.url).searchParams.get('file');
917
1101
  const result = await api.deleteCanvas({ file });
@@ -933,9 +1117,19 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
933
1117
  );
934
1118
  }
935
1119
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
936
- const body = await readJson<{ name?: unknown; kind?: unknown; group?: unknown }>(
1120
+ const body = await readJson<{
1121
+ name?: unknown;
1122
+ kind?: unknown;
1123
+ group?: unknown;
1124
+ clips?: unknown;
1125
+ fps?: unknown;
1126
+ width?: unknown;
1127
+ height?: unknown;
1128
+ }>(
937
1129
  req,
938
- 8 * 1024
1130
+ // DDR-150 P4 Task 12 — a video-comp assemble carries a clips[] array;
1131
+ // widen the read cap accordingly (still bounded well under abuse).
1132
+ 64 * 1024
939
1133
  );
940
1134
  if (!body) return new Response('body required', { status: 400 });
941
1135
  const result = await api.createCanvas(body);
@@ -994,6 +1188,8 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
994
1188
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
995
1189
  if (!sameOriginWrite(req))
996
1190
  return new Response('cross-origin write rejected', { status: 403 });
1191
+ if (!isLoopbackHost(req.headers.get('host')))
1192
+ return new Response('local request required (DNS-rebinding guard)', { status: 403 });
997
1193
  const body = await readJson<unknown>(req, 8 * 1024);
998
1194
  return gitJson(await gitApi.createBranch(body));
999
1195
  },
@@ -1002,6 +1198,8 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
1002
1198
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
1003
1199
  if (!sameOriginWrite(req))
1004
1200
  return new Response('cross-origin write rejected', { status: 403 });
1201
+ if (!isLoopbackHost(req.headers.get('host')))
1202
+ return new Response('local request required (DNS-rebinding guard)', { status: 403 });
1005
1203
  const body = await readJson<unknown>(req, 8 * 1024);
1006
1204
  return gitJson(await gitApi.checkout(body));
1007
1205
  },
@@ -1034,6 +1232,8 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
1034
1232
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
1035
1233
  if (!sameOriginWrite(req))
1036
1234
  return new Response('cross-origin write rejected', { status: 403 });
1235
+ if (!isLoopbackHost(req.headers.get('host')))
1236
+ return new Response('local request required (DNS-rebinding guard)', { status: 403 });
1037
1237
  const body = await readJson<unknown>(req, 256 * 1024);
1038
1238
  return gitJson(await gitApi.commit(body));
1039
1239
  },
@@ -1042,6 +1242,8 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
1042
1242
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
1043
1243
  if (!sameOriginWrite(req))
1044
1244
  return new Response('cross-origin write rejected', { status: 403 });
1245
+ if (!isLoopbackHost(req.headers.get('host')))
1246
+ return new Response('local request required (DNS-rebinding guard)', { status: 403 });
1045
1247
  const body = await readJson<unknown>(req, 256 * 1024);
1046
1248
  return gitJson(await gitApi.discard(body));
1047
1249
  },
@@ -1050,6 +1252,8 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
1050
1252
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
1051
1253
  if (!sameOriginWrite(req))
1052
1254
  return new Response('cross-origin write rejected', { status: 403 });
1255
+ if (!isLoopbackHost(req.headers.get('host')))
1256
+ return new Response('local request required (DNS-rebinding guard)', { status: 403 });
1053
1257
  // Token-bearing: refuse anything not from a loopback Host (the server binds
1054
1258
  // 127.0.0.1, so this is belt-and-suspenders against a forwarded/rebound Host).
1055
1259
  if (!isLoopbackHost(req.headers.get('host')))
@@ -1197,6 +1401,8 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
1197
1401
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
1198
1402
  if (!sameOriginWrite(req))
1199
1403
  return new Response('cross-origin write rejected', { status: 403 });
1404
+ if (!isLoopbackHost(req.headers.get('host')))
1405
+ return new Response('local request required (DNS-rebinding guard)', { status: 403 });
1200
1406
  const body = await readJson<{
1201
1407
  canvas?: unknown;
1202
1408
  id?: unknown;
@@ -1227,6 +1433,8 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
1227
1433
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
1228
1434
  if (!sameOriginWrite(req))
1229
1435
  return new Response('cross-origin write rejected', { status: 403 });
1436
+ if (!isLoopbackHost(req.headers.get('host')))
1437
+ return new Response('local request required (DNS-rebinding guard)', { status: 403 });
1230
1438
  const body = await readJson<{ canvas?: unknown; id?: unknown; text?: unknown }>(
1231
1439
  req,
1232
1440
  16 * 1024
@@ -1255,6 +1463,8 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
1255
1463
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
1256
1464
  if (!sameOriginWrite(req))
1257
1465
  return new Response('cross-origin write rejected', { status: 403 });
1466
+ if (!isLoopbackHost(req.headers.get('host')))
1467
+ return new Response('local request required (DNS-rebinding guard)', { status: 403 });
1258
1468
  const body = await readJson<{
1259
1469
  canvas?: unknown;
1260
1470
  id?: unknown;
@@ -1271,7 +1481,7 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
1271
1481
  );
1272
1482
  }
1273
1483
  return Response.json(
1274
- { ok: true, delta: result.delta },
1484
+ { ok: true, delta: result.delta, seq: result.seq },
1275
1485
  { status: 200, headers: { 'Cache-Control': 'no-store' } }
1276
1486
  );
1277
1487
  },
@@ -1289,6 +1499,8 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
1289
1499
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
1290
1500
  if (!sameOriginWrite(req))
1291
1501
  return new Response('cross-origin write rejected', { status: 403 });
1502
+ if (!isLoopbackHost(req.headers.get('host')))
1503
+ return new Response('local request required (DNS-rebinding guard)', { status: 403 });
1292
1504
  const body = await readJson<{
1293
1505
  canvas?: unknown;
1294
1506
  id?: unknown;
@@ -1317,6 +1529,227 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
1317
1529
  );
1318
1530
  },
1319
1531
 
1532
+ '/_api/retime-sequence': async (req: Request) => {
1533
+ // DDR-148 — Timeline drag-to-retime. POST { canvas, index, durationInFrames?,
1534
+ // from? } → rewrites the index-th sequence's timing (const-preferring so a
1535
+ // derived total updates too). Same MAIN-ORIGIN-ONLY trust boundary as
1536
+ // /_api/reorder + /_api/edit-css (absent from CANVAS_SAFE_API +
1537
+ // startCanvasServer routes, DDR-054) — the source write is privileged; the
1538
+ // untrusted canvas iframe can only request it over the dgn:* bus.
1539
+ if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
1540
+ if (!sameOriginWrite(req))
1541
+ return new Response('cross-origin write rejected', { status: 403 });
1542
+ if (!isLoopbackHost(req.headers.get('host')))
1543
+ return new Response('local request required (DNS-rebinding guard)', { status: 403 });
1544
+ const body = await readJson<{
1545
+ canvas?: unknown;
1546
+ stableId?: unknown;
1547
+ artboardId?: unknown;
1548
+ contentHash?: unknown;
1549
+ index?: unknown;
1550
+ durationInFrames?: unknown;
1551
+ from?: unknown;
1552
+ }>(req, 8 * 1024);
1553
+ if (!body) return new Response('body required', { status: 400 });
1554
+ const result = await api.retimeSequenceOp(body);
1555
+ if (!result.ok) {
1556
+ return Response.json(
1557
+ { ok: false, error: result.error },
1558
+ { status: result.status, headers: { 'Cache-Control': 'no-store' } }
1559
+ );
1560
+ }
1561
+ return Response.json(
1562
+ { ok: true, seq: result.seq },
1563
+ { status: 200, headers: { 'Cache-Control': 'no-store' } }
1564
+ );
1565
+ },
1566
+
1567
+ '/_api/remove-sequence': async (req: Request) => {
1568
+ // DDR-150 P3 — remove a clip. POST { canvas, stableId, artboardId?,
1569
+ // contentHash? } → removeClip (fingerprint + semantic gate; refuses the
1570
+ // only clip; drops an adjacent transition in a series). Snapshots pre-remove
1571
+ // for /design:rollback. Same MAIN-ORIGIN-ONLY trust boundary as the other
1572
+ // source-writes (absent from CANVAS_SAFE_API + startCanvasServer routes).
1573
+ if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
1574
+ if (!sameOriginWrite(req))
1575
+ return new Response('cross-origin write rejected', { status: 403 });
1576
+ if (!isLoopbackHost(req.headers.get('host')))
1577
+ return new Response('local request required (DNS-rebinding guard)', { status: 403 });
1578
+ const body = await readJson<{
1579
+ canvas?: unknown;
1580
+ stableId?: unknown;
1581
+ artboardId?: unknown;
1582
+ contentHash?: unknown;
1583
+ }>(req, 8 * 1024);
1584
+ if (!body) return new Response('body required', { status: 400 });
1585
+ const result = await api.removeSequenceOp(body);
1586
+ if (!result.ok) {
1587
+ return Response.json(
1588
+ { ok: false, error: result.error },
1589
+ { status: result.status, headers: { 'Cache-Control': 'no-store' } }
1590
+ );
1591
+ }
1592
+ return Response.json(
1593
+ { ok: true, seq: result.seq },
1594
+ { status: 200, headers: { 'Cache-Control': 'no-store' } }
1595
+ );
1596
+ },
1597
+
1598
+ '/_api/insert-sequence': async (req: Request) => {
1599
+ // DDR-150 P4 — insert a clip. POST { canvas, artboardId, from,
1600
+ // durationInFrames, mediaTag?, src? } → insertClip (appends after the
1601
+ // comp's last clip; refuses TransitionSeries + empty comps; src contained).
1602
+ // MAIN-ORIGIN-ONLY (absent from CANVAS_SAFE_API + startCanvasServer routes).
1603
+ if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
1604
+ if (!sameOriginWrite(req))
1605
+ return new Response('cross-origin write rejected', { status: 403 });
1606
+ if (!isLoopbackHost(req.headers.get('host')))
1607
+ return new Response('local request required (DNS-rebinding guard)', { status: 403 });
1608
+ const body = await readJson<{
1609
+ canvas?: unknown;
1610
+ artboardId?: unknown;
1611
+ from?: unknown;
1612
+ durationInFrames?: unknown;
1613
+ mediaTag?: unknown;
1614
+ src?: unknown;
1615
+ }>(req, 8 * 1024);
1616
+ if (!body) return new Response('body required', { status: 400 });
1617
+ const result = await api.insertSequenceOp(body);
1618
+ if (!result.ok) {
1619
+ return Response.json(
1620
+ { ok: false, error: result.error },
1621
+ { status: result.status, headers: { 'Cache-Control': 'no-store' } }
1622
+ );
1623
+ }
1624
+ return Response.json(
1625
+ { ok: true, stableId: result.stableId, seq: result.seq },
1626
+ { status: 200, headers: { 'Cache-Control': 'no-store' } }
1627
+ );
1628
+ },
1629
+
1630
+ '/_api/reorder-sequence': async (req: Request) => {
1631
+ // DDR-150 P5 — z-order reorder. POST { canvas, artboardId, stableId,
1632
+ // contentHash, refStableId, refContentHash, position } → reorderClip
1633
+ // (moves a standalone <Sequence> before/after a sibling via moveElement +
1634
+ // semantic gate; both clips fingerprint-checked; refuses TransitionSeries
1635
+ // clips + self-move). MAIN-ORIGIN-ONLY (absent from CANVAS_SAFE_API +
1636
+ // startCanvasServer routes — same dual-allowlist as the other clip writes).
1637
+ if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
1638
+ if (!sameOriginWrite(req))
1639
+ return new Response('cross-origin write rejected', { status: 403 });
1640
+ if (!isLoopbackHost(req.headers.get('host')))
1641
+ return new Response('local request required (DNS-rebinding guard)', { status: 403 });
1642
+ const body = await readJson<{
1643
+ canvas?: unknown;
1644
+ artboardId?: unknown;
1645
+ stableId?: unknown;
1646
+ contentHash?: unknown;
1647
+ refStableId?: unknown;
1648
+ refContentHash?: unknown;
1649
+ position?: unknown;
1650
+ }>(req, 8 * 1024);
1651
+ if (!body) return new Response('body required', { status: 400 });
1652
+ const result = await api.reorderSequenceOp(body);
1653
+ if (!result.ok) {
1654
+ return Response.json(
1655
+ { ok: false, error: result.error },
1656
+ { status: result.status, headers: { 'Cache-Control': 'no-store' } }
1657
+ );
1658
+ }
1659
+ return Response.json(
1660
+ { ok: true, stableId: result.stableId, seq: result.seq },
1661
+ { status: 200, headers: { 'Cache-Control': 'no-store' } }
1662
+ );
1663
+ },
1664
+
1665
+ '/_api/toggle-hide': async (req: Request) => {
1666
+ // DDR-150 dogfood — hide/show a clip (gates its body behind {false && …};
1667
+ // the tag + time slot + TransitionSeries alternation stay). MAIN-ORIGIN-ONLY
1668
+ // + CSRF-gated like the other source writes.
1669
+ if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
1670
+ if (!sameOriginWrite(req))
1671
+ return new Response('cross-origin write rejected', { status: 403 });
1672
+ if (!isLoopbackHost(req.headers.get('host')))
1673
+ return new Response('local request required (DNS-rebinding guard)', { status: 403 });
1674
+ const body = await readJson<{
1675
+ canvas?: unknown;
1676
+ stableId?: unknown;
1677
+ artboardId?: unknown;
1678
+ contentHash?: unknown;
1679
+ }>(req, 8 * 1024);
1680
+ if (!body) return new Response('body required', { status: 400 });
1681
+ const result = await api.toggleHideOp(body);
1682
+ if (!result.ok) {
1683
+ return Response.json(
1684
+ { ok: false, error: result.error },
1685
+ { status: result.status, headers: { 'Cache-Control': 'no-store' } }
1686
+ );
1687
+ }
1688
+ return Response.json(
1689
+ { ok: true, hidden: result.hidden, seq: result.seq },
1690
+ { status: 200, headers: { 'Cache-Control': 'no-store' } }
1691
+ );
1692
+ },
1693
+
1694
+ '/_api/edit-array-src': async (req: Request) => {
1695
+ // DDR-150 dogfood — replace a media src stored in an array literal (the
1696
+ // showreel `const CLIPS = [{ src }, …]` fed via `<ClipShot clip={CLIPS[i]}>`),
1697
+ // addressed by the enumerator's mediaArrayRef. MAIN-ORIGIN-ONLY + CSRF-gated
1698
+ // (same dual-allowlist as the other source writes); value contained under
1699
+ // assets/ in the engine.
1700
+ if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
1701
+ if (!sameOriginWrite(req))
1702
+ return new Response('cross-origin write rejected', { status: 403 });
1703
+ if (!isLoopbackHost(req.headers.get('host')))
1704
+ return new Response('local request required (DNS-rebinding guard)', { status: 403 });
1705
+ const body = await readJson<{
1706
+ canvas?: unknown;
1707
+ arrayName?: unknown;
1708
+ index?: unknown;
1709
+ field?: unknown;
1710
+ value?: unknown;
1711
+ }>(req, 8 * 1024);
1712
+ if (!body) return new Response('body required', { status: 400 });
1713
+ const result = await api.editArraySrcOp(body);
1714
+ if (!result.ok) {
1715
+ return Response.json(
1716
+ { ok: false, error: result.error },
1717
+ { status: result.status, headers: { 'Cache-Control': 'no-store' } }
1718
+ );
1719
+ }
1720
+ return Response.json(
1721
+ { ok: true, seq: result.seq },
1722
+ { status: 200, headers: { 'Cache-Control': 'no-store' } }
1723
+ );
1724
+ },
1725
+
1726
+ '/_api/comp-clips': async (req: Request) => {
1727
+ // DDR-150 P2 — the single authoritative clip enumerator for a video-comp.
1728
+ // GET ?canvas=<rel>&artboardId=<id> → { ok, compName, clips:[{ stableId,
1729
+ // from, durationInFrames, mediaTag, mediaSrc, mediaCdId, contentHash }] }.
1730
+ // The Timeline addresses every clip op by the returned stableId (never a
1731
+ // regex document-order index — the multi-comp mis-hit defect). Read-only +
1732
+ // MAIN-ORIGIN-ONLY: absent from CANVAS_SAFE_API + the startCanvasServer
1733
+ // route map, so the untrusted canvas iframe can never reach it (DDR-054).
1734
+ if (req.method !== 'GET') return new Response('Method not allowed', { status: 405 });
1735
+ // DNS-rebinding guard — this GET echoes project source structure; loopback-
1736
+ // only Host (real browser passes, rebound hostname 403s). No sameOriginWrite (GET).
1737
+ if (!isLoopbackHost(req.headers.get('host')))
1738
+ return new Response('local request required (DNS-rebinding guard)', { status: 403 });
1739
+ const u = new URL(req.url);
1740
+ const result = await api.compClips({
1741
+ canvas: u.searchParams.get('canvas'),
1742
+ artboardId: u.searchParams.get('artboardId') ?? undefined,
1743
+ });
1744
+ if (!result.ok) {
1745
+ return Response.json(
1746
+ { ok: false, error: result.error },
1747
+ { status: result.status, headers: { 'Cache-Control': 'no-store' } }
1748
+ );
1749
+ }
1750
+ return Response.json(result, { status: 200, headers: { 'Cache-Control': 'no-store' } });
1751
+ },
1752
+
1320
1753
  '/_api/reorder-revert': async (req: Request) => {
1321
1754
  // Phase 12.1 follow-up — Cmd+Z / Cmd+Shift+Z for a reorder. POST body
1322
1755
  // { canvas, seq, dir:'undo'|'redo' } → api.reorderRevert swaps the whole
@@ -1327,6 +1760,8 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
1327
1760
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
1328
1761
  if (!sameOriginWrite(req))
1329
1762
  return new Response('cross-origin write rejected', { status: 403 });
1763
+ if (!isLoopbackHost(req.headers.get('host')))
1764
+ return new Response('local request required (DNS-rebinding guard)', { status: 403 });
1330
1765
  const body = await readJson<{ canvas?: unknown; seq?: unknown; dir?: unknown }>(
1331
1766
  req,
1332
1767
  8 * 1024
@@ -1346,31 +1781,29 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
1346
1781
  },
1347
1782
 
1348
1783
  '/_api/asset': async (req: Request) => {
1349
- // Phase 23 — binary image upload from the canvas (drag-drop / paste / the
1350
- // a11y file picker). POST raw image bytes → content-addressed write under
1784
+ // Phase 23 / DDR-148 — binary media upload from the canvas (drag-drop /
1785
+ // paste). POST raw bytes → content-addressed write under
1351
1786
  // <designRoot>/assets/<sha8>.<ext>, returns 201 { path }. This route is on
1352
1787
  // the canvas-origin allowlist (CANVAS_SAFE_API below) — a bigger grant than
1353
1788
  // the inert annotation-SVG write (binary, disk) — so the caps in
1354
- // api.saveAsset (magic-byte sniff, 10 MB ceiling, content-addressed name,
1355
- // traversal guard, no-SVG, dedupe) are the load-bearing trust mitigation,
1356
- // NOT optional. See DDR (Task 9).
1789
+ // api.saveAssetFromStream (magic-byte sniff, per-category ceiling, content-
1790
+ // addressed name, traversal guard, no-SVG/script, dedupe, session budget)
1791
+ // are the load-bearing trust mitigation, NOT optional. DDR-088 + DDR-148:
1792
+ // the body is STREAMED to disk (no full-buffer of a 100 MB clip in RAM).
1357
1793
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
1358
- // Early reject on a declared oversize body (the post-read length check in
1359
- // saveAsset is the authoritative gate — Content-Length can be omitted/lied).
1794
+ // Early reject on a declared oversize body (the streamed per-category cap in
1795
+ // saveAssetFromStream is the authoritative gate — Content-Length can be
1796
+ // omitted/lied; this only trims an obvious oversize before we open a temp).
1360
1797
  const declared = Number(req.headers.get('content-length') || '0');
1361
- if (Number.isFinite(declared) && declared > 10 * 1024 * 1024) {
1798
+ if (Number.isFinite(declared) && declared > ASSET_MAX_VIDEO_BYTES) {
1799
+ const mb = Math.round(ASSET_MAX_VIDEO_BYTES / (1024 * 1024));
1362
1800
  return Response.json(
1363
- { ok: false, error: 'asset exceeds the 10 MB cap' },
1801
+ { ok: false, error: `media exceeds the ${mb} MB cap` },
1364
1802
  { status: 413, headers: { 'Cache-Control': 'no-store' } }
1365
1803
  );
1366
1804
  }
1367
- let bytes: Uint8Array;
1368
- try {
1369
- bytes = new Uint8Array(await req.arrayBuffer());
1370
- } catch {
1371
- return new Response('could not read request body', { status: 400 });
1372
- }
1373
- const result = await api.saveAsset(bytes);
1805
+ if (!req.body) return new Response('empty body', { status: 400 });
1806
+ const result = await api.saveAssetFromStream(req.body as ReadableStream<Uint8Array>);
1374
1807
  if (!result.ok) {
1375
1808
  return Response.json(
1376
1809
  { ok: false, error: result.error },
@@ -1569,8 +2002,51 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
1569
2002
  if (pathname === '/_canvas-shell.html' || pathname === '/_canvas-shell') {
1570
2003
  // The segregated canvas origin (server.ts) calls serveCanvasShell(true)
1571
2004
  // directly with CSP always on; on the legacy main origin the CSP stays
1572
- // env-gated (MAUDE_CSP_POC) for the POC / backwards-compat.
1573
- return serveCanvasShell(process.env.MAUDE_CSP_POC === '1');
2005
+ // env-gated (MAUDE_CSP_POC) for the POC / backwards-compat. A capture
2006
+ // render (?hide-chrome=1 ⌘E export / screenshot shim) ALWAYS gets the
2007
+ // network-locked capture CSP so it can't SSRF/exfil (attacker F1).
2008
+ const capture = url.searchParams.get('hide-chrome') === '1';
2009
+ return serveCanvasShell(process.env.MAUDE_CSP_POC === '1', capture);
2010
+ }
2011
+
2012
+ // DDR-150 dogfood — canvas-relative assets alias. A comp's
2013
+ // `<Video src="assets/x.mp4">` resolves against the iframe root →
2014
+ // `/assets/x.mp4`; serve `designRoot/assets/x.mp4` (flat sha8-named
2015
+ // uploads, media extensions only — mirrors isCanvasSafeRoute's gate on
2016
+ // the canvas origin). Without this, every src the timeline insert /
2017
+ // replace / assemble writers emit 404'd in the Player.
2018
+ if (pathname.startsWith('/assets/')) {
2019
+ let name = '';
2020
+ try {
2021
+ name = decodeURIComponent(pathname.slice('/assets/'.length));
2022
+ } catch {
2023
+ return new Response('Bad request', { status: 400 });
2024
+ }
2025
+ if (
2026
+ name &&
2027
+ !name.includes('/') &&
2028
+ !name.includes('\\') &&
2029
+ !name.includes('..') &&
2030
+ !name.startsWith('_') &&
2031
+ CANVAS_ASSET_EXTS.has(ext(name))
2032
+ ) {
2033
+ const abs = join(ctx.paths.designRoot, 'assets', name);
2034
+ // Range-aware for video/audio (scrubbing + WKWebView compat).
2035
+ if (RANGE_MEDIA_EXTS.has(ext(name))) {
2036
+ return serveMediaFile(abs, req, { 'X-Content-Type-Options': 'nosniff' });
2037
+ }
2038
+ const f = Bun.file(abs);
2039
+ if (await f.exists()) {
2040
+ return new Response(f, {
2041
+ headers: {
2042
+ 'Content-Type': MIME[ext(name)] || 'application/octet-stream',
2043
+ 'Cache-Control': 'no-store',
2044
+ 'X-Content-Type-Options': 'nosniff',
2045
+ },
2046
+ });
2047
+ }
2048
+ return new Response('Not found', { status: 404 });
2049
+ }
1574
2050
  }
1575
2051
 
1576
2052
  // Fall-through: serve user repo files (designRoot + everything under repoRoot).
@@ -1587,6 +2063,11 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
1587
2063
  if (e === '.tsx' && underDesignRoot) {
1588
2064
  return serveCanvasTsx(fp, req, ctx, join(ctx.paths.designRoot, '_locator.json'));
1589
2065
  }
2066
+ // Video/audio get Range support (scrubbing + WKWebView compat) — the
2067
+ // `/.design/assets/…` form comps reference goes through here.
2068
+ if (RANGE_MEDIA_EXTS.has(e)) {
2069
+ return serveMediaFile(fp, req, { 'X-Content-Type-Options': 'nosniff' });
2070
+ }
1590
2071
  // Bun.file streams transparently for binary content.
1591
2072
  return new Response(file, {
1592
2073
  headers: {
@@ -1605,7 +2086,7 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
1605
2086
  }
1606
2087
  }
1607
2088
 
1608
- async function serveCanvasShell(applyCsp: boolean): Promise<Response> {
2089
+ async function serveCanvasShell(applyCsp: boolean, capture = false): Promise<Response> {
1609
2090
  const shellHtml = await Bun.file(join(TEMPLATES_DIR, '_shell.html')).text();
1610
2091
  // Inject inspector overlay — Cmd+Click selection + add-comment flow.
1611
2092
  const injected = inspect.injectInspector(shellHtml);
@@ -1613,7 +2094,12 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
1613
2094
  'Content-Type': 'text/html; charset=utf-8',
1614
2095
  'Cache-Control': 'no-store',
1615
2096
  };
1616
- if (applyCsp) headers['Content-Security-Policy'] = cspForCanvasShell(injected, ctx.mainOrigin);
2097
+ // A capture render (⌘E export / screenshot) ALWAYS carries the network-locked
2098
+ // capture CSP, even on the main origin where the normal shell CSP is off —
2099
+ // this is the SSRF/exfil boundary the export otherwise lacked (attacker F1).
2100
+ if (capture) headers['Content-Security-Policy'] = cspForCapture();
2101
+ else if (applyCsp)
2102
+ headers['Content-Security-Policy'] = cspForCanvasShell(injected, ctx.mainOrigin);
1617
2103
  return new Response(injected, { headers });
1618
2104
  }
1619
2105
 
@@ -1634,6 +2120,17 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
1634
2120
  '.woff2',
1635
2121
  '.ttf',
1636
2122
  '.otf',
2123
+ // DDR-148 — video/audio media a video-comp `<Video>`/`<Audio>` loads from
2124
+ // assets/. Served like images (inert static bytes, `nosniff`, no script
2125
+ // execution under the canvas CSP) — the same read grant, widened for media.
2126
+ '.mp4',
2127
+ '.m4v',
2128
+ '.mov',
2129
+ '.webm',
2130
+ '.mp3',
2131
+ '.wav',
2132
+ '.m4a',
2133
+ '.ogg',
1637
2134
  ]);
1638
2135
 
1639
2136
  // Exact API paths the canvas iframe needs (collab + display data). See
@@ -1697,6 +2194,17 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
1697
2194
  if (rest.split('/').some((seg) => seg.startsWith('_'))) return false;
1698
2195
  return CANVAS_ASSET_EXTS.has(ext(safe));
1699
2196
  }
2197
+ // DDR-150 dogfood — `/assets/<file>`: the canvas-RELATIVE form every writer
2198
+ // emits (`<Video src="assets/x.mp4">` from timeline insert / replace /
2199
+ // assemble, and what the video-comp skill teaches). It aliases to
2200
+ // `designRoot/assets/<file>` — flat single segment (uploads are sha8-named),
2201
+ // media/image extensions only, no `_` names. Same read-only trust class as
2202
+ // the designRel static lane above (DDR-088 widened media grant).
2203
+ if (safe.startsWith('/assets/')) {
2204
+ const rest = safe.slice('/assets/'.length);
2205
+ if (!rest || rest.includes('/') || rest.startsWith('_')) return false;
2206
+ return CANVAS_ASSET_EXTS.has(ext(safe));
2207
+ }
1700
2208
  return false;
1701
2209
  }
1702
2210