@1agh/maude 1.0.6 → 1.0.8

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 (48) hide show
  1. package/apps/studio/bin/_html-playwright.mjs +198 -19
  2. package/apps/studio/bin/_pdf-playwright.mjs +1 -0
  3. package/apps/studio/bin/_png-playwright.mjs +28 -8
  4. package/apps/studio/bin/_pptx-playwright.mjs +4 -1
  5. package/apps/studio/bin/_pw-launch.mjs +48 -0
  6. package/apps/studio/bin/_svg-playwright.mjs +63 -149
  7. package/apps/studio/bin/_video-playwright.mjs +123 -11
  8. package/apps/studio/canvas-edit.ts +33 -0
  9. package/apps/studio/canvas-lib.tsx +136 -0
  10. package/apps/studio/client/app.jsx +316 -28
  11. package/apps/studio/client/export-lane.js +191 -0
  12. package/apps/studio/dist/client.bundle.js +907 -907
  13. package/apps/studio/dist/runtime/.min-sizes.json +2 -1
  14. package/apps/studio/dist/runtime/dom-to-svg.js +31 -0
  15. package/apps/studio/export-dialog.tsx +24 -19
  16. package/apps/studio/exporters/_browser-bundles.ts +30 -0
  17. package/apps/studio/exporters/_runtime.ts +31 -1
  18. package/apps/studio/exporters/capture-chrome.ts +111 -0
  19. package/apps/studio/exporters/capture-core.ts +947 -0
  20. package/apps/studio/exporters/format-scopes.ts +88 -0
  21. package/apps/studio/exporters/html.ts +3 -2
  22. package/apps/studio/exporters/jobs.ts +180 -18
  23. package/apps/studio/exporters/pdf.ts +21 -1
  24. package/apps/studio/exporters/pptx.ts +46 -11
  25. package/apps/studio/exporters/remote.ts +94 -11
  26. package/apps/studio/exporters/scope.ts +14 -3
  27. package/apps/studio/exporters/svg.ts +17 -3
  28. package/apps/studio/exporters/video.ts +75 -8
  29. package/apps/studio/http.ts +175 -7
  30. package/apps/studio/runtime-bundle.ts +6 -0
  31. package/apps/studio/test/canvas-hide-chrome.test.ts +30 -0
  32. package/apps/studio/test/capture-core.test.ts +32 -0
  33. package/apps/studio/test/export-assemble.test.ts +53 -0
  34. package/apps/studio/test/export-browser-lane.test.ts +126 -0
  35. package/apps/studio/test/export-capture-fidelity.test.ts +146 -0
  36. package/apps/studio/test/export-capture-hygiene.test.ts +675 -0
  37. package/apps/studio/test/export-e2e-lanes.test.ts +857 -0
  38. package/apps/studio/test/export-format-scope-coherence.test.ts +89 -0
  39. package/apps/studio/test/exporters/jobs.test.ts +73 -0
  40. package/apps/studio/test/exporters/scope.test.ts +33 -0
  41. package/apps/studio/test/fixtures/tiny-subset.woff2 +0 -0
  42. package/apps/studio/test/pdf-print-boxes.test.ts +33 -0
  43. package/apps/studio/test/photo-canvas-bundle.test.ts +18 -0
  44. package/apps/studio/test/workspace-containment.test.ts +8 -0
  45. package/apps/studio/whats-new.json +18 -0
  46. package/apps/studio/workspace-mode.ts +15 -2
  47. package/package.json +8 -8
  48. package/plugins/design/templates/_shell.html +2 -1
@@ -73,6 +73,15 @@ export interface ExportScopeHints {
73
73
  selection?: { selector?: string; file?: string } | null;
74
74
  /** `data-dc-screen` id of the artboard to export for `scope=artboard`. */
75
75
  artboardId?: string | null;
76
+ /**
77
+ * The canvas FILE the dialog was exporting, captured at submit time. Wins
78
+ * over `activeJson.active`: the persisted `_active.json` is written
79
+ * asynchronously after a tab switch, so a job submitted right after opening
80
+ * another canvas rendered the PREVIOUS one — with the live `artboardId` of
81
+ * the new one, i.e. a selector that never appears (a 60 s waitFor timeout on
82
+ * the worker). The live dialog knows which file it is looking at; trust it.
83
+ */
84
+ canvasFile?: string | null;
76
85
  }
77
86
 
78
87
  export interface ResolveScopeArgs {
@@ -108,7 +117,9 @@ function readHints(options: Record<string, unknown> | undefined): ExportScopeHin
108
117
  : null;
109
118
  const artboardId =
110
119
  typeof options.artboardId === 'string' && options.artboardId ? options.artboardId : null;
111
- return { selection, artboardId };
120
+ const canvasFile =
121
+ typeof options.canvasFile === 'string' && options.canvasFile ? options.canvasFile : null;
122
+ return { selection, artboardId, canvasFile };
112
123
  }
