@1agh/maude 0.39.1 → 0.41.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 (137) hide show
  1. package/README.md +1 -1
  2. package/apps/studio/ai-banner.tsx +2 -2
  3. package/apps/studio/annotations-context-toolbar.tsx +44 -1
  4. package/apps/studio/annotations-layer.tsx +306 -5
  5. package/apps/studio/annotations-model.ts +107 -6
  6. package/apps/studio/api.ts +1316 -67
  7. package/apps/studio/artboard-marquee.tsx +1 -1
  8. package/apps/studio/bin/_canvas-rects-playwright.mjs +55 -0
  9. package/apps/studio/bin/_canvas-rects-static.mjs +166 -0
  10. package/apps/studio/bin/_fetch-asset.mjs +556 -0
  11. package/apps/studio/bin/_html-playwright.mjs +9 -1
  12. package/apps/studio/bin/_pdf-playwright.mjs +8 -1
  13. package/apps/studio/bin/_png-playwright.mjs +8 -1
  14. package/apps/studio/bin/_pw-launch.mjs +54 -0
  15. package/apps/studio/bin/_svg-playwright.mjs +8 -1
  16. package/apps/studio/bin/_video-playwright.mjs +452 -0
  17. package/apps/studio/bin/annotate.mjs +576 -34
  18. package/apps/studio/bin/canvas-rects.sh +152 -0
  19. package/apps/studio/bin/fetch-asset.sh +34 -0
  20. package/apps/studio/bin/prep.sh +8 -1
  21. package/apps/studio/bin/read-annotations.mjs +138 -7
  22. package/apps/studio/bin/smoke.sh +42 -6
  23. package/apps/studio/build.ts +21 -0
  24. package/apps/studio/canvas-comment-mount.tsx +138 -4
  25. package/apps/studio/canvas-edit.ts +2625 -111
  26. package/apps/studio/canvas-lib.tsx +238 -2
  27. package/apps/studio/canvas-list-watch.ts +6 -2
  28. package/apps/studio/canvas-shell.tsx +514 -20
  29. package/apps/studio/client/app.jsx +2480 -52
  30. package/apps/studio/client/comments-overlay.css +130 -126
  31. package/apps/studio/client/github.js +15 -0
  32. package/apps/studio/client/panels/TimelinePanel.jsx +860 -0
  33. package/apps/studio/client/panels/timeline-parse.js +229 -0
  34. package/apps/studio/client/panels/timeline-snap.js +55 -0
  35. package/apps/studio/client/styles/3-shell-maude.css +155 -0
  36. package/apps/studio/comments-overlay.tsx +148 -41
  37. package/apps/studio/config.schema.json +14 -0
  38. package/apps/studio/context-menu.tsx +16 -6
  39. package/apps/studio/context.ts +113 -1
  40. package/apps/studio/contextual-toolbar.tsx +262 -4
  41. package/apps/studio/cursors-overlay.tsx +4 -4
  42. package/apps/studio/dist/client.bundle.js +89 -17
  43. package/apps/studio/dist/comment-mount.js +59 -1
  44. package/apps/studio/dist/runtime/.min-sizes.json +11 -1
  45. package/apps/studio/dist/runtime/@remotion_media.js +491 -0
  46. package/apps/studio/dist/runtime/@remotion_player.js +56 -0
  47. package/apps/studio/dist/runtime/@remotion_transitions.js +300 -0
  48. package/apps/studio/dist/runtime/@remotion_transitions_clock-wipe.js +1 -0
  49. package/apps/studio/dist/runtime/@remotion_transitions_fade.js +1 -0
  50. package/apps/studio/dist/runtime/@remotion_transitions_flip.js +1 -0
  51. package/apps/studio/dist/runtime/@remotion_transitions_none.js +1 -0
  52. package/apps/studio/dist/runtime/@remotion_transitions_slide.js +1 -0
  53. package/apps/studio/dist/runtime/@remotion_transitions_wipe.js +1 -0
  54. package/apps/studio/dist/runtime/REMOTION-LICENSE.md +28 -0
  55. package/apps/studio/dist/runtime/remotion.js +42 -0
  56. package/apps/studio/dist/styles.css +1 -1
  57. package/apps/studio/dom-selection.ts +127 -1
  58. package/apps/studio/drag-state.ts +24 -0
  59. package/apps/studio/equal-spacing-detector.ts +205 -0
  60. package/apps/studio/export-dialog.tsx +1 -1
  61. package/apps/studio/exporters/_browser-bundles.ts +117 -0
  62. package/apps/studio/exporters/_runtime.ts +69 -0
  63. package/apps/studio/exporters/html.ts +4 -3
  64. package/apps/studio/exporters/index.ts +28 -2
  65. package/apps/studio/exporters/pdf.ts +4 -3
  66. package/apps/studio/exporters/png.ts +5 -3
  67. package/apps/studio/exporters/pptx.ts +6 -4
  68. package/apps/studio/exporters/svg.ts +9 -5
  69. package/apps/studio/exporters/video-encode-lib.ts +200 -0
  70. package/apps/studio/exporters/video-render-lib.ts +108 -0
  71. package/apps/studio/exporters/video.ts +184 -0
  72. package/apps/studio/history.ts +47 -1
  73. package/apps/studio/http.ts +758 -27
  74. package/apps/studio/input-router.tsx +19 -1
  75. package/apps/studio/marquee-overlay.tsx +1 -1
  76. package/apps/studio/measure-overlay.tsx +241 -0
  77. package/apps/studio/participants-chrome.tsx +3 -3
  78. package/apps/studio/runtime-bundle.ts +30 -0
  79. package/apps/studio/server.ts +34 -9
  80. package/apps/studio/sizing-mode.ts +117 -0
  81. package/apps/studio/spacing-handles.ts +166 -0
  82. package/apps/studio/test/annotate-write.test.ts +890 -0
  83. package/apps/studio/test/annotations-roundtrip.test.ts +47 -0
  84. package/apps/studio/test/camera-reveal.test.tsx +173 -0
  85. package/apps/studio/test/canvas-create-api.test.ts +76 -0
  86. package/apps/studio/test/canvas-edit.test.ts +140 -0
  87. package/apps/studio/test/canvas-list-watch.test.ts +49 -0
  88. package/apps/studio/test/canvas-media-drop.test.ts +30 -1
  89. package/apps/studio/test/canvas-origin-gate.test.ts +54 -0
  90. package/apps/studio/test/canvas-rects.test.ts +198 -0
  91. package/apps/studio/test/clip-addressing.test.ts +732 -0
  92. package/apps/studio/test/comments-overlay.test.ts +117 -0
  93. package/apps/studio/test/config-reload.test.ts +230 -0
  94. package/apps/studio/test/dns-rebinding-guard.test.ts +74 -0
  95. package/apps/studio/test/dom-selection.test.ts +130 -0
  96. package/apps/studio/test/edit-css-occurrence.test.ts +81 -0
  97. package/apps/studio/test/edit-persistence.test.ts +91 -0
  98. package/apps/studio/test/edit-scope-api.test.ts +115 -0
  99. package/apps/studio/test/element-resize.test.ts +136 -0
  100. package/apps/studio/test/element-structural-api.test.ts +360 -0
  101. package/apps/studio/test/element-structural-edit.test.ts +233 -0
  102. package/apps/studio/test/equal-spacing-detector.test.ts +165 -1
  103. package/apps/studio/test/exporters/runtime.test.ts +59 -0
  104. package/apps/studio/test/file-lock.test.ts +84 -0
  105. package/apps/studio/test/fixtures/video-comp-fixture.tsx +82 -0
  106. package/apps/studio/test/history-rollback.test.ts +26 -0
  107. package/apps/studio/test/knob-props-authored.test.ts +87 -0
  108. package/apps/studio/test/read-annotations.test.ts +154 -0
  109. package/apps/studio/test/sizing-mode.test.ts +102 -0
  110. package/apps/studio/test/spacing-handles.test.ts +138 -0
  111. package/apps/studio/test/specimen-select.test.ts +88 -0
  112. package/apps/studio/test/timeline-parse.test.ts +127 -0
  113. package/apps/studio/test/timeline-snap.test.ts +85 -0
  114. package/apps/studio/test/tool-palette-insert-anchor.test.ts +84 -0
  115. package/apps/studio/test/undo-sequence-byte-compare.test.ts +211 -0
  116. package/apps/studio/test/video-asset.test.ts +163 -0
  117. package/apps/studio/test/video-comp-fixture.test.ts +50 -0
  118. package/apps/studio/test/video-comp.test.ts +168 -0
  119. package/apps/studio/test/video-render-bridge.test.ts +149 -0
  120. package/apps/studio/tool-palette.tsx +122 -2
  121. package/apps/studio/undo-hud.tsx +2 -2
  122. package/apps/studio/use-annotation-resize.tsx +6 -3
  123. package/apps/studio/use-canvas-media-drop.tsx +66 -4
  124. package/apps/studio/use-element-resize.tsx +732 -0
  125. package/apps/studio/use-keyboard-discipline.tsx +205 -15
  126. package/apps/studio/use-selection-set.tsx +14 -0
  127. package/apps/studio/use-spacing-handles.tsx +388 -0
  128. package/apps/studio/video-comp.tsx +444 -0
  129. package/apps/studio/whats-new.json +55 -0
  130. package/apps/studio/ws.ts +5 -0
  131. package/cli/commands/design.mjs +6 -1
  132. package/cli/commands/design.test.mjs +49 -1
  133. package/cli/lib/fetch-asset.test.mjs +213 -0
  134. package/package.json +8 -8
  135. package/plugins/design/dependencies.json +10 -2
  136. package/plugins/design/templates/_shell.html +25 -2
  137. 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,12 +1401,15 @@ 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;
