@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
@@ -0,0 +1,88 @@
1
+ // format-scopes.ts — which export scopes a format can actually render.
2
+ //
3
+ // DDR-231 Phase 2 T4. This map existed in THREE places and was checked in
4
+ // none of them: `client/app.jsx` (`EXPORT_VALID_SCOPES`, the shell dialog),
5
+ // `export-dialog.tsx` (`VALID_SCOPES_PER_FORMAT`, the in-canvas dialog) — and
6
+ // nowhere on the server, which accepted whatever pair a client sent.
7
+ //
8
+ // That gap is the "PDF nefunguje vubec — invalid render job" the first live
9
+ // cloud export hit, reproduced locally: `POST /_api/export-jobs
10
+ // {format:'pdf', scope:'project-raw'}` is accepted, `resolveScope` returns a
11
+ // `file-tree` target (correct for that scope — it IS the whole project), and
12
+ // the render service's `validBody` rejects it with an opaque `invalid render
13
+ // job` because a file-tree target names repo paths that process must never
14
+ // read. Every layer behaved as specified; the pair was never legal, and
15
+ // nothing said so.
16
+ //
17
+ // The two dialogs keep the scope pickers honest for a user who is looking;
18
+ // this module is what makes the pair unrepresentable for one who is not (a
19
+ // stale scope carried across a format switch, a re-run of a history entry, a
20
+ // context-menu hint, a direct POST).
21
+ //
22
+ // PURE DATA, NO IMPORTS — `http.ts` (Bun), `apps/render/server.ts` (Bun) and
23
+ // both browser bundles all consume it, so it must not reach for `node:*`.
24
+
25
+ /** Every format the export pipeline knows. Mirrors `index.ts`'s `Format`. */
26
+ export type ExportFormatName =
27
+ | 'png'
28
+ | 'pdf'
29
+ | 'svg'
30
+ | 'html'
31
+ | 'pptx'
32
+ | 'canva'
33
+ | 'zip'
34
+ | 'mp4'
35
+ | 'webm'
36
+ | 'gif';
37
+
38
+ /** Mirrors `scope.ts`'s `Scope`. */
39
+ export type ExportScopeName = 'selection' | 'artboard' | 'canvas-as-separate' | 'project-raw';
40
+
41
+ /**
42
+ * Scopes each format accepts, in the order a picker should offer them — the
43
+ * FIRST entry is that format's default.
44
+ *
45
+ * The shape rules behind the table:
46
+ * - `project-raw` resolves to a `file-tree` target, which only `zip`
47
+ * consumes; every rendering format must therefore exclude it.
48
+ * - `pptx` is a deck: one slide per artboard, so canvas-wide only.
49
+ * - video renders one temporal artboard, so `artboard` only.
50
+ * - `html` has no meaningful "selection" unit (it emits a page per artboard).
51
+ */
52
+ export const VALID_SCOPES_BY_FORMAT: Readonly<
53
+ Record<ExportFormatName, readonly ExportScopeName[]>
54
+ > = Object.freeze({
55
+ png: ['selection', 'artboard', 'canvas-as-separate'],
56
+ pdf: ['selection', 'artboard', 'canvas-as-separate'],
57
+ svg: ['selection', 'artboard', 'canvas-as-separate'],
58
+ html: ['artboard', 'canvas-as-separate'],
59
+ pptx: ['canvas-as-separate'],
60
+ canva: ['canvas-as-separate'],
61
+ zip: ['project-raw'],
62
+ mp4: ['artboard'],
63
+ webm: ['artboard'],
64
+ gif: ['artboard'],
65
+ });
66
+
67
+ /** The scope a picker should select when the format changes. */
68
+ export function defaultScopeForFormat(format: string): ExportScopeName {
69
+ return VALID_SCOPES_BY_FORMAT[format as ExportFormatName]?.[0] ?? 'artboard';
70
+ }
71
+
72
+ export function validScopesForFormat(format: string): readonly ExportScopeName[] {
73
+ return VALID_SCOPES_BY_FORMAT[format as ExportFormatName] ?? ['artboard'];
74
+ }
75
+
76
+ export function isScopeValidForFormat(format: string, scope: string): boolean {
77
+ return validScopesForFormat(format).includes(scope as ExportScopeName);
78
+ }
79
+
80
+ /**
81
+ * The refusal a caller sees for an incoherent pair. Written for a person and
82
+ * naming the way out, because it surfaces in the export dialog — the old
83
+ * `invalid render job` named neither the field nor the remedy.
84
+ */
85
+ export function scopeRefusalMessage(format: string, scope: string): string {
86
+ const offered = validScopesForFormat(format).join(', ');
87
+ return `${format.toUpperCase()} can't export the "${scope}" scope — it supports: ${offered}.`;
88
+ }
@@ -1,8 +1,9 @@
1
1
  // Phase 6.5 T5 — HTML adapter (standalone bundler).