113
124
 
114
125
  const RAW_EXCLUDES = new Set([
@@ -207,11 +218,11 @@ export async function resolveScope(args: ResolveScopeArgs): Promise<Target[]> {
207
218
  return [{ kind: 'file-tree', paths }];
208
219
  }
209
220
 
210
- const activeFile = activeJson.active;
221
+ const hints = readHints(args.options);
222
+ const activeFile = hints.canvasFile ?? activeJson.active;
211
223
  if (!activeFile) return [];
212
224
  const slug = slugify(activeFile, designRel);
213
225
  const sel = firstSelection(activeJson.selected);
214
- const hints = readHints(args.options);
215
226
 
216
227
  if (scope === 'selection') {
217
228
  // Prefer the live submit-time snapshot over the persisted `_active.json`
@@ -12,7 +12,7 @@ import path from 'node:path';
12
12
 
13
13
  import JSZip from 'jszip';
14
14
 
15
- import { getBrowserBundle } from './_browser-bundles.ts';
15
+ import { getBrowserBundle, getCaptureCoreBundle } from './_browser-bundles.ts';
16
16
  import { exportShimPath, runShim } from './_runtime.ts';
17
17
  import {
18
18
  canvasShellUrl,
@@ -32,6 +32,7 @@ async function captureSvg(
32
32
  outDir: string,
33
33
  timeoutSec: number,
34
34
  bundlePath: string,
35
+ corePath: string,
35
36
  hooks?: ExportHooks
36
37
  ): Promise<string[]> {
37
38
  const args = [
@@ -42,6 +43,8 @@ async function captureSvg(
42
43
  target.cssPath,
43
44
  '--bundle-path',
44
45
  bundlePath,
46
+ '--core-path',
47
+ corePath,
45
48
  '--timeout',
46
49
  String(timeoutSec),
47
50
  ];
@@ -82,13 +85,24 @@ export async function run(
82
85
  throw new Error('svg adapter requires element targets (got file-tree)');
83
86
  }
84
87
  const timeoutSec = (options.timeoutSec as number | undefined) ?? 12;
85
- const bundlePath = await getBrowserBundle('dom-to-svg', 'domToSvg');
88
+ const [bundlePath, corePath] = await Promise.all([
89
+ getBrowserBundle('dom-to-svg', 'domToSvg'),
90
+ getCaptureCoreBundle(),
91
+ ]);
86
92
  const tmp = mkdtempSync(path.join(tmpdir(), 'maude-svg-'));
87
93
 
88
94
  try {
89
95
  const written: string[] = [];
90
96
  for (let i = 0; i < elementTargets.length; i += 1) {
91
- const paths = await captureSvg(elementTargets[i], ctx, tmp, timeoutSec, bundlePath, hooks);
97
+ const paths = await captureSvg(
98
+ elementTargets[i],
99
+ ctx,
100
+ tmp,
101
+ timeoutSec,
102
+ bundlePath,
103
+ corePath,
104
+ hooks
105
+ );
92
106
  written.push(...paths);
93
107
  hooks?.onProgress?.({ current: i + 1, total: elementTargets.length });
94
108
  }
@@ -15,7 +15,7 @@
15
15
  // Scope is always `artboard`. No native binaries; the capture Chromium is the
16
16
  // same one every other exporter already uses (DDR-041).
17
17
 
18
- import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
18
+ import { mkdtempSync, readFileSync, realpathSync, rmSync } from 'node:fs';
19
19
  import { tmpdir } from 'node:os';
20
20
  import path from 'node:path';
21
21
 
@@ -30,7 +30,45 @@ import {
30
30
  type ExportResult,
31
31
  } from './index.ts';
32
32
  import type { Target } from './scope.ts';
33
- import { audioRefusalMessage, scanUnsupportedMedia } from './unsupported-media.ts';
33
+ import {
34
+ audioRefusalMessage,
35
+ scanUnsupportedMedia,
36
+ type UnsupportedFinding,
37
+ } from './unsupported-media.ts';
38
+
39
+ /** Resolve a client-influenced canvas `file` to an absolute path inside the
40
+ * design root, following symlinks — .tsx/.html only. Mirrors jobs.ts's guard
41
+ * (security review F2). Returns null on any failure. */
42
+ function safeCanvasAbs(repoRoot: string, designRoot: string, file: string): string | null {
43
+ if (!/\.(tsx|html)$/i.test(file)) return null;
44
+ try {
45
+ const designReal = realpathSync(path.resolve(designRoot));
46
+ // `file` is repo-relative (carries the `.design/` prefix) — resolve against
47
+ // repoRoot, require the realpath under designRoot.
48
+ const abs = realpathSync(path.resolve(repoRoot, file));
49
+ return abs === designReal || abs.startsWith(designReal + path.sep) ? abs : null;
50
+ } catch {
51
+ return null;
52
+ }
53
+ }
54
+
55
+ /**
56
+ * `options.unsupportedMedia` — the cell's pre-resolved `scanUnsupportedMedia`
57
+ * findings for a remote job, or null when absent (local lane: scan the disk).
58
+ * Shape-checked: it crosses the cell→worker HTTP boundary.
59
+ */
60
+ function shippedUnsupportedMedia(options: ExportOptions): UnsupportedFinding[] | null {
61
+ const raw = (options as { unsupportedMedia?: unknown }).unsupportedMedia;
62
+ if (!Array.isArray(raw)) return null;
63
+ return raw.filter(
64
+ (f): f is UnsupportedFinding =>
65
+ !!f &&
66
+ typeof f === 'object' &&
67
+ ((f as UnsupportedFinding).element === 'Audio' ||
68
+ (f as UnsupportedFinding).element === 'OffthreadVideo') &&
69
+ typeof (f as UnsupportedFinding).sourceFile === 'string'
70
+ );
71
+ }
34
72
 
35
73
  // DDR-045: resolve via DEV_SERVER_ROOT, never `import.meta.dir`. See _runtime.ts.
36
74
  const VIDEO_PLAYWRIGHT = exportShimPath('_video-playwright.mjs');
@@ -75,8 +113,21 @@ async function runVideo(
75
113
  // via a muted file is pure waste. GIF is silent by format, so it is exempt.
76
114
  let preStamped: ExportDegradation | undefined;
77
115
  if (format !== 'gif' && options.allowUnsupportedMedia !== true) {
78
- const canvasAbs = path.resolve(ctx.repoRoot, el.file);
79
- for (const finding of scanUnsupportedMedia(canvasAbs)) {
116
+ // The scan reads the canvas SOURCE, which only the process holding the
117
+ // checkout has. On the render worker (DDR-230: no tenant files) the read
118
+ // fails quietly — an empty finding list — and the export went on to do
119
+ // EXACTLY what this guard exists to prevent: renderMediaOnWeb rejected
120
+ // <Audio>, the frame-step fallback ran ~40× slower and shipped a muted
121
+ // file. So the cell resolves the findings up front and ships them in the
122
+ // job (`options.unsupportedMedia`, exporters/jobs.ts), same as printProps.
123
+ // `el.file` is client-influenced (canvasFile / selection.file). On the
124
+ // local/in-cell lane we scan it off disk — bound to the DESIGN root,
125
+ // realpath-checked (a `startsWith` prefix alone is escaped by an
126
+ // in-checkout symlink), .tsx/.html only (defender W1 / attacker F2).
127
+ const canvasAbs = safeCanvasAbs(ctx.repoRoot, ctx.designRoot, el.file);
128
+ const findings =
129
+ shippedUnsupportedMedia(options) ?? (canvasAbs ? scanUnsupportedMedia(canvasAbs) : []);
130
+ for (const finding of findings) {
80
131
  if (finding.element === 'Audio' && options.audio !== false) {
81
132
  // Refuse. A vanished music bed is worse than an export that did not run.
82
133
  throw new Error(audioRefusalMessage(finding, el.file));
@@ -97,10 +148,26 @@ async function runVideo(
97
148
  // silent format), so it never needs the render-lib bundle; mp4/webm build it
98
149
  // alongside the frame-step encode-lib (the shim decides per-target at runtime
99
150
  // whether the artboard has a registered comp to hand the renderer).
100
- const [lib, renderLib] = await Promise.all([
101
- getEncodeLibBundle(),
102
- format === 'gif' ? Promise.resolve(null) : getWebRendererBundle(),
103
- ]);
151
+ // These Bun.build calls happen BEFORE the render shim spawns. On the headless
152
+ // render worker a mp4 job hung here in total silence (no shim ever launched,
153
+ // so the shim's own hard-kill never fired) — bound it so a stuck bundle build
154
+ // FAILS with a named reason instead of eating the whole render budget.
155
+ console.error(`[video] building bundles (format=${format})…`);
156
+ const bundleStart = Date.now();
157
+ const withTimeout = (p: Promise<unknown>, label: string, ms: number) =>
158
+ Promise.race([
159
+ p,
160
+ new Promise((_, reject) =>
161
+ setTimeout(() => reject(new Error(`${label} did not finish within ${ms}ms`)), ms)
162
+ ),
163
+ ]);
164
+ const [lib, renderLib] = (await Promise.all([
165
+ withTimeout(getEncodeLibBundle(), 'encode-lib bundle', 90_000),
166
+ format === 'gif'
167
+ ? Promise.resolve(null)
168
+ : withTimeout(getWebRendererBundle(), 'web-renderer bundle', 90_000),
169
+ ])) as [string, string | null];
170
+ console.error(`[video] bundles ready in ${Date.now() - bundleStart}ms`);
104
171
  const tmp = mkdtempSync(path.join(tmpdir(), 'maude-video-'));
105
172
  const outPath = path.join(tmp, `export.${format}`);
106
173
 
@@ -6,7 +6,7 @@
6
6
 
7
7
  import { createHash } from 'node:crypto';
8
8
  import { existsSync, readFileSync, realpathSync, unlinkSync, watch } from 'node:fs';
9
- import { dirname, join, posix, relative, resolve, sep } from 'node:path';
9
+ import { basename, dirname, join, posix, relative, resolve, sep } from 'node:path';
10
10
  // Ownership mutations live with the CLI on purpose — see /_api/sync/ownership.
11
11
  import { adoptToHub, detachToRepo, ownershipState } from '../../cli/lib/design-ownership.mjs';
12
12
  import {
@@ -39,6 +39,7 @@ import type { Context } from './context.ts';
39
39
  import { reloadConfig } from './context.ts';
40
40
  import { buildDebugBundle } from './debug-bundle.ts';
41
41
  import { probeSetupReadiness } from './design-setup-readiness.ts';
42
+ import { isScopeValidForFormat, scopeRefusalMessage } from './exporters/format-scopes.ts';
42
43
  import { type Format, isFormat, isScope, type Scope } from './exporters/index.ts';
43
44
  import { type ExportJobQueue, ExportQueueFullError } from './exporters/jobs.ts';
44
45
  import type { ActiveJsonShape } from './exporters/scope.ts';
@@ -399,6 +400,19 @@ export const READ_ONLY_ALLOWED_WRITES = new Set([
399
400
  '/_api/export', // "look, comment and download" — the cell allows /api/export too
400
401
  '/_api/export-jobs',
401
402
  '/_api/export-jobs/download',
403
+ // DDR-231 Phase 2 T6 — the browser lane's ledger row. A viewer may already
404
+ // EXPORT (the two entries above), so recording that they did is the same
405
+ // class of write: it touches no project state, only the recent-exports list.
406
+ // Refusing it here while allowing the export produced exactly the reported
407
+ // symptom for viewers — a file downloads and nothing anywhere says so.
408
+ '/_api/export-history',
409
+ // …and the deck's composition half. The member's own browser captured the
410
+ // PNGs; this route only packs bytes they already hold into a .pptx and hands
411
+ // it back — it writes no project state. Missing from this list, a VIEWER's
412
+ // PPTX export captured every artboard and then died on a 403 with the dialog
413
+ // still saying "Capturing 10/10" (found by the T7 export E2E, which runs
414
+ // unauthenticated and therefore as a viewer).
415
+ '/_api/export-assemble',
402
416
  '/_api/report', // bug reports are about Maude, not the project
403
417
  '/_api/report-fallback',
404
418
  // Cloud Phase 27 — COMMENT IS THE ONE WRITE A VIEWER HOLDS. The role matrix
@@ -4292,12 +4306,49 @@ export function createHttp(
4292
4306
  },
4293
4307
 
4294
4308
  '/_api/export-history': async (req: Request) => {
4295
- // Phase 6.5 T10 — read-only recent-exports feed for the dialog's
4296
- // Recent tab. Writes happen as a side-effect of job completion
4297
- // (exporters/jobs.ts persistAndEvict) rather than this handler.
4298
- if (req.method !== 'GET') return new Response('Method not allowed', { status: 405 });
4299
- const history = exportJobs.loadHistory();
4300
- return Response.json({ history }, { headers: { 'Cache-Control': 'no-store' } });
4309
+ // Phase 6.5 T10 — recent-exports feed for the dialog's Recent tab. For a
4310
+ // JOB, the write happens as a side-effect of completion (exporters/
4311
+ // jobs.ts persistAndEvict), not here.
4312
+ //
4313
+ // DDR-231 Phase 2 T6 — the browser lane has no job: the member's own
4314
+ // browser captures the artboard and saves the file, and this process
4315
+ // never sees the bytes. Phase 1 therefore left those exports INVISIBLE —
4316
+ // "v exports dialog nic nevidim" — so the POST exists to record one in
4317
+ // the same ledger. It stores a name and a timestamp, never a payload.
4318
+ if (req.method === 'GET') {
4319
+ const history = exportJobs.loadHistory();
4320
+ return Response.json({ history }, { headers: { 'Cache-Control': 'no-store' } });
4321
+ }
4322
+ if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
4323
+ // Same gates as every other write here: CSRF + DNS-rebinding. This route
4324
+ // is MAIN-ORIGIN only (absent from CANVAS_SAFE_API + the canvas server's
4325
+ // routes map) — the canvas iframe reaches it through the shell bridge.
4326
+ if (!isTrustedRequestHost(req))
4327
+ return new Response('local request required (DNS-rebinding guard)', { status: 403 });
4328
+ if (!sameOriginWrite(req))
4329
+ return new Response('cross-origin write rejected', { status: 403 });
4330
+ const body = await readJson<{ format?: unknown; scope?: unknown; filename?: unknown }>(
4331
+ req,
4332
+ 4 * 1024
4333
+ );
4334
+ if (!body) return new Response('body required', { status: 400 });
4335
+ if (!isFormat(body.format)) return new Response('unknown or missing format', { status: 400 });
4336
+ if (!isScope(body.scope)) return new Response('unknown or missing scope', { status: 400 });
4337
+ // The name is chosen in the browser, where tenant TSX shares the window
4338
+ // (DDR-231 F1) — reduce it to a bare, charset-limited basename before it
4339
+ // reaches the ledger that agents and future sessions read back.
4340
+ const raw = typeof body.filename === 'string' ? body.filename : '';
4341
+ const filename = basename(raw)
4342
+ .replace(/[^A-Za-z0-9._-]/g, '')
4343
+ .slice(0, 255);
4344
+ if (!filename || /^\.+$/.test(filename))
4345
+ return new Response('filename required', { status: 400 });
4346
+ const entry = exportJobs.recordBrowserExport({
4347
+ format: body.format,
4348
+ scope: body.scope,
4349
+ filename,
4350
+ });
4351
+ return Response.json(entry, { status: 201, headers: { 'Cache-Control': 'no-store' } });
4301
4352
  },
4302
4353
 
4303
4354
  '/_api/export': async (req: Request) => {
@@ -4323,6 +4374,14 @@ export function createHttp(
4323
4374
  if (!body) return new Response('body required', { status: 400 });
4324
4375
  if (!isFormat(body.format)) return new Response('unknown or missing format', { status: 400 });
4325
4376
  if (!isScope(body.scope)) return new Response('unknown or missing scope', { status: 400 });
4377
+ // DDR-231 Phase 2 T4 — the PAIR, not just each half. `pdf` + `project-raw`
4378
+ // is two individually-valid values that resolve to a `file-tree` target,
4379
+ // which the render service refuses as `invalid render job` — a refusal
4380
+ // that named neither the field nor the remedy. Reproduced locally against
4381
+ // a real maude-render; see test/export-format-scope-coherence.test.ts.
4382
+ if (!isScopeValidForFormat(body.format, body.scope)) {
4383
+ return new Response(scopeRefusalMessage(body.format, body.scope), { status: 400 });
4384
+ }
4326
4385
  const format = body.format;
4327
4386
  const scope = body.scope;
4328
4387
  try {
@@ -4376,6 +4435,14 @@ export function createHttp(
4376
4435
  if (!body) return new Response('body required', { status: 400 });
4377
4436
  if (!isFormat(body.format)) return new Response('unknown or missing format', { status: 400 });
4378
4437
  if (!isScope(body.scope)) return new Response('unknown or missing scope', { status: 400 });
4438
+ // DDR-231 Phase 2 T4 — the PAIR, not just each half. `pdf` + `project-raw`
4439
+ // is two individually-valid values that resolve to a `file-tree` target,
4440
+ // which the render service refuses as `invalid render job` — a refusal
4441
+ // that named neither the field nor the remedy. Reproduced locally against
4442
+ // a real maude-render; see test/export-format-scope-coherence.test.ts.
4443
+ if (!isScopeValidForFormat(body.format, body.scope)) {
4444
+ return new Response(scopeRefusalMessage(body.format, body.scope), { status: 400 });
4445
+ }
4379
4446
  try {
4380
4447
  const { id, result } = exportJobs.enqueue(
4381
4448
  buildExportArgs(req, { format: body.format, scope: body.scope, options: body.options })
@@ -4425,6 +4492,107 @@ export function createHttp(
4425
4492
  });
4426
4493
  },
4427
4494
 
4495
+ '/_api/export-warmup': async (req: Request) => {
4496
+ // DDR-231 T7 — wake the render service while the member is still picking
4497
+ // export options: the dialog fires this on open (remote lane only), so
4498
+ // by the time a video/PDF job lands the multi-GB Chromium container is
4499
+ // already booting instead of starting cold. Proxies GET /_health — no
4500
+ // job body, no secret in the response, nothing evaluates.
4501
+ if (req.method !== 'GET') return new Response('Method not allowed', { status: 405 });
4502
+ if (!isTrustedRequestHost(req))
4503
+ return new Response('local request required (DNS-rebinding guard)', { status: 403 });
4504
+ // Side-effecting GET (it triggers an outbound fetch that wakes/bills a
4505
+ // container) → gate it like every other side-effecting GET, so a
4506
+ // cross-site page a member visits can't fire it as a keep-warm
4507
+ // amplification (DDR-231 security L2).
4508
+ if (!sameOriginRead(req)) return new Response('cross-origin rejected', { status: 403 });
4509
+ const serviceUrl = process.env.MAUDE_RENDER_URL;
4510
+ if (!serviceUrl) return Response.json({ ok: false, lane: resolveRenderLane() });
4511
+ try {
4512
+ const r = await fetch(`${serviceUrl.replace(/\/+$/, '')}/_health`, {
4513
+ signal: AbortSignal.timeout(8_000),
4514
+ });
4515
+ const body = (await r.json().catch(() => null)) as { ok?: boolean } | null;
4516
+ return Response.json(
4517
+ { ok: Boolean(body?.ok), lane: 'remote' },
4518
+ { headers: { 'Cache-Control': 'no-store' } }
4519
+ );
4520
+ } catch {
4521
+ // A timeout here usually MEANS the container is waking — that is the
4522
+ // point of the ping; report it calmly.
4523
+ return Response.json(
4524
+ { ok: false, waking: true, lane: 'remote' },
4525
+ { headers: { 'Cache-Control': 'no-store' } }
4526
+ );
4527
+ }
4528
+ },
4529
+
4530
+ '/_api/export-assemble': async (req: Request) => {
4531
+ // DDR-231 (hybrid export lanes) — the browser lane's ASSEMBLE half. The
4532
+ // member's browser captured the artboard PNGs (canvas-lib's
4533
+ // export-capture bridge — the only place the pixels exist in a
4534
+ // workspace); this composes them into a .pptx deck with PptxGenJS —
4535
+ // pure JS over pure data, the same containment class as zip: no tenant
4536
+ // TSX evaluates here and no browser enters the image. MAIN-ORIGIN ONLY
4537
+ // (absent from CANVAS_SAFE_API + the canvas-origin routes map) and
4538
+ // CSRF-gated like its jobs siblings.
4539
+ if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
4540
+ if (!isTrustedRequestHost(req))
4541
+ return new Response('local request required (DNS-rebinding guard)', { status: 403 });
4542
+ if (!sameOriginWrite(req))
4543
+ return new Response('cross-origin write rejected', { status: 403 });
4544
+ // Byte cap BEFORE parsing: 50 artboards at 3× are tens of MB; 96 MB sits
4545
+ // under Bun's global maxRequestBodySize (~108 MB) so formData() can't be
4546
+ // pushed past it, and still fits a large deck (PptxGenJS base64-encodes
4547
+ // every image → memory ~3× input). REQUIRE an honest content-length: a
4548
+ // chunked / absent-length body would otherwise skip this fast-reject and
4549
+ // buffer up to Bun's ceiling before the loop below could stop it
4550
+ // (adversarial pass). formData() itself is still Bun-capped as a backstop.
4551
+ const MAX_ASSEMBLE_BYTES = 96 * 1024 * 1024;
4552
+ const declared = Number(req.headers.get('content-length'));
4553
+ if (!Number.isFinite(declared) || declared <= 0)
4554
+ return new Response('content-length required', { status: 411 });
4555
+ if (declared > MAX_ASSEMBLE_BYTES)
4556
+ return new Response('assembly payload too large', { status: 413 });
4557
+ const form = await req.formData().catch(() => null);
4558
+ if (!form) return new Response('multipart form body required', { status: 400 });
4559
+ if (form.get('format') !== 'pptx')
4560
+ return new Response('unsupported assemble format', { status: 400 });
4561
+ const scale = Math.max(1, Math.min(8, Number(form.get('scale')) || 1));
4562
+ const images: Uint8Array[] = [];
4563
+ let total = 0;
4564
+ for (const [key, value] of form.entries()) {
4565
+ if (key !== 'image') continue;
4566
+ // Bun's FormData typings collapse the entry value; runtime-check the
4567
+ // Blob shape instead of trusting the declared union.
4568
+ const file = value as unknown;
4569
+ if (!(file instanceof Blob)) continue;
4570
+ const bytes = new Uint8Array(await file.arrayBuffer());
4571
+ total += bytes.byteLength;
4572
+ if (images.length >= 100 || total > MAX_ASSEMBLE_BYTES)
4573
+ return new Response('assembly payload too large', { status: 413 });
4574
+ images.push(bytes);
4575
+ }
4576
+ if (!images.length) return new Response('no images to assemble', { status: 400 });
4577
+ try {
4578
+ const { assemblePngDeck } = await import('./exporters/pptx.ts');
4579
+ const body = await assemblePngDeck(images, scale);
4580
+ const name = String(form.get('name') || 'export').replace(/[^A-Za-z0-9._-]/g, '_');
4581
+ return new Response(body as unknown as BodyInit, {
4582
+ status: 200,
4583
+ headers: {
4584
+ 'Content-Type':
4585
+ 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
4586
+ 'Content-Disposition': `attachment; filename="${name}.pptx"`,
4587
+ 'Cache-Control': 'no-store',
4588
+ },
4589
+ });
4590
+ } catch (err) {
4591
+ const msg = err instanceof Error ? err.message : String(err);
4592
+ return new Response(`deck assembly failed: ${msg}`, { status: 422 });
4593
+ }
4594
+ },
4595
+
4428
4596
  // feature-ai-media-generation (DDR-16x) — the background AI-media generation
4429
4597
  // job queue. The privileged sibling of /_api/export-jobs: MAIN-ORIGIN ONLY
4430
4598
  // (absent from CANVAS_SAFE_API + startCanvasServer's routes — the untrusted
@@ -123,6 +123,12 @@ export const RUNTIME_PACKAGES = [
123
123
  '@remotion/transitions/flip',
124
124
  '@remotion/transitions/clock-wipe',
125
125
  '@remotion/transitions/none',
126
+ // DDR-231 (hybrid export lanes) — the workspace `browser` lane captures
127
+ // artboards in the MEMBER's browser. canvas-lib's export-capture listener
128
+ // reaches dom-to-svg through a LAZY runtime `import('dom-to-svg')` (the
129
+ // pixi.js pattern), so a canvas that never exports pays zero bundle cost —
130
+ // regression-pinned alongside pixi in test/photo-canvas-bundle.test.ts.
131
+ 'dom-to-svg',
126
132
  ] as const;
127
133
 
128
134
  export type RuntimePackage = (typeof RUNTIME_PACKAGES)[number];
@@ -13,6 +13,7 @@ import { describe, expect, test } from 'bun:test';
13
13
  import { readFileSync } from 'node:fs';
14
14
  import { join } from 'node:path';
15
15
 
16
+ import { CAPTURE_HIDDEN_SELECTORS } from '../exporters/capture-chrome.ts';
16
17
  import { DEV_SERVER_ROOT } from '../paths.ts';
17
18
 
18
19
  const SHELL_HTML_PATH = join(
@@ -56,3 +57,32 @@ describe('_shell.html #canvas-hide-chrome covers every overlay mount class', ()
56
57
  });
57
58
  }
58
59
  });
60
+
61
+ // DDR-231 Phase 2 T2 — the SECOND consumer. The browser lane captures the live
62
+ // canvas (it never navigates to `?hide-chrome=1`), so it applies the same
63
+ // suppression from `exporters/capture-chrome.ts`. Two copies of a selector list
64
+ // drift; this is the tripwire that makes them fail loudly instead. Same shape
65
+ // as the `isMaudeRuntimeState` / `isRuntimeStateRel` agreement pin.
66
+ describe('capture-chrome.ts agrees with the _shell.html block', () => {
67
+ const shellHtml = readFileSync(SHELL_HTML_PATH, 'utf8');
68
+ const block =
69
+ shellHtml.match(/<style id="canvas-hide-chrome"[^>]*>([\s\S]*?)<\/style>/)?.[1] ?? '';
70
+
71
+ /** Selectors declared in the shell's style block, comments stripped. */
72
+ const shellSelectors = block
73
+ .replace(/\/\*[\s\S]*?\*\//g, '')
74
+ .replace(/\{[\s\S]*$/, '')
75
+ .split(',')
76
+ .map((s) => s.trim())
77
+ .filter(Boolean);
78
+
79
+ test('every shell selector is in CAPTURE_HIDDEN_SELECTORS', () => {
80
+ const ours = new Set(CAPTURE_HIDDEN_SELECTORS);
81
+ expect(shellSelectors.filter((s) => !ours.has(s))).toEqual([]);
82
+ });
83
+
84
+ test('every CAPTURE_HIDDEN_SELECTORS entry is in the shell block', () => {
85
+ const theirs = new Set(shellSelectors);
86
+ expect(CAPTURE_HIDDEN_SELECTORS.filter((s) => !theirs.has(s))).toEqual([]);
87
+ });
88
+ });
@@ -0,0 +1,32 @@
1
+ // capture-core.test.ts — pure-logic guards of the shared capture spine
2
+ // (DDR-231). The DOM-dependent half (svgForElement, rasterizeSvg) is exercised
3
+ // end-to-end by the fidelity gate (plan T6) — no DOM emulation here.
4
+
5
+ import { describe, expect, test } from 'bun:test';
6
+
7
+ import {
8
+ assertRasterSizeOk,
9
+ MAX_OUTPUT_BYTES,
10
+ MAX_OUTPUT_SIDE_PX,
11
+ } from '../exporters/capture-core.ts';
12
+
13
+ describe('assertRasterSizeOk — the raster render guard', () => {
14
+ test('a 1440×900 artboard at 2× passes', () => {
15
+ expect(() => assertRasterSizeOk(1440, 900, 2)).not.toThrow();
16
+ });
17
+
18
+ test('a 600dpi A0 poster still fits (the guard ceiling rationale)', () => {
19
+ // 9933×14043 px ≈ 558MB RGBA — deliberately inside the ~600MB ceiling.
20
+ expect(() => assertRasterSizeOk(9933 / 6.25, 14043 / 6.25, 6.25)).not.toThrow();
21
+ });
22
+
23
+ test('exceeding the side ceiling throws with an actionable max-DPI hint', () => {
24
+ expect(() => assertRasterSizeOk(MAX_OUTPUT_SIDE_PX, 900, 2)).toThrow(/max supported DPI/);
25
+ });
26
+
27
+ test('exceeding the byte ceiling throws even when both sides fit', () => {
28
+ // 15000×15000 × 4B = 900MB > ceiling while each side < 16000.
29
+ expect(15000 * 15000 * 4).toBeGreaterThan(MAX_OUTPUT_BYTES);
30
+ expect(() => assertRasterSizeOk(15000, 15000, 1)).toThrow(/render guard/);
31
+ });
32
+ });
@@ -0,0 +1,53 @@
1
+ // export-assemble.test.ts — DDR-231: the in-cell half of the pptx browser
2
+ // lane. assemblePngDeck composes client-captured PNG bytes into a deck with
3
+ // PptxGenJS — pure JS over pure data (the zip containment class), so it must
4
+ // work with no browser, no filesystem inputs, no tenant checkout.
5
+
6
+ import { describe, expect, test } from 'bun:test';
7
+
8
+ import { assemblePngDeck } from '../exporters/pptx.ts';
9
+
10
+ // 1×1 transparent PNG.
11
+ const PNG_1PX = Uint8Array.from(
12
+ atob(
13
+ 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=='
14
+ ),
15
+ (c) => c.charCodeAt(0)
16
+ );
17
+
18
+ describe('assemblePngDeck — bytes in, deck out', () => {
19
+ test('two PNGs become a two-slide .pptx (zip container with per-slide parts)', async () => {
20
+ const body = await assemblePngDeck([PNG_1PX, PNG_1PX], 3);
21
+ // .pptx IS a zip — PK\x03\x04 magic.
22
+ expect(body[0]).toBe(0x50);
23
+ expect(body[1]).toBe(0x4b);
24
+ const JSZip = (await import('jszip')).default;
25
+ const zip = await JSZip.loadAsync(body);
26
+ expect(zip.file('ppt/slides/slide1.xml')).toBeTruthy();
27
+ expect(zip.file('ppt/slides/slide2.xml')).toBeTruthy();
28
+ expect(zip.file('ppt/slides/slide3.xml')).toBeFalsy();
29
+ });
30
+
31
+ test('non-PNG bytes are refused, not composed into a corrupt deck', async () => {
32
+ await expect(assemblePngDeck([new TextEncoder().encode('<svg/>')], 3)).rejects.toThrow(
33
+ /not a PNG/
34
+ );
35
+ });
36
+
37
+ test('a \\x89PNG prefix on non-PNG bytes is caught by the full 8-byte signature (L3)', async () => {
38
+ // First 4 bytes match the old check, bytes 4-7 do not.
39
+ const fake = new Uint8Array(24);
40
+ fake.set([0x89, 0x50, 0x4e, 0x47, 0x00, 0x00, 0x00, 0x00], 0);
41
+ await expect(assemblePngDeck([fake], 3)).rejects.toThrow(/not a PNG/);
42
+ });
43
+
44
+ test('an implausible IHDR dimension is refused, not fed to the geometry math (F3)', async () => {
45
+ // Valid 8-byte sig + IHDR claiming 0x7fffffff × 0x7fffffff.
46
+ const bomb = new Uint8Array(24);
47
+ bomb.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a], 0);
48
+ const dv = new DataView(bomb.buffer);
49
+ dv.setUint32(16, 0x7fffffff);
50
+ dv.setUint32(20, 0x7fffffff);
51
+ await expect(assemblePngDeck([bomb], 3)).rejects.toThrow(/implausible PNG dimensions/);
52
+ });
53
+ });