1203
1409
  property?: unknown;
1204
1410
  value?: unknown;
1205
1411
  reset?: unknown;
1412
+ idIndex?: unknown;
1206
1413
  }>(req, 8 * 1024);
1207
1414
  if (!body) return new Response('body required', { status: 400 });
1208
1415
  const result = await api.editCss(body);
@@ -1227,6 +1434,8 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
1227
1434
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
1228
1435
  if (!sameOriginWrite(req))
1229
1436
  return new Response('cross-origin write rejected', { status: 403 });
1437
+ if (!isLoopbackHost(req.headers.get('host')))
1438
+ return new Response('local request required (DNS-rebinding guard)', { status: 403 });
1230
1439
  const body = await readJson<{ canvas?: unknown; id?: unknown; text?: unknown }>(
1231
1440
  req,
1232
1441
  16 * 1024
@@ -1255,6 +1464,8 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
1255
1464
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
1256
1465
  if (!sameOriginWrite(req))
1257
1466
  return new Response('cross-origin write rejected', { status: 403 });
1467
+ if (!isLoopbackHost(req.headers.get('host')))
1468
+ return new Response('local request required (DNS-rebinding guard)', { status: 403 });
1258
1469
  const body = await readJson<{
1259
1470
  canvas?: unknown;
1260
1471
  id?: unknown;
@@ -1271,7 +1482,7 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
1271
1482
  );
1272
1483
  }
1273
1484
  return Response.json(
1274
- { ok: true, delta: result.delta },
1485
+ { ok: true, delta: result.delta, seq: result.seq },
1275
1486
  { status: 200, headers: { 'Cache-Control': 'no-store' } }
1276
1487
  );
1277
1488
  },
@@ -1289,6 +1500,8 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
1289
1500
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
1290
1501
  if (!sameOriginWrite(req))