2
2
  //
3
3
  // Renders the target through Playwright, emits a self-contained `index.html`
4
- // with all stylesheets inlined (fonts + remote images still referenced by
5
- // origin — full asset inlining is a follow-up). Output is always a ZIP per
4
+ // with all stylesheets inlined and every asset (images, fonts, CSS url()s,
5
+ // SVG defs) embedded as data: URIs — see bin/_html-playwright.mjs. Output is
6
+ // always a ZIP per
6
7
  // plan T5 ("Always zipped (multi-file)"), even for a single artboard,
7
8
  // because the consuming workflow typically wants one bundle per export.
8
9
 
@@ -23,9 +23,10 @@
23
23
  // pair had). The ledger is seeded from disk once at construction so history
24
24
  // survives a server restart even though job state itself doesn't.
25
25
 
26
- import { readFileSync } from 'node:fs';
26
+ import { readFileSync, realpathSync } from 'node:fs';
27
27
  import { mkdir, rm } from 'node:fs/promises';
28
28
  import path from 'node:path';
29
+ import { readAllArtboardPrintProps } from '../canvas-edit.ts';
29
30
 
30
31
  import type { Bus } from '../context.ts';
31
32
  import { resolveRenderLane } from '../workspace-mode.ts';
@@ -47,7 +48,8 @@ import {
47
48
  renderRemotely,
48
49
  resolveRenderService,
49
50
  } from './remote.ts';
50
- import { type ResolveScopeArgs, resolveScope } from './scope.ts';
51
+ import { type ResolveScopeArgs, resolveScope, type Target } from './scope.ts';
52
+ import { scanUnsupportedMedia } from './unsupported-media.ts';
51
53
 
52
54
  /** Extended shape of the old Phase 6.5 T10 history entry — additive fields only. */