1291
1502
  return new Response('cross-origin write rejected', { status: 403 });
1503
+ if (!isLoopbackHost(req.headers.get('host')))
1504
+ return new Response('local request required (DNS-rebinding guard)', { status: 403 });
1292
1505
  const body = await readJson<{
1293
1506
  canvas?: unknown;
1294
1507
  id?: unknown;
@@ -1317,6 +1530,227 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
1317
1530
  );
1318
1531
  },
1319
1532
 
1533
+ '/_api/retime-sequence': async (req: Request) => {
1534
+ // DDR-148 — Timeline drag-to-retime. POST { canvas, index, durationInFrames?,
1535
+ // from? } → rewrites the index-th sequence's timing (const-preferring so a
1536
+ // derived total updates too). Same MAIN-ORIGIN-ONLY trust boundary as
1537
+ // /_api/reorder + /_api/edit-css (absent from CANVAS_SAFE_API +
1538
+ // startCanvasServer routes, DDR-054) — the source write is privileged; the
1539
+ // untrusted canvas iframe can only request it over the dgn:* bus.
1540
+ if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
1541
+ if (!sameOriginWrite(req))
1542
+ return new Response('cross-origin write rejected', { status: 403 });
1543
+ if (!isLoopbackHost(req.headers.get('host')))
1544
+ return new Response('local request required (DNS-rebinding guard)', { status: 403 });
1545
+ const body = await readJson<{
1546
+ canvas?: unknown;
1547
+ stableId?: unknown;
1548
+ artboardId?: unknown;
1549
+ contentHash?: unknown;
1550
+ index?: unknown;
1551
+ durationInFrames?: unknown;
1552
+ from?: unknown;
1553
+ }>(req, 8 * 1024);
1554
+ if (!body) return new Response('body required', { status: 400 });
1555
+ const result = await api.retimeSequenceOp(body);
1556
+ if (!result.ok) {
1557
+ return Response.json(
1558
+ { ok: false, error: result.error },
1559
+ { status: result.status, headers: { 'Cache-Control': 'no-store' } }
1560
+ );
1561
+ }
1562
+ return Response.json(
1563
+ { ok: true, seq: result.seq },
1564
+ { status: 200, headers: { 'Cache-Control': 'no-store' } }
1565
+ );
1566
+ },
1567
+
1568
+ '/_api/remove-sequence': async (req: Request) => {
1569
+ // DDR-150 P3 — remove a clip. POST { canvas, stableId, artboardId?,
1570
+ // contentHash? } → removeClip (fingerprint + semantic gate; refuses the
1571
+ // only clip; drops an adjacent transition in a series). Snapshots pre-remove
1572
+ // for /design:rollback. Same MAIN-ORIGIN-ONLY trust boundary as the other
1573
+ // source-writes (absent from CANVAS_SAFE_API + startCanvasServer routes).
1574
+ if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
1575
+ if (!sameOriginWrite(req))
1576
+ return new Response('cross-origin write rejected', { status: 403 });
1577
+ if (!isLoopbackHost(req.headers.get('host')))
1578
+ return new Response('local request required (DNS-rebinding guard)', { status: 403 });
1579
+ const body = await readJson<{
1580
+ canvas?: unknown;
1581
+ stableId?: unknown;
1582
+ artboardId?: unknown;
1583
+ contentHash?: unknown;
1584
+ }>(req, 8 * 1024);
1585
+ if (!body) return new Response('body required', { status: 400 });
1586
+ const result = await api.removeSequenceOp(body);
1587
+ if (!result.ok) {
1588
+ return Response.json(
1589
+ { ok: false, error: result.error },
1590
+ { status: result.status, headers: { 'Cache-Control': 'no-store' } }
1591
+ );
1592
+ }
1593
+ return Response.json(
1594
+ { ok: true, seq: result.seq },
1595
+ { status: 200, headers: { 'Cache-Control': 'no-store' } }
1596
+ );
1597
+ },
1598
+
1599
+ '/_api/insert-sequence': async (req: Request) => {
1600
+ // DDR-150 P4 — insert a clip. POST { canvas, artboardId, from,
1601
+ // durationInFrames, mediaTag?, src? } → insertClip (appends after the
1602
+ // comp's last clip; refuses TransitionSeries + empty comps; src contained).
1603
+ // MAIN-ORIGIN-ONLY (absent from CANVAS_SAFE_API + startCanvasServer routes).
1604
+ if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
1605
+ if (!sameOriginWrite(req))
1606
+ return new Response('cross-origin write rejected', { status: 403 });
1607
+ if (!isLoopbackHost(req.headers.get('host')))
1608
+ return new Response('local request required (DNS-rebinding guard)', { status: 403 });
1609
+ const body = await readJson<{
1610
+ canvas?: unknown;
1611
+ artboardId?: unknown;
1612
+ from?: unknown;
1613
+ durationInFrames?: unknown;
1614
+ mediaTag?: unknown;
1615
+ src?: unknown;
1616
+ }>(req, 8 * 1024);
1617
+ if (!body) return new Response('body required', { status: 400 });
1618
+ const result = await api.insertSequenceOp(body);
1619
+ if (!result.ok) {
1620
+ return Response.json(
1621
+ { ok: false, error: result.error },
1622
+ { status: result.status, headers: { 'Cache-Control': 'no-store' } }
1623
+ );
1624
+ }
1625
+ return Response.json(
1626
+ { ok: true, stableId: result.stableId, seq: result.seq },
1627
+ { status: 200, headers: { 'Cache-Control': 'no-store' } }
1628
+ );
1629
+ },
1630
+
1631
+ '/_api/reorder-sequence': async (req: Request) => {
1632
+ // DDR-150 P5 — z-order reorder. POST { canvas, artboardId, stableId,
1633
+ // contentHash, refStableId, refContentHash, position } → reorderClip
1634
+ // (moves a standalone <Sequence> before/after a sibling via moveElement +
1635
+ // semantic gate; both clips fingerprint-checked; refuses TransitionSeries
1636
+ // clips + self-move). MAIN-ORIGIN-ONLY (absent from CANVAS_SAFE_API +
1637
+ // startCanvasServer routes — same dual-allowlist as the other clip writes).
1638
+ if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
1639
+ if (!sameOriginWrite(req))
1640
+ return new Response('cross-origin write rejected', { status: 403 });
1641
+ if (!isLoopbackHost(req.headers.get('host')))
1642
+ return new Response('local request required (DNS-rebinding guard)', { status: 403 });
1643
+ const body = await readJson<{
1644
+ canvas?: unknown;
1645
+ artboardId?: unknown;
1646
+ stableId?: unknown;
1647
+ contentHash?: unknown;
1648
+ refStableId?: unknown;
1649
+ refContentHash?: unknown;
1650
+ position?: unknown;
1651
+ }>(req, 8 * 1024);
1652
+ if (!body) return new Response('body required', { status: 400 });
1653
+ const result = await api.reorderSequenceOp(body);
1654
+ if (!result.ok) {
1655
+ return Response.json(
1656
+ { ok: false, error: result.error },
1657
+ { status: result.status, headers: { 'Cache-Control': 'no-store' } }
1658
+ );
1659
+ }
1660
+ return Response.json(
1661
+ { ok: true, stableId: result.stableId, seq: result.seq },
1662
+ { status: 200, headers: { 'Cache-Control': 'no-store' } }
1663
+ );
1664
+ },
1665
+
1666
+ '/_api/toggle-hide': async (req: Request) => {
1667
+ // DDR-150 dogfood — hide/show a clip (gates its body behind {false && …};
1668
+ // the tag + time slot + TransitionSeries alternation stay). MAIN-ORIGIN-ONLY
1669
+ // + CSRF-gated like the other source writes.
1670
+ if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
1671
+ if (!sameOriginWrite(req))
1672
+ return new Response('cross-origin write rejected', { status: 403 });
1673
+ if (!isLoopbackHost(req.headers.get('host')))
1674
+ return new Response('local request required (DNS-rebinding guard)', { status: 403 });
1675
+ const body = await readJson<{
1676
+ canvas?: unknown;
1677
+ stableId?: unknown;
1678
+ artboardId?: unknown;
1679
+ contentHash?: unknown;
1680
+ }>(req, 8 * 1024);
1681
+ if (!body) return new Response('body required', { status: 400 });
1682
+ const result = await api.toggleHideOp(body);
1683
+ if (!result.ok) {
1684
+ return Response.json(
1685
+ { ok: false, error: result.error },
1686
+ { status: result.status, headers: { 'Cache-Control': 'no-store' } }
1687
+ );
1688
+ }
1689
+ return Response.json(
1690
+ { ok: true, hidden: result.hidden, seq: result.seq },
1691
+ { status: 200, headers: { 'Cache-Control': 'no-store' } }
1692
+ );
1693
+ },
1694
+
1695
+ '/_api/edit-array-src': async (req: Request) => {
1696
+ // DDR-150 dogfood — replace a media src stored in an array literal (the
1697
+ // showreel `const CLIPS = [{ src }, …]` fed via `<ClipShot clip={CLIPS[i]}>`),
1698
+ // addressed by the enumerator's mediaArrayRef. MAIN-ORIGIN-ONLY + CSRF-gated
1699
+ // (same dual-allowlist as the other source writes); value contained under
1700
+ // assets/ in the engine.
1701
+ if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
1702
+ if (!sameOriginWrite(req))
1703
+ return new Response('cross-origin write rejected', { status: 403 });
1704
+ if (!isLoopbackHost(req.headers.get('host')))
1705
+ return new Response('local request required (DNS-rebinding guard)', { status: 403 });
1706
+ const body = await readJson<{
1707
+ canvas?: unknown;
1708
+ arrayName?: unknown;
1709
+ index?: unknown;
1710
+ field?: unknown;
1711
+ value?: unknown;
1712
+ }>(req, 8 * 1024);
1713
+ if (!body) return new Response('body required', { status: 400 });
1714
+ const result = await api.editArraySrcOp(body);
1715
+ if (!result.ok) {
1716
+ return Response.json(
1717
+ { ok: false, error: result.error },
1718
+ { status: result.status, headers: { 'Cache-Control': 'no-store' } }
1719
+ );
1720
+ }
1721
+ return Response.json(
1722
+ { ok: true, seq: result.seq },
1723
+ { status: 200, headers: { 'Cache-Control': 'no-store' } }
1724
+ );
1725
+ },
1726
+
1727
+ '/_api/comp-clips': async (req: Request) => {
1728
+ // DDR-150 P2 — the single authoritative clip enumerator for a video-comp.
1729
+ // GET ?canvas=<rel>&artboardId=<id> → { ok, compName, clips:[{ stableId,
1730
+ // from, durationInFrames, mediaTag, mediaSrc, mediaCdId, contentHash }] }.
1731
+ // The Timeline addresses every clip op by the returned stableId (never a
1732
+ // regex document-order index — the multi-comp mis-hit defect). Read-only +
1733
+ // MAIN-ORIGIN-ONLY: absent from CANVAS_SAFE_API + the startCanvasServer
1734
+ // route map, so the untrusted canvas iframe can never reach it (DDR-054).
1735
+ if (req.method !== 'GET') return new Response('Method not allowed', { status: 405 });
1736
+ // DNS-rebinding guard — this GET echoes project source structure; loopback-
1737
+ // only Host (real browser passes, rebound hostname 403s). No sameOriginWrite (GET).
1738
+ if (!isLoopbackHost(req.headers.get('host')))
1739
+ return new Response('local request required (DNS-rebinding guard)', { status: 403 });
1740
+ const u = new URL(req.url);
1741
+ const result = await api.compClips({
1742
+ canvas: u.searchParams.get('canvas'),
1743
+ artboardId: u.searchParams.get('artboardId') ?? undefined,
1744
+ });
1745
+ if (!result.ok) {
1746
+ return Response.json(
1747
+ { ok: false, error: result.error },
1748
+ { status: result.status, headers: { 'Cache-Control': 'no-store' } }
1749
+ );
1750
+ }
1751
+ return Response.json(result, { status: 200, headers: { 'Cache-Control': 'no-store' } });
1752
+ },
1753
+
1320
1754
  '/_api/reorder-revert': async (req: Request) => {
1321
1755
  // Phase 12.1 follow-up — Cmd+Z / Cmd+Shift+Z for a reorder. POST body
1322
1756
  // { canvas, seq, dir:'undo'|'redo' } → api.reorderRevert swaps the whole
@@ -1327,6 +1761,8 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
1327
1761
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
1328
1762
  if (!sameOriginWrite(req))
1329
1763
  return new Response('cross-origin write rejected', { status: 403 });
1764
+ if (!isLoopbackHost(req.headers.get('host')))
1765
+ return new Response('local request required (DNS-rebinding guard)', { status: 403 });
1330
1766
  const body = await readJson<{ canvas?: unknown; seq?: unknown; dir?: unknown }>(
1331
1767
  req,
1332
1768
  8 * 1024
@@ -1345,32 +1781,252 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
1345
1781
  );
1346
1782
  },
1347
1783
 
1784
+ '/_api/delete-element': async (req: Request) => {
1785
+ // Stage I (feature-element-editing-robustness) — delete an element by
1786
+ // data-cd-id. POST { canvas, id, idIndex? } → api.deleteElementOp (reparse
1787
+ // gate; reused-component instance via idIndex). Logs a whole-file undo seq
1788
+ // (Cmd+Z via /_api/reorder-revert — a structural edit churns positional
1789
+ // ids so an inverse descriptor goes stale). Same MAIN-ORIGIN-ONLY trust
1790
+ // boundary as /_api/reorder: absent from CANVAS_SAFE_API + startCanvasServer
1791
+ // routes (DDR-054); sameOriginWrite CSRF + loopback-Host (DNS-rebinding) gated.
1792
+ if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
1793
+ if (!sameOriginWrite(req))
1794
+ return new Response('cross-origin write rejected', { status: 403 });
1795
+ if (!isLoopbackHost(req.headers.get('host')))
1796
+ return new Response('local request required (DNS-rebinding guard)', { status: 403 });
1797
+ const body = await readJson<{ canvas?: unknown; id?: unknown; idIndex?: unknown }>(
1798
+ req,
1799
+ 8 * 1024
1800
+ );
1801
+ if (!body) return new Response('body required', { status: 400 });
1802
+ const result = await api.deleteElementOp(body);
1803
+ if (!result.ok) {
1804
+ return Response.json(
1805
+ { ok: false, error: result.error },
1806
+ { status: result.status, headers: { 'Cache-Control': 'no-store' } }
1807
+ );
1808
+ }
1809
+ return Response.json(
1810
+ { ok: true, deletedId: result.deletedId, seq: result.seq },
1811
+ { status: 200, headers: { 'Cache-Control': 'no-store' } }
1812
+ );
1813
+ },
1814
+
1815
+ '/_api/insert-element': async (req: Request) => {
1816
+ // Stage I — insert a synthesized div/text/image relative to `refId`. POST
1817
+ // { canvas, refId, position, kind, src?, refIndex? } → api.insertElementOp.
1818
+ // Returns the new element's post-transpile id so the shell can select it.
1819
+ // Whole-file undo seq. MAIN-ORIGIN ONLY (absent from both allowlists);
1820
+ // sameOriginWrite + loopback-Host gated. An `image` src is contained to
1821
+ // assets/ in the engine (no remote hotlink / ../ / scheme).
1822
+ if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
1823
+ if (!sameOriginWrite(req))
1824
+ return new Response('cross-origin write rejected', { status: 403 });
1825
+ if (!isLoopbackHost(req.headers.get('host')))
1826
+ return new Response('local request required (DNS-rebinding guard)', { status: 403 });
1827
+ const body = await readJson<{
1828
+ canvas?: unknown;
1829
+ refId?: unknown;
1830
+ position?: unknown;
1831
+ kind?: unknown;
1832
+ src?: unknown;
1833
+ refIndex?: unknown;
1834
+ }>(req, 8 * 1024);
1835
+ if (!body) return new Response('body required', { status: 400 });
1836
+ const result = await api.insertElementOp(body);
1837
+ if (!result.ok) {
1838
+ return Response.json(
1839
+ { ok: false, error: result.error },
1840
+ { status: result.status, headers: { 'Cache-Control': 'no-store' } }
1841
+ );
1842
+ }
1843
+ return Response.json(
1844
+ { ok: true, newId: result.newId, seq: result.seq },
1845
+ { status: 200, headers: { 'Cache-Control': 'no-store' } }
1846
+ );
1847
+ },
1848
+
1849
+ '/_api/duplicate-element': async (req: Request) => {
1850
+ // Cmd+D (Task L3) — duplicate an element (a copy as the next sibling). POST
1851
+ // { canvas, id, idIndex? } → api.duplicateElementOp. Whole-file undo seq.
1852
+ // MAIN-ORIGIN ONLY; sameOriginWrite + loopback-Host gated (dual-allowlist).
1853
+ if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
1854
+ if (!sameOriginWrite(req))
1855
+ return new Response('cross-origin write rejected', { status: 403 });
1856
+ if (!isLoopbackHost(req.headers.get('host')))
1857
+ return new Response('local request required (DNS-rebinding guard)', { status: 403 });
1858
+ const body = await readJson<{ canvas?: unknown; id?: unknown; idIndex?: unknown }>(
1859
+ req,
1860
+ 8 * 1024
1861
+ );
1862
+ if (!body) return new Response('body required', { status: 400 });
1863
+ const result = await api.duplicateElementOp(body);
1864
+ if (!result.ok) {
1865
+ return Response.json(
1866
+ { ok: false, error: result.error },
1867
+ { status: result.status, headers: { 'Cache-Control': 'no-store' } }
1868
+ );
1869
+ }
1870
+ return Response.json(
1871
+ { ok: true, newId: result.newId, seq: result.seq },
1872
+ { status: 200, headers: { 'Cache-Control': 'no-store' } }
1873
+ );
1874
+ },
1875
+
1876
+ '/_api/insert-artboard': async (req: Request) => {
1877
+ // Stage I4 — insert a new EMPTY artboard from a screen-size preset. POST
1878
+ // { canvas, id, label, width, height } → api.insertArtboardOp (appends a
1879
+ // <DCArtboard id label width height></DCArtboard> after the last artboard;
1880
+ // size JSX-authoritative per DDR-027). Whole-file undo seq. MAIN-ORIGIN
1881
+ // ONLY (absent from both allowlists); sameOriginWrite + loopback-Host gated.
1882
+ if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
1883
+ if (!sameOriginWrite(req))
1884
+ return new Response('cross-origin write rejected', { status: 403 });
1885
+ if (!isLoopbackHost(req.headers.get('host')))
1886
+ return new Response('local request required (DNS-rebinding guard)', { status: 403 });
1887
+ const body = await readJson<{
1888
+ canvas?: unknown;
1889
+ id?: unknown;
1890
+ label?: unknown;
1891
+ width?: unknown;
1892
+ height?: unknown;
1893
+ }>(req, 8 * 1024);
1894
+ if (!body) return new Response('body required', { status: 400 });
1895
+ const result = await api.insertArtboardOp(body);
1896
+ if (!result.ok) {
1897
+ return Response.json(
1898
+ { ok: false, error: result.error },
1899
+ { status: result.status, headers: { 'Cache-Control': 'no-store' } }
1900
+ );
1901
+ }
1902
+ return Response.json(
1903
+ { ok: true, artboardId: result.artboardId, seq: result.seq },
1904
+ { status: 200, headers: { 'Cache-Control': 'no-store' } }
1905
+ );
1906
+ },
1907
+
1908
+ '/_api/assets': async (req: Request) => {
1909
+ // Stage F1 (feature-element-editing-robustness) — list content-addressed
1910
+ // image/video assets under <designRoot>/assets/ for the AssetPicker (Replace
1911
+ // image / insert image). GET → { assets:[{path,name,ext,kind,size,mtimeMs}] }.
1912
+ // MAIN-ORIGIN ONLY: the picker is a shell dialog; absent from CANVAS_SAFE_API
1913
+ // + startCanvasServer routes (dual-allowlist, DDR-054). Loopback-Host gated
1914
+ // (DNS-rebinding); read-only, so no sameOriginWrite (GET).
1915
+ if (req.method !== 'GET') return new Response('Method not allowed', { status: 405 });
1916
+ if (!isLoopbackHost(req.headers.get('host')))
1917
+ return new Response('local request required (DNS-rebinding guard)', { status: 403 });
1918
+ const r = await api.listAssets();
1919
+ return Response.json(
1920
+ { ok: true, assets: r.assets },
1921
+ { headers: { 'Cache-Control': 'no-store' } }
1922
+ );
1923
+ },
1924
+
1925
+ '/_api/edit-scope': async (req: Request) => {
1926
+ // Stage H (feature-element-editing-robustness) — the INV-3 predictability
1927
+ // verdict. GET ?canvas&id&rendered → { ok, scope:'local'|'shared',
1928
+ // componentName, affects, reason }. READ-only (a parse the shell runs on
1929
+ // selection to render the Local/Shared badge). MAIN-ORIGIN ONLY: absent
1930
+ // from CANVAS_SAFE_API + startCanvasServer routes (dual-allowlist, DDR-054);
1931
+ // loopback-Host gated (DNS-rebinding); no sameOriginWrite (GET).
1932
+ if (req.method !== 'GET') return new Response('Method not allowed', { status: 405 });
1933
+ if (!isLoopbackHost(req.headers.get('host')))
1934
+ return new Response('local request required (DNS-rebinding guard)', { status: 403 });
1935
+ const url = new URL(req.url);
1936
+ const result = await api.editScopeOp({
1937
+ canvas: url.searchParams.get('canvas') ?? undefined,
1938
+ id: url.searchParams.get('id') ?? undefined,
1939
+ rendered: url.searchParams.get('rendered') ?? undefined,
1940
+ });
1941
+ if (!result.ok) {
1942
+ return Response.json(
1943
+ { ok: false, error: result.error },
1944
+ { status: result.status, headers: { 'Cache-Control': 'no-store' } }
1945
+ );
1946
+ }
1947
+ return Response.json(result, { headers: { 'Cache-Control': 'no-store' } });
1948
+ },
1949
+
1950
+ '/_api/resize-artboard': async (req: Request) => {
1951
+ // Stage D4 — free-hand artboard resize. POST { canvas, artboardId, width?,
1952
+ // height? } → api.resizeArtboardOp (writes the NUMERIC width/height props on
1953
+ // the <DCArtboard id="…">, addressed by its `id` prop since the rendered
1954
+ // <article data-dc-screen> carries no data-cd-id; DDR-027). Whole-file undo
1955
+ // seq. MAIN-ORIGIN ONLY; sameOriginWrite + loopback-Host gated.
1956
+ if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
1957
+ if (!sameOriginWrite(req))
1958
+ return new Response('cross-origin write rejected', { status: 403 });
1959
+ if (!isLoopbackHost(req.headers.get('host')))
1960
+ return new Response('local request required (DNS-rebinding guard)', { status: 403 });
1961
+ const body = await readJson<{
1962
+ canvas?: unknown;
1963
+ artboardId?: unknown;
1964
+ width?: unknown;
1965
+ height?: unknown;
1966
+ }>(req, 8 * 1024);
1967
+ if (!body) return new Response('body required', { status: 400 });
1968
+ const result = await api.resizeArtboardOp(body);
1969
+ if (!result.ok) {
1970
+ return Response.json(
1971
+ { ok: false, error: result.error },
1972
+ { status: result.status, headers: { 'Cache-Control': 'no-store' } }
1973
+ );
1974
+ }
1975
+ return Response.json(
1976
+ { ok: true, seq: result.seq },
1977
+ { status: 200, headers: { 'Cache-Control': 'no-store' } }
1978
+ );
1979
+ },
1980
+
1981
+ '/_api/delete-artboard': async (req: Request) => {
1982
+ // Delete an artboard by its `id` prop (Backspace / context-menu on a frame).
1983
+ // POST { canvas, artboardId } → api.deleteArtboardOp (removes the
1984
+ // <DCArtboard id="…"> span; refuses the last one). Whole-file undo seq.
1985
+ // MAIN-ORIGIN ONLY; sameOriginWrite + loopback-Host gated (dual-allowlist).
1986
+ if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
1987
+ if (!sameOriginWrite(req))
1988
+ return new Response('cross-origin write rejected', { status: 403 });
1989
+ if (!isLoopbackHost(req.headers.get('host')))
1990
+ return new Response('local request required (DNS-rebinding guard)', { status: 403 });
1991
+ const body = await readJson<{ canvas?: unknown; artboardId?: unknown }>(req, 8 * 1024);
1992
+ if (!body) return new Response('body required', { status: 400 });
1993
+ const result = await api.deleteArtboardOp(body);
1994
+ if (!result.ok) {
1995
+ return Response.json(
1996
+ { ok: false, error: result.error },
1997
+ { status: result.status, headers: { 'Cache-Control': 'no-store' } }
1998
+ );
1999
+ }
2000
+ return Response.json(
2001
+ { ok: true, seq: result.seq },
2002
+ { status: 200, headers: { 'Cache-Control': 'no-store' } }
2003
+ );
2004
+ },
2005
+
1348
2006
  '/_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