53
55
  export interface ExportHistoryEntry {
@@ -63,6 +65,13 @@ export interface ExportHistoryEntry {
63
65
  error?: string;
64
66
  /** Present when the export produced less than was asked for (e.g. muted mp4). */
65
67
  degraded?: ExportDegradation;
68
+ /**
69
+ * DDR-231 Phase 2 T6 — the browser lane captured and saved this export in
70
+ * the member's OWN browser; the cell holds no bytes for it. Present so the
71
+ * Recent tab and the notification center can list it without offering a
72
+ * download that would 404.
73
+ */
74
+ deliveredInBrowser?: boolean;
66
75
  }
67
76
 
68
77
  export type ExportJobStatus = 'queued' | 'running' | 'done' | 'failed';
@@ -87,6 +96,9 @@ export interface ExportJob {
87
96
  * RCA `issue-mp4-audio-export-html5audio-silent-degrade`.
88
97
  */
89
98
  degraded?: ExportDegradation;
99
+ /** DDR-231 browser lane — produced and saved in the member's own browser;
100
+ * this process stores no bytes for it. See {@link ExportHistoryEntry}. */
101
+ deliveredInBrowser?: boolean;
90
102
  }
91
103
 
92
104
  export interface EnqueueArgs {
@@ -111,6 +123,12 @@ export type DownloadResult =
111
123
 
112
124
  export interface ExportJobQueue {
113
125
  enqueue(args: EnqueueArgs): { id: string; result: Promise<ExportResult> };
126
+ /**
127
+ * Record an export the MEMBER'S BROWSER produced and saved (DDR-231 browser
128
+ * lane) so it appears in the same ledger every other export does. No bytes
129
+ * are kept — the file never passed through this process.
130
+ */
131
+ recordBrowserExport(args: { format: Format; scope: Scope; filename: string }): ExportHistoryEntry;
114
132
  get(id: string): ExportJob | undefined;
115
133
  list(): ExportJob[];
116
134
  loadHistory(): ExportHistoryEntry[];
@@ -211,6 +229,75 @@ function isFinished(job: ExportJob): boolean {
211
229
  return job.status === 'done' || job.status === 'failed';
212
230
  }
213
231
 
232
+ /**
233
+ * Resolve a client-influenced canvas `file` to an absolute path INSIDE the
234
+ * design root, following symlinks (security review F2). `file` originates from
235
+ * `options.canvasFile`/`selection.file` — a viewer picks it — so the floor is
236
+ * the DESIGN root (not the whole checkout: no `.git`/`.env`/sibling files), the
237
+ * containment is re-checked AFTER `realpathSync` (a `startsWith` string prefix
238
+ * alone is escaped by an in-checkout symlink), and only `.tsx`/`.html` canvas
239
+ * files are eligible. Returns null on any failure — the reader then contributes
240
+ * nothing, exactly as an unreadable canvas does.
241
+ */
242
+ function safeCanvasAbs(
243
+ repoRoot: string | undefined,
244
+ designRoot: string | undefined,
245
+ file: string
246
+ ): string | null {
247
+ if (!repoRoot || !designRoot) return null;
248
+ if (!/\.(tsx|html)$/i.test(file)) return null;
249
+ let designReal: string;
250
+ let abs: string;
251
+ try {
252
+ designReal = realpathSync(path.resolve(designRoot));
253
+ // `file` is REPO-relative (it carries the `.design/` prefix) — resolve
254
+ // against repoRoot, then require the realpath sits under the DESIGN root.
255
+ abs = realpathSync(path.resolve(repoRoot, file));
256
+ } catch {
257
+ return null;
258
+ }
259
+ return abs === designReal || abs.startsWith(designReal + path.sep) ? abs : null;
260
+ }
261
+
262
+ /** The cell-side `scanUnsupportedMedia` for a remote video job — the first
263
+ * element target's canvas (video renders one artboard). */
264
+ function readUnsupportedMediaFor(
265
+ targets: Target[],
266
+ repoRoot: string | undefined,
267
+ designRoot: string | undefined
268
+ ) {
269
+ const t = targets.find((x) => x.kind === 'element');
270
+ if (!t || t.kind !== 'element') return [];
271
+ const abs = safeCanvasAbs(repoRoot, designRoot, t.file);
272
+ return abs ? scanUnsupportedMedia(abs) : [];
273
+ }
274
+
275
+ /**
276
+ * `{ [repoRelativeCanvasFile]: { [artboardId]: print } }` for every canvas an
277
+ * element target names — what exporters/pdf.ts reads as `options.printProps`
278
+ * when it renders on the worker. Read from the cell's own checkout; a canvas
279
+ * that won't parse or has no print artboard contributes nothing.
280
+ */
281
+ function readPrintPropsFor(
282
+ targets: Target[],
283
+ repoRoot: string | undefined,
284
+ designRoot: string | undefined
285
+ ): Record<string, Record<string, Record<string, unknown>>> {
286
+ const out: Record<string, Record<string, Record<string, unknown>>> = {};
287
+ for (const t of targets) {
288
+ if (t.kind !== 'element' || out[t.file]) continue;
289
+ const abs = safeCanvasAbs(repoRoot, designRoot, t.file);
290
+ if (!abs) continue;
291
+ try {
292
+ const props = readAllArtboardPrintProps(abs, readFileSync(abs, 'utf8'));
293
+ if (Object.keys(props).length) out[t.file] = props;
294
+ } catch {
295
+ /* unreadable canvas — the adapter treats it as non-print, same as local */
296
+ }
297
+ }
298
+ return out;
299
+ }
300
+
214
301
  export function createExportJobQueue(bus: Bus, designRoot: string): ExportJobQueue {
215
302
  const jobsDir = path.join(designRoot, '_export-jobs');
216
303
  const historyPath = path.join(designRoot, '_export-history.json');
@@ -283,6 +370,7 @@ export function createExportJobQueue(bus: Bus, designRoot: string): ExportJobQue
283
370
  // A muted mp4 must not look like a clean one in the ledger either — the
284
371
  // history entry is what a later session (or an agent) reads back.
285
372
  degraded: j.degraded,
373
+ deliveredInBrowser: j.deliveredInBrowser,
286
374
  }));
287
375
  }
288
376
 