2007
+ // Phase 23 / DDR-148 — binary media upload from the canvas (drag-drop /
2008
+ // paste). POST raw bytes → content-addressed write under
1351
2009
  // <designRoot>/assets/<sha8>.<ext>, returns 201 { path }. This route is on
1352
2010
  // the canvas-origin allowlist (CANVAS_SAFE_API below) — a bigger grant than
1353
2011
  // 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).
2012
+ // api.saveAssetFromStream (magic-byte sniff, per-category ceiling, content-
2013
+ // addressed name, traversal guard, no-SVG/script, dedupe, session budget)
2014
+ // are the load-bearing trust mitigation, NOT optional. DDR-088 + DDR-148:
2015
+ // the body is STREAMED to disk (no full-buffer of a 100 MB clip in RAM).
1357
2016
  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).
2017
+ // Early reject on a declared oversize body (the streamed per-category cap in
2018
+ // saveAssetFromStream is the authoritative gate — Content-Length can be
2019
+ // omitted/lied; this only trims an obvious oversize before we open a temp).
1360
2020
  const declared = Number(req.headers.get('content-length') || '0');
1361
- if (Number.isFinite(declared) && declared > 10 * 1024 * 1024) {
2021
+ if (Number.isFinite(declared) && declared > ASSET_MAX_VIDEO_BYTES) {
2022
+ const mb = Math.round(ASSET_MAX_VIDEO_BYTES / (1024 * 1024));
1362
2023
  return Response.json(
1363
- { ok: false, error: 'asset exceeds the 10 MB cap' },
2024
+ { ok: false, error: `media exceeds the ${mb} MB cap` },
1364
2025
  { status: 413, headers: { 'Cache-Control': 'no-store' } }
1365
2026
  );
1366
2027
  }
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);
2028
+ if (!req.body) return new Response('empty body', { status: 400 });
2029
+ const result = await api.saveAssetFromStream(req.body as ReadableStream<Uint8Array>);
1374
2030
  if (!result.ok) {
1375
2031
  return Response.json(
1376
2032
  { ok: false, error: result.error },
@@ -1569,8 +2225,51 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
1569
2225
  if (pathname === '/_canvas-shell.html' || pathname === '/_canvas-shell') {
1570
2226
  // The segregated canvas origin (server.ts) calls serveCanvasShell(true)
1571
2227
  // 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');
2228
+ // env-gated (MAUDE_CSP_POC) for the POC / backwards-compat. A capture
2229
+ // render (?hide-chrome=1 ⌘E export / screenshot shim) ALWAYS gets the
2230
+ // network-locked capture CSP so it can't SSRF/exfil (attacker F1).
2231
+ const capture = url.searchParams.get('hide-chrome') === '1';
2232
+ return serveCanvasShell(process.env.MAUDE_CSP_POC === '1', capture);
2233
+ }
2234
+
2235
+ // DDR-150 dogfood — canvas-relative assets alias. A comp's
2236
+ // `<Video src="assets/x.mp4">` resolves against the iframe root →
2237
+ // `/assets/x.mp4`; serve `designRoot/assets/x.mp4` (flat sha8-named
2238
+ // uploads, media extensions only — mirrors isCanvasSafeRoute's gate on
2239
+ // the canvas origin). Without this, every src the timeline insert /
2240
+ // replace / assemble writers emit 404'd in the Player.
2241
+ if (pathname.startsWith('/assets/')) {
2242
+ let name = '';
2243
+ try {
2244
+ name = decodeURIComponent(pathname.slice('/assets/'.length));
2245
+ } catch {
2246
+ return new Response('Bad request', { status: 400 });
2247
+ }
2248
+ if (
2249
+ name &&
2250
+ !name.includes('/') &&
2251
+ !name.includes('\\') &&
2252
+ !name.includes('..') &&
2253
+ !name.startsWith('_') &&
2254
+ CANVAS_ASSET_EXTS.has(ext(name))
2255
+ ) {
2256
+ const abs = join(ctx.paths.designRoot, 'assets', name);
2257
+ // Range-aware for video/audio (scrubbing + WKWebView compat).
2258
+ if (RANGE_MEDIA_EXTS.has(ext(name))) {
2259
+ return serveMediaFile(abs, req, { 'X-Content-Type-Options': 'nosniff' });
2260
+ }
2261
+ const f = Bun.file(abs);
2262
+ if (await f.exists()) {
2263
+ return new Response(f, {
2264
+ headers: {
2265
+ 'Content-Type': MIME[ext(name)] || 'application/octet-stream',
2266
+ 'Cache-Control': 'no-store',
2267
+ 'X-Content-Type-Options': 'nosniff',
2268
+ },
2269
+ });
2270
+ }
2271
+ return new Response('Not found', { status: 404 });
2272
+ }
1574
2273
  }
1575
2274
 
1576
2275
  // Fall-through: serve user repo files (designRoot + everything under repoRoot).
@@ -1587,6 +2286,11 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
1587
2286
  if (e === '.tsx' && underDesignRoot) {
1588
2287
  return serveCanvasTsx(fp, req, ctx, join(ctx.paths.designRoot, '_locator.json'));
1589
2288
  }
2289
+ // Video/audio get Range support (scrubbing + WKWebView compat) — the
2290
+ // `/.design/assets/…` form comps reference goes through here.
2291
+ if (RANGE_MEDIA_EXTS.has(e)) {
2292
+ return serveMediaFile(fp, req, { 'X-Content-Type-Options': 'nosniff' });
2293
+ }
1590
2294
  // Bun.file streams transparently for binary content.
1591
2295
  return new Response(file, {
1592
2296
  headers: {
@@ -1605,7 +2309,7 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
1605
2309
  }
1606
2310
  }
1607
2311
 
1608
- async function serveCanvasShell(applyCsp: boolean): Promise<Response> {
2312
+ async function serveCanvasShell(applyCsp: boolean, capture = false): Promise<Response> {
1609
2313
  const shellHtml = await Bun.file(join(TEMPLATES_DIR, '_shell.html')).text();
1610
2314
  // Inject inspector overlay — Cmd+Click selection + add-comment flow.
1611
2315
  const injected = inspect.injectInspector(shellHtml);
@@ -1613,7 +2317,12 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
1613
2317
  'Content-Type': 'text/html; charset=utf-8',
1614
2318
  'Cache-Control': 'no-store',
1615
2319
  };
1616
- if (applyCsp) headers['Content-Security-Policy'] = cspForCanvasShell(injected, ctx.mainOrigin);
2320
+ // A capture render (⌘E export / screenshot) ALWAYS carries the network-locked
2321
+ // capture CSP, even on the main origin where the normal shell CSP is off —
2322
+ // this is the SSRF/exfil boundary the export otherwise lacked (attacker F1).
2323
+ if (capture) headers['Content-Security-Policy'] = cspForCapture();
2324
+ else if (applyCsp)
2325
+ headers['Content-Security-Policy'] = cspForCanvasShell(injected, ctx.mainOrigin);
1617
2326
  return new Response(injected, { headers });
1618
2327
  }
1619
2328
 
@@ -1634,6 +2343,17 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
1634
2343
  '.woff2',
1635
2344
  '.ttf',
1636
2345
  '.otf',
2346
+ // DDR-148 — video/audio media a video-comp `<Video>`/`<Audio>` loads from
2347
+ // assets/. Served like images (inert static bytes, `nosniff`, no script
2348
+ // execution under the canvas CSP) — the same read grant, widened for media.
2349
+ '.mp4',
2350
+ '.m4v',
2351
+ '.mov',
2352
+ '.webm',
2353
+ '.mp3',
2354
+ '.wav',
2355
+ '.m4a',
2356
+ '.ogg',
1637
2357
  ]);
1638
2358
 
1639
2359
  // Exact API paths the canvas iframe needs (collab + display data). See
@@ -1697,6 +2417,17 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
1697
2417
  if (rest.split('/').some((seg) => seg.startsWith('_'))) return false;
1698
2418
  return CANVAS_ASSET_EXTS.has(ext(safe));
1699
2419
  }
2420
+ // DDR-150 dogfood — `/assets/<file>`: the canvas-RELATIVE form every writer
2421
+ // emits (`<Video src="assets/x.mp4">` from timeline insert / replace /
2422
+ // assemble, and what the video-comp skill teaches). It aliases to
2423
+ // `designRoot/assets/<file>` — flat single segment (uploads are sha8-named),
2424
+ // media/image extensions only, no `_` names. Same read-only trust class as
2425
+ // the designRel static lane above (DDR-088 widened media grant).
2426
+ if (safe.startsWith('/assets/')) {
2427
+ const rest = safe.slice('/assets/'.length);
2428
+ if (!rest || rest.includes('/') || rest.startsWith('_')) return false;
2429
+ return CANVAS_ASSET_EXTS.has(ext(safe));
2430
+ }
1700
2431
  return false;
1701
2432
  }
1702
2433