@@ -290,21 +378,36 @@ export function createExportJobQueue(bus: Bus, designRoot: string): ExportJobQue
290
378
  const history = deriveHistory();
291
379
  await Bun.write(historyPath, JSON.stringify(history, null, 2));
292
380
 
293
- // Evict bytes (+ the in-memory record) for anything that rolled past the
294
- // cap, or aged out, whichever comes first.
295
- const finished = Array.from(jobs.values())
296
- .filter(isFinished)
297
- .sort((a, b) => (b.finishedAt ?? '').localeCompare(a.finishedAt ?? ''));
298
381
  const now = Date.now();
299
- const stale = finished.filter((j, i) => {
382
+
383
+ // BYTE eviction is ranked among jobs that HAVE bytes on disk — never driven
384
+ // by browser-lane rows. A `deliveredInBrowser` row (recordBrowserExport)
385
+ // holds no bytes and is reachable by a viewer, so counting it toward the
386
+ // FIFO-`HISTORY_DEPTH` window would let a viewer flood fake rows and push a
387
+ // real member's not-yet-downloaded PDF/video past the cap — deleting its
388
+ // bytes (security review F2: an append that was secretly a delete
389
+ // primitive). The two concerns are now separate: the ledger above is a
390
+ // display list; this is GC of real artifacts only.
391
+ const withBytes = Array.from(jobs.values())
392
+ .filter((j) => isFinished(j) && !j.deliveredInBrowser && j.filename)
393
+ .sort((a, b) => (b.finishedAt ?? '').localeCompare(a.finishedAt ?? ''));
394
+ const staleBytes = withBytes.filter((j, i) => {
300
395
  if (i >= HISTORY_DEPTH) return true;
301
396
  const finishedAt = j.finishedAt ? Date.parse(j.finishedAt) : Number.NaN;
302
397
  return Number.isFinite(finishedAt) && now - finishedAt > MAX_JOB_AGE_MS;
303
398
  });
304
- for (const job of stale) {
399
+ for (const job of staleBytes) {
305
400
  jobs.delete(job.id);
306
401
  await rm(path.join(jobsDir, job.id), { recursive: true, force: true }).catch(() => {});
307
402
  }
403
+
404
+ // In-memory record eviction for the byte-free rows (browser-lane +
405
+ // aged/over-cap finished jobs whose bytes are already gone) — bound the Map
406
+ // so the ledger's own history depth caps memory. No disk to remove.
407
+ const byteless = Array.from(jobs.values())
408
+ .filter((j) => isFinished(j) && (j.deliveredInBrowser || !j.filename))
409
+ .sort((a, b) => (b.finishedAt ?? '').localeCompare(a.finishedAt ?? ''));
410
+ for (const job of byteless.slice(HISTORY_DEPTH)) jobs.delete(job.id);
308
411
  }
309
412
 
310
413
  function enqueue(args: EnqueueArgs): { id: string; result: Promise<ExportResult> } {
@@ -336,6 +439,9 @@ export function createExportJobQueue(bus: Bus, designRoot: string): ExportJobQue
336
439
  // exercise the lanes without a reboot.
337
440
  const lane = resolveRenderLane();
338
441
  const dispatchRemote = lane !== 'local' && formatNeedsBrowser(args.format);
442
+ console.error(
443
+ `[jobs] ${args.format}: lane=${lane} dispatchRemote=${dispatchRemote} (renderUrl=${process.env.MAUDE_RENDER_URL ? 'set' : 'unset'})`
444
+ );
339
445
 
340
446
  const result = (async (): Promise<ExportResult> => {
341
447
  // Fail BEFORE taking a render slot: a job that can never render must not
@@ -367,18 +473,42 @@ export function createExportJobQueue(bus: Bus, designRoot: string): ExportJobQue
367
473
  job.startedAt = new Date().toISOString();
368
474
  emit(job);
369
475
 
476
+ // Scope resolves HERE, against the cell's own checkout — Target is
477
+ // pure data, and the render service holds no tenant store to resolve
478
+ // against (DDR-230 §1).
479
+ const remoteTargets = dispatchRemote
480
+ ? await resolveScope({ scope: args.scope, ...args.resolve, options: args.options })
481
+ : [];
370
482
  const res = dispatchRemote
371
483
  ? await renderRemotely({
372
484
  format: args.format,
373
- // Scope resolves HERE, against the cell's own checkout — Target
374
- // is pure data, and the render service holds no tenant store to
375
- // resolve against (DDR-230 §1).
376
- targets: await resolveScope({
377
- scope: args.scope,
378
- ...args.resolve,
379
- options: args.options,
380
- }),
381
- options: args.options,
485
+ targets: remoteTargets,
486
+ // Same rule for anything ELSE the adapter would read off disk: a
487
+ // print artboard's `print` prop (bleed, paper) lives in the canvas
488
+ // source, which only this process has. Resolve it here and ship
489
+ // it as data, or the worker's PDF silently loses its boxes/marks.
490
+ options:
491
+ args.format === 'pdf'
492
+ ? {
493
+ ...args.options,
494
+ printProps: readPrintPropsFor(
495
+ remoteTargets,
496
+ args.resolve.repoRoot,
497
+ args.resolve.designRoot
498
+ ),
499
+ }
500
+ : VIDEO_FORMATS.has(args.format)
501
+ ? {
502
+ ...args.options,
503
+ // The <Audio>/<OffthreadVideo> pre-flight reads the canvas
504
+ // source — same "only the cell has the checkout" rule.
505
+ unsupportedMedia: readUnsupportedMediaFor(
506
+ remoteTargets,
507
+ args.resolve.repoRoot,
508
+ args.resolve.designRoot
509
+ ),
510
+ }
511
+ : args.options,
382
512
  canvas: args.remoteCanvas ?? { origin: '' },
383
513
  // resolveRenderService() is non-null on the `remote` lane by
384
514
  // construction (the lane IS the presence of MAUDE_RENDER_URL).
@@ -438,6 +568,38 @@ export function createExportJobQueue(bus: Bus, designRoot: string): ExportJobQue
438
568
  enqueue,
439
569
  get: (id) => jobs.get(id),
440
570
  list: () => Array.from(jobs.values()).sort((a, b) => b.createdAt.localeCompare(a.createdAt)),
571
+ recordBrowserExport({ format, scope, filename }) {
572
+ const now = new Date().toISOString();
573
+ const job: ExportJob = {
574
+ id: crypto.randomUUID(),
575
+ format,
576
+ scope,
577
+ options: {},
578
+ status: 'done',
579
+ createdAt: now,
580
+ startedAt: now,
581
+ finishedAt: now,
582
+ filename,
583
+ deliveredInBrowser: true,
584
+ };
585
+ jobs.set(job.id, job);
586
+ emit(job);
587
+ // Fire-and-forget: the member already HAS the file, so a slow ledger
588
+ // write must not gate their UI, and a failed one must not fail an export
589
+ // that already succeeded.
590
+ void persistAndEvict().catch(() => {});
591
+ return {
592
+ id: job.id,
593
+ format,
594
+ scope,
595
+ filename,
596
+ at: now,
597
+ status: 'done',
598
+ startedAt: now,
599
+ finishedAt: now,
600
+ deliveredInBrowser: true,
601
+ };
602
+ },
441
603
  loadHistory: deriveHistory,
442
604
  async getBytes(id) {
443
605
  const job = jobs.get(id);
@@ -65,6 +65,20 @@ export function artboardIdFromCssPath(cssPath: string): string | null {
65
65
  return m ? (m[1] as string) : null;
66
66
  }
67
67
 
68
+ /**
69
+ * `options.printProps` — `{ [repoRelativeCanvasFile]: { [artboardId]: print } }`,
70
+ * attached by the cell when the job dispatches to the render worker
71
+ * (exporters/jobs.ts). Shape-checked here because it crosses a trust boundary
72
+ * (the job body reaches the worker over HTTP); anything else is ignored.
73
+ */
74
+ function shippedPrintProps(
75
+ options: ExportOptions
76
+ ): Record<string, Record<string, Record<string, unknown>>> | null {
77
+ const raw = (options as { printProps?: unknown }).printProps;
78
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
79
+ return raw as Record<string, Record<string, Record<string, unknown>>>;
80
+ }
81
+
68
82
  /** Resolve a client-supplied `sourceFile` (from `options.selection.file`, a
69
83
  * main-origin but caller-controlled string — see scope.ts's `readHints`)
70
84
  * against `repoRoot`, rejecting anything that escapes it — mirrors
@@ -423,7 +437,13 @@ export async function run(
423
437
  const pageIndex = out.getPageCount();
424
438
  out.addPage(page);
425
439
  let printProp: Record<string, unknown> | null = null;
426
- if (w.artboardId) {
440
+ // The cell ships the print props inside a REMOTE job (it has the
441
+ // checkout; this process may not — DDR-230). Prefer them; fall back to
442
+ // reading the source on disk for the local lane.
443
+ const shipped = shippedPrintProps(options);
444
+ if (w.artboardId && shipped) {
445
+ printProp = shipped[w.sourceFile]?.[w.artboardId] ?? null;
446
+ } else if (w.artboardId) {
427
447
  const abs = resolveSourceFileUnderRoot(ctx.repoRoot, w.sourceFile);
428
448
  if (abs) {
429
449
  let text = sourceCache.get(abs);
@@ -269,30 +269,58 @@ function pngArgsFor(target: ElementTarget, url: string, outDir: string): string[
269
269
 
270
270
  // ─── PNG fallback deck ────────────────────────────────────────────────────────
271
271
 
272
- async function buildPngDeck(pngPaths: string[]): Promise<Uint8Array> {
272
+ /**
273
+ * DDR-231 (hybrid export lanes) — the browser-free half of the PNG deck,
274
+ * working on BYTES so the workspace assemble route (`/_api/export-assemble`)
275
+ * can feed it captures made in the MEMBER's browser: the client's
276
+ * export-capture bridge rasterizes the artboards, this composes the .pptx
277
+ * in-cell with PptxGenJS (pure JS — same containment class as zip). The
278
+ * desktop path below ({@link buildPngDeck}) is a thin file-reading wrapper,
279
+ * so both hosts share one composition (the single-spine rule).
280
+ *
281
+ * @param captureScale the deviceScale the PNGs were rendered at — slide
282
+ * dimensions normalise back to artboard inches through it.
283
+ */
284
+ export async function assemblePngDeck(
285
+ images: Uint8Array[],
286
+ captureScale: number
287
+ ): Promise<Uint8Array> {
273
288
  const JSZip = (await import('jszip')).default;
274
- // Slide size from the first PNG (px → inches); contain-fit the rest.
275
- const dims = pngPaths.map((p) => {
289
+ // The full 8-byte PNG signature (security L3 — 4 bytes let a `\x89PNG…`
290
+ // prefix on non-PNG bytes through) and a sane dimension ceiling (security
291
+ // F3 — the images arrive from the untrusted client; an IHDR claiming
292
+ // 2^31×2^31 would drive PptxGenJS's EMU geometry into overflow/garbage).
293
+ const PNG_SIG = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
294
+ const MAX_DIM = 20000; // px — above any real artboard × 8× export scale
295
+ // Slide size from the largest PNG (px → inches); contain-fit the rest.
296
+ const dims = images.map((bytes) => {
276
297
  // PNG IHDR: width @ byte 16, height @ 20 (big-endian).
277
- const b = readFileSync(p);
278
- return { w: b.readUInt32BE(16), h: b.readUInt32BE(20), p };
298
+ const b = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
299
+ if (b.length < 24 || PNG_SIG.some((sig, i) => b[i] !== sig)) {
300
+ throw new Error('assemblePngDeck: not a PNG');
301
+ }
302
+ const w = b.readUInt32BE(16);
303
+ const h = b.readUInt32BE(20);
304
+ if (w < 1 || h < 1 || w > MAX_DIM || h > MAX_DIM) {
305
+ throw new Error(`assemblePngDeck: implausible PNG dimensions ${w}×${h}`);
306
+ }
307
+ return { w, h, b };
279
308
  });
280
309
  const maxW = Math.max(...dims.map((d) => d.w));
281
310
  const maxH = Math.max(...dims.map((d) => d.h));
282
- // The PNGs are FALLBACK_SCALE× the artboard; normalise to artboard inches.
283
- const slideW = maxW / SCREEN_DPI / FALLBACK_SCALE;
284
- const slideH = maxH / SCREEN_DPI / FALLBACK_SCALE;
311
+ const slideW = maxW / SCREEN_DPI / captureScale;
312
+ const slideH = maxH / SCREEN_DPI / captureScale;
285
313
  const pptx = new PptxGenJS();
286
314
  pptx.defineLayout({ name: 'MAUDE_ARTBOARD', width: slideW, height: slideH });
287
315
  pptx.layout = 'MAUDE_ARTBOARD';
288
316
  for (const d of dims) {
289
317
  const slide = pptx.addSlide();
290
- const nativeW = d.w / SCREEN_DPI / FALLBACK_SCALE;
291
- const nativeH = d.h / SCREEN_DPI / FALLBACK_SCALE;
318
+ const nativeW = d.w / SCREEN_DPI / captureScale;
319
+ const nativeH = d.h / SCREEN_DPI / captureScale;
292
320
  const scale = Math.min(slideW / nativeW, slideH / nativeH);
293
321
  const w = nativeW * scale;
294
322
  const h = nativeH * scale;
295
- const dataUri = `data:image/png;base64,${readFileSync(d.p).toString('base64')}`;
323
+ const dataUri = `data:image/png;base64,${d.b.toString('base64')}`;
296
324
  slide.addImage({ data: dataUri, x: (slideW - w) / 2, y: (slideH - h) / 2, w, h });
297
325
  }
298
326
  const buf = new Uint8Array((await pptx.write({ outputType: 'nodebuffer' })) as Uint8Array);
@@ -310,6 +338,13 @@ async function buildPngDeck(pngPaths: string[]): Promise<Uint8Array> {
310
338
  return zip.generateAsync({ type: 'uint8array' });
311
339
  }
312
340
 
341
+ async function buildPngDeck(pngPaths: string[]): Promise<Uint8Array> {
342
+ return assemblePngDeck(
343
+ pngPaths.map((p) => new Uint8Array(readFileSync(p))),
344
+ FALLBACK_SCALE
345
+ );
346
+ }
347
+
313
348
  export async function run(
314
349
  targets: Target[],
315
350
  options: ExportOptions,
@@ -149,6 +149,41 @@ export class NoRenderServiceError extends Error {
149
149
  * artifact as a normal `ExportResult`. Degradation travels back as a JSON
150
150
  * header rather than a wrapped body so the artifact bytes stream unmodified.
151
151
  */
152
+ /**
153
+ * Keys a render adapter legitimately consumes (grepped from `exporters/*.ts`
154
+ * `options.*` reads). Scope hints (`canvasFile`/`artboardId`/`selection`) are
155
+ * deliberately absent — the cell resolves scope into `targets` before dispatch,
156
+ * so the worker never needs them. `printProps`/`unsupportedMedia` are re-set by
157
+ * the cell AFTER this pick (jobs.ts), so they pass through here too.
158
+ */
159
+ const REMOTE_OPTION_KEYS = new Set([
160
+ 'scale',
161
+ 'dpi',
162
+ 'mode',
163
+ 'raster',
164
+ 'pageFit',
165
+ 'pdfPrint',
166
+ 'audio',
167
+ 'allowUnsupportedMedia',
168
+ 'timeoutSec',
169
+ 'durationMs',
170
+ 'fps',
171
+ 'frameFormat',
172
+ 'frames',
173
+ 'gifColors',
174
+ 'maxFrames',
175
+ 'printProps',
176
+ 'unsupportedMedia',
177
+ ]);
178
+
179
+ function pickRemoteOptions(options: ExportOptions): ExportOptions {
180
+ const out: ExportOptions = {};
181
+ for (const [k, v] of Object.entries(options)) {
182
+ if (REMOTE_OPTION_KEYS.has(k)) out[k] = v;
183
+ }
184
+ return out;
185
+ }
186
+
152
187
  export async function renderRemotely(args: {
153
188
  format: Format;
154
189
  targets: Target[];
@@ -165,21 +200,60 @@ export async function renderRemotely(args: {
165
200
  // sandbox on first access, so `_canvas-shell.html` can take far longer than
166
201
  // 8s to fire `load`. Give the remote path a generous floor (unless the
167
202
  // caller asked for more) so a cold-cell first render doesn't time out.
203
+ // ALLOWLIST what crosses to the worker (security review F3). `options` is a
204
+ // free-form bag the requesting member (a viewer, in a workspace) fills, and
205
+ // the worker is the fleet's least-trusted process (DDR-230) shared across
206
+ // tenants. Forward ONLY the keys the render adapters actually read — an
207
+ // unknown attacker key never reaches worker code, and the scope-only hints
208
+ // (canvasFile / artboardId / selection) that were already consumed cell-side
209
+ // to build `targets` don't leak the canvas path onward.
168
210
  const remoteOptions: ExportOptions = {
169
- ...options,
211
+ ...pickRemoteOptions(options),
170
212
  timeoutSec: Math.max(Number((options as { timeoutSec?: number }).timeoutSec) || 0, 60),
171
213
  };
172
- const res = await fetch(`${service.url}/render`, {
173
- method: 'POST',
174
- signal,
175
- headers: {
176
- 'content-type': 'application/json',
177
- authorization: `Bearer ${service.secret}`,
178
- },
179
- body: JSON.stringify({ format, targets, options: remoteOptions, canvas }),
180
- });
214
+ let res: Response;
215
+ try {
216
+ // Bun's `fetch` has its OWN default ceiling — 300 s — independent of the
217
+ // signal, and it rejects with a plain TimeoutError that the catch below
218
+ // read as "the service didn't answer". Every video job longer than five
219
+ // minutes failed that way (measured: 300 s exactly; `timeout: false`
220
+ // lifts it). The JOB timeout (exporters/jobs.ts jobTimeoutMs, sized to
221
+ // the frame count) is the governor here, through `signal`; the fetch
222
+ // must not carry a second, shorter one.
223
+ // `timeout` is a Bun extension the bundled RequestInit types don't declare
224
+ // (bun 1.3.3); the runtime honours it — see the measurement above.
225
+ const init: RequestInit & { timeout: false } = {
226
+ method: 'POST',
227
+ signal,
228
+ timeout: false,
229
+ headers: {
230
+ 'content-type': 'application/json',
231
+ authorization: `Bearer ${service.secret}`,
232
+ },
233
+ body: JSON.stringify({ format, targets, options: remoteOptions, canvas }),
234
+ };
235
+ const postStart = Date.now();
236
+ console.error(`[remote] POST ${format} → ${service.url}/render`);
237
+ res = await fetch(`${service.url}/render`, init);
238
+ console.error(`[remote] ${format} responded ${res.status} in ${Date.now() - postStart}ms`);
239
+ } catch (err) {
240
+ if (signal?.aborted) throw err;
241
+ // DDR-231 T7 — the member sees this in the export dialog / notification
242
+ // center: name the LIKELY cause (a sleeping container waking) instead of
243
+ // leaking a bare fetch error.
244
+ throw new Error(
245
+ 'The render service didn’t answer — it may still be waking up. Try the export again in a minute.'
246
+ );
247
+ }
248
+ if (res.status === 503) {
249
+ throw new Error('The render service is busy with other exports right now — try again shortly.');
250
+ }
181
251
  if (!res.ok) {
182
252
  const detail = (await res.text().catch(() => '')) || `${res.status}`;
253
+ // Surface the worker's failure reason on the STUDIO side — the worker's own
254
+ // stderr buffers under render load and is lost when the test kills it, so
255
+ // this is the only reliable window into WHY a render 500'd.
256
+ console.error(`[remote] ${format} FAILED ${res.status}: ${detail.slice(0, 800)}`);
183
257
  throw new Error(`render service refused the job: ${detail}`);
184
258
  }
185
259
  // Sanitize the filename BEFORE it can reach `Bun.write` in jobs.ts — the
@@ -191,7 +265,16 @@ export async function renderRemotely(args: {
191
265
  const degradedHeader = res.headers.get('x-maude-degraded');
192
266
  if (degradedHeader) {
193
267
  try {
194
- degraded = JSON.parse(degradedHeader) as ExportDegradation;
268
+ // base64 on the wire — the degradation reason is free text (it can carry
269
+ // an em-dash, quotes, any Unicode from a renderer error), and a raw JSON
270
+ // header value is restricted to Latin-1 with no control chars: a `—` in
271
+ // the reason made the worker's Response construction throw 500 and lose
272
+ // the whole (successful, degraded) render. Decode base64 first; fall back
273
+ // to raw JSON so a mixed-version worker/cell still parses.
274
+ const raw = /^[A-Za-z0-9+/=]+$/.test(degradedHeader)
275
+ ? Buffer.from(degradedHeader, 'base64').toString('utf8')
276
+ : degradedHeader;
277
+ degraded = JSON.parse(raw) as ExportDegradation;
195
278
  } catch {
196
279
  /* a malformed degradation note must not fail a real artifact */
197
280
  }