@1agh/maude 1.0.7 → 1.0.9

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 (34) 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 +23 -6
  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 +45 -12
  7. package/apps/studio/bin/_video-playwright.mjs +135 -12
  8. package/apps/studio/canvas-edit.ts +33 -0
  9. package/apps/studio/canvas-lib.tsx +7 -0
  10. package/apps/studio/client/app.jsx +84 -23
  11. package/apps/studio/client/export-lane.js +37 -2
  12. package/apps/studio/dist/client.bundle.js +601 -601
  13. package/apps/studio/export-dialog.tsx +24 -19
  14. package/apps/studio/exporters/_runtime.ts +31 -1
  15. package/apps/studio/exporters/capture-chrome.ts +111 -0
  16. package/apps/studio/exporters/capture-core.ts +659 -3
  17. package/apps/studio/exporters/format-scopes.ts +88 -0
  18. package/apps/studio/exporters/html.ts +3 -2
  19. package/apps/studio/exporters/jobs.ts +190 -18
  20. package/apps/studio/exporters/pdf.ts +21 -1
  21. package/apps/studio/exporters/remote.ts +73 -4
  22. package/apps/studio/exporters/scope.ts +14 -3
  23. package/apps/studio/exporters/video.ts +75 -8
  24. package/apps/studio/http.ts +74 -7
  25. package/apps/studio/test/canvas-hide-chrome.test.ts +30 -0
  26. package/apps/studio/test/export-capture-hygiene.test.ts +675 -0
  27. package/apps/studio/test/export-e2e-lanes.test.ts +857 -0
  28. package/apps/studio/test/export-format-scope-coherence.test.ts +89 -0
  29. package/apps/studio/test/exporters/jobs.test.ts +73 -0
  30. package/apps/studio/test/exporters/scope.test.ts +33 -0
  31. package/apps/studio/test/fixtures/tiny-subset.woff2 +0 -0
  32. package/apps/studio/test/pdf-print-boxes.test.ts +33 -0
  33. package/apps/studio/whats-new.json +9 -0
  34. package/package.json +8 -8
@@ -1,10 +1,11 @@
1
1
  // _html-playwright.mjs — playwright shim for the HTML exporter.
2
2
  //
3
- // Walks the rendered DOM, serializes the full document with stylesheets
4
- // inlined, and emits a standalone `index.html`. Web fonts + remote images
5
- // are NOT inlined in v1 — the doc references them by absolute URL so the
6
- // resulting file works under file:// when the user has those origins
7
- // available. Full asset inlining is a follow-up; see plan T5.
3
+ // Walks the rendered DOM, serializes the artboard with every stylesheet
4
+ // inlined and EVERY resource embedded as a data: URI (images, fonts, CSS
5
+ // url()s, out-of-artboard SVG defs), and emits a self-contained `index.html`
6
+ // that opens under file:// with nothing else around. v1 referenced assets by
7
+ // absolute URL against a `<base>` — dead the moment the serving origin went
8
+ // away, which on the render worker is the end of the job (DDR-232 follow-up).
8
9
  //
9
10
  // Invocation (Bun.spawn from exporters/html.ts — not invoked directly):
10
11
  // npm exec --package=playwright -- node _html-playwright.mjs \
@@ -13,7 +14,7 @@
13
14
 
14
15
  import { mkdirSync, writeFileSync } from 'node:fs';
15
16
  import { dirname, join } from 'node:path';
16
- import { launchChromium } from './_pw-launch.mjs';
17
+ import { launchChromium, safeArtboardFilename } from './_pw-launch.mjs';
17
18
 
18
19
  const args = Object.fromEntries(
19
20
  process.argv.slice(2).reduce((acc, cur, i, all) => {
@@ -43,7 +44,10 @@ const timeoutMs = Number(timeout) * 1000;
43
44
 
44
45
  const browser = await launchChromium();
45
46
  try {
46
- const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
47
+ const ctx = await browser.newContext({
48
+ viewport: { width: 1440, height: 900 },
49
+ serviceWorkers: 'block',
50
+ });
47
51
  const page = await ctx.newPage();
48
52
  await page.goto(url, { waitUntil: 'load', timeout: timeoutMs });
49
53
  await page.evaluate(() => document.fonts.ready);
@@ -100,7 +104,10 @@ try {
100
104
  const restore = await pinArtboard(handle);
101
105
  const html = await serializeOne(handle, false);
102
106
  await restore();
103
- const target = join(outDir, `${id}.html`);
107
+ // Tenant-authored id (untrusted, DDR-054) — sanitize before it becomes a
108
+ // path segment, same as the png shim (security review F1: arbitrary-path
109
+ // write of attacker bytes via a `../`-laden data-dc-screen).
110
+ const target = join(outDir, safeArtboardFilename(id, i, 'html'));
104
111
  writeFileSync(target, html, 'utf8');
105
112
  written.push(target);
106
113
  console.log(`MAUDE_PROGRESS {"current":${i + 1},"total":${screens.length}}`);
@@ -128,30 +135,202 @@ try {
128
135
 
129
136
  async function serializeOne(locator, widenToArtboard) {
130
137
  return await locator.evaluate(
131
- (el, opts) => {
138
+ async (el, opts) => {
132
139
  const target = opts.widenToArtboard ? (el.closest('[data-dc-screen]') ?? el) : el;
140
+ // Work on a CLONE — the live canvas must not be mutated (the member may
141
+ // be looking at it on the browser lane's sibling host, and the worker
142
+ // captures several artboards from one page).
143
+ const clone = target.cloneNode(true);
144
+
145
+ // Editor chrome never ships (the artboard's own title bar is a child of
146
+ // the artboard — DDR-232 §2). capture-core exposes the one shared list.
147
+ const hidden = window.__maudeCaptureCore?.CAPTURE_HIDDEN_SELECTORS ?? [];
148
+ for (const sel of hidden) {
149
+ if (sel.startsWith('#')) continue;
150
+ for (const n of Array.from(clone.querySelectorAll(sel))) n.remove();
151
+ }
152
+
153
+ // ── resource inlining ────────────────────────────────────────────────
154
+ // Until now every src/url() stayed RELATIVE and the doc carried
155
+ // `<base href=<this origin>>`. On the desktop that origin is the dev
156
+ // server (alive only while Maude runs); on the render worker it is the
157
+ // job's THROWAWAY token-proxy loopback, dead the moment the job ends —
158
+ // so a cloud HTML export had no assets at all (DDR-232 follow-up). The
159
+ // file is now self-contained: same-origin fetch → data: URI.
160
+ const cache = new Map();
161
+ const toDataUrl = async (raw) => {
162
+ if (!raw || /^(data:|blob:|#)/.test(raw)) return null;
163
+ let abs;
164
+ try {
165
+ abs = new URL(raw, document.baseURI).href;
166
+ } catch {
167
+ return null;
168
+ }
169
+ if (cache.has(abs)) return cache.get(abs);
170
+ let out = null;
171
+ try {
172
+ const r = await fetch(abs);
173
+ if (r.ok) {
174
+ const blob = await r.blob();
175
+ out = await new Promise((resolve) => {
176
+ const fr = new FileReader();
177
+ fr.onload = () => resolve(String(fr.result));
178
+ fr.onerror = () => resolve(null);
179
+ fr.readAsDataURL(blob);
180
+ });
181
+ }
182
+ } catch {
183
+ out = null;
184
+ }
185
+ cache.set(abs, out);
186
+ return out;
187
+ };
188
+
189
+ // <img> — bake a CSS `filter` into the bitmap (a duotone/posterize via an
190
+ // in-page SVG filter), the same rule the SVG/PNG spine applies; an
191
+ // unfiltered image keeps its original bytes.
192
+ const liveImgs = Array.from(target.querySelectorAll('img'));
193
+ const cloneImgs = Array.from(clone.querySelectorAll('img'));
194
+ for (let i = 0; i < cloneImgs.length; i += 1) {
195
+ const c = cloneImgs[i];
196
+ const live = liveImgs[i];
197
+ let data = null;
198
+ const filter = live ? getComputedStyle(live).filter : 'none';
199
+ if (live && filter && filter !== 'none' && live.naturalWidth && live.naturalHeight) {
200
+ try {
201
+ const cv = document.createElement('canvas');
202
+ cv.width = live.naturalWidth;
203
+ cv.height = live.naturalHeight;
204
+ const g = cv.getContext('2d');
205
+ if (g) {
206
+ g.filter = filter;
207
+ if (g.filter === filter) {
208
+ g.drawImage(live, 0, 0);
209
+ data = cv.toDataURL('image/png');
210
+ c.style.filter = 'none';
211
+ }
212
+ }
213
+ } catch {
214
+ data = null;
215
+ }
216
+ }
217
+ if (!data) data = await toDataUrl(c.getAttribute('src'));
218
+ if (data) {
219
+ c.setAttribute('src', data);
220
+ c.removeAttribute('srcset');
221
+ }
222
+ }
223
+ for (const n of Array.from(clone.querySelectorAll('[poster]'))) {
224
+ const d = await toDataUrl(n.getAttribute('poster'));
225
+ if (d) n.setAttribute('poster', d);
226
+ }
227
+ for (const n of Array.from(clone.querySelectorAll('image'))) {
228
+ const href = n.getAttribute('href') ?? n.getAttribute('xlink:href');
229
+ const d = await toDataUrl(href);
230
+ if (d) {
231
+ n.setAttribute('href', d);
232
+ n.setAttribute('xlink:href', d);
233
+ }
234
+ }
235
+
236
+ // Neutralize executable markup before serializing. The artboard is
237
+ // TENANT-authored (untrusted, DDR-054) and this file is opened LOCALLY by
238
+ // a member (file:// origin) — so a `<script>`, an `onerror=`, a
239
+ // `javascript:` href or a `<foreignObject>` in the canvas would run as
240
+ // the member on open (security review F1). Strip them from anything we
241
+ // serialize; a `script-src 'none'` CSP meta (below) is the belt to this
242
+ // braces. The clone is a throwaway — the live canvas is untouched.
243
+ const neutralize = (rootEl) => {
244
+ for (const el of Array.from(rootEl.querySelectorAll('script,foreignObject'))) el.remove();
245
+ const walk = rootEl.querySelectorAll('*');
246
+ for (const el of [rootEl, ...Array.from(walk)]) {
247
+ for (const attr of Array.from(el.attributes ?? [])) {
248
+ const name = attr.name.toLowerCase();
249
+ if (name.startsWith('on')) {
250
+ el.removeAttribute(attr.name);
251
+ continue;
252
+ }
253
+ if (
254
+ (name === 'href' || name === 'xlink:href' || name === 'src') &&
255
+ /^\s*javascript:/i.test(attr.value)
256
+ ) {
257
+ el.removeAttribute(attr.name);
258
+ }
259
+ }
260
+ }
261
+ };
262
+ neutralize(clone);
263
+
264
+ // SVG defs the artboard references but does not CONTAIN — a design
265
+ // system typically mounts its <filter>/<linearGradient>/<pattern> once
266
+ // per canvas, outside any artboard. `outerHTML` of the artboard alone
267
+ // silently dropped them ("the image is still missing some filter").
268
+ const defsHtml = [];
269
+ for (const d of Array.from(
270
+ document.querySelectorAll(
271
+ 'filter,linearGradient,radialGradient,pattern,clipPath,mask,symbol'
272
+ )
273
+ )) {
274
+ if (target.contains(d)) continue;
275
+ // A def can equally smuggle script (a <symbol>/<mask> wrapping a
276
+ // <foreignObject>) — clone + neutralize before taking its markup.
277
+ const dc = d.cloneNode(true);
278
+ neutralize(dc);
279
+ defsHtml.push(dc.outerHTML);
280
+ }
281
+ const defsBlock = defsHtml.length
282
+ ? `<svg width="0" height="0" style="position:absolute" aria-hidden="true"><defs>${defsHtml.join('')}</defs></svg>`
283
+ : '';
284
+
285
+ // Stylesheets — every rule, every url() inlined (fonts, background images).
133
286
  const cssChunks = [];
134
287
  for (const sheet of Array.from(document.styleSheets)) {
135
288
  try {
136
- for (const rule of Array.from(sheet.cssRules)) {
137
- cssChunks.push(rule.cssText);
138
- }
289
+ for (const rule of Array.from(sheet.cssRules)) cssChunks.push(rule.cssText);
139
290
  } catch {
140
- // Cross-origin sheet — skip.
291
+ /* cross-origin sheet — skip */
141
292
  }
142
293
  }
143
- const styleBlock = `<style>${cssChunks.join('\n')}</style>`;
144
- const innerHtml = target.outerHTML;
294
+ let css = cssChunks.join('\n');
295
+ const urls = new Set();
296
+ for (const m of css.matchAll(/url\(\s*(['"]?)([^)'"]+)\1\s*\)/g)) urls.add(m[2]);
297
+ for (const u of urls) {
298
+ const d = await toDataUrl(u);
299
+ if (d) css = css.split(u).join(d);
300
+ }
301
+ // BOUNDARY (security review F5): any `url(http(s)://…)` still here is a
302
+ // resource we could NOT inline (blocked/failed) — a tenant-authored
303
+ // beacon that would fire when a DIFFERENT user opens the file. Neutralize
304
+ // it to `url(about:blank)` rather than leave the phone-home in the CSS.
305
+ css = css.replace(/url\(\s*(['"]?)(https?:[^)'"]+)\1\s*\)/gi, 'url(about:blank)');
306
+ // Same for any element `src`/`href` the img/image loops left remote.
307
+ for (const el of Array.from(clone.querySelectorAll('[src],[href],[xlink\\:href]'))) {
308
+ for (const a of ['src', 'href', 'xlink:href']) {
309
+ const v = el.getAttribute(a);
310
+ if (v && /^\s*https?:/i.test(v)) el.removeAttribute(a);
311
+ }
312
+ }
313
+
314
+ const escapeHtml = (s) =>
315
+ String(s).replace(
316
+ /[&<>"']/g,
317
+ (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]
318
+ );
319
+
320
+ // `script-src 'none'` is the belt to `neutralize()`'s braces — even if a
321
+ // sink is missed, the opened artifact executes no script. `object-src`
322
+ // blocks plugin embeds; the design still renders (it is static markup +
323
+ // inlined CSS/images). `document.title` is tenant-influenced — escape it.
145
324
  return `<!doctype html>
146
325
  <html lang="en">
147
326
  <head>
148
327
  <meta charset="utf-8" />
328
+ <meta http-equiv="Content-Security-Policy" content="script-src 'none'; object-src 'none'; base-uri 'none'" />
149
329
  <meta name="viewport" content="width=device-width, initial-scale=1" />
150
- <title>${document.title || 'Maude export'}</title>
151
- <base href="${location.origin}/" />
152
- ${styleBlock}
330
+ <title>${escapeHtml(document.title || 'Maude export')}</title>
331
+ <style>${css}</style>
153
332
  </head>
154
- <body>${innerHtml}</body>
333
+ <body>${defsBlock}${clone.outerHTML}</body>
155
334
  </html>`;
156
335
  },
157
336
  { widenToArtboard }
@@ -64,6 +64,7 @@ const deviceScaleFactor = Math.max(1, Math.min(8, Number(scale) || 1));
64
64
  const browser = await launchChromium();
65
65
  try {
66
66
  const ctx = await browser.newContext({
67
+ serviceWorkers: 'block',
67
68
  viewport: { width: 1440, height: 900 },
68
69
  deviceScaleFactor,
69
70
  });
@@ -73,6 +73,7 @@ try {
73
73
  // 1440x900 matches the canvas viewport the design tool uses everywhere;
74
74
  // exporters resize per-target before each shot to fit the artboard exactly.
75
75
  const ctx = await browser.newContext({
76
+ serviceWorkers: 'block',
76
77
  viewport: { width: 1440, height: 900 },
77
78
  deviceScaleFactor,
78
79
  });
@@ -157,22 +158,38 @@ try {
157
158
  width: Math.max(1, Math.ceil(rect.width)),
158
159
  height: Math.max(1, Math.ceil(rect.height)),
159
160
  });
161
+ // Anchor the CAPTURED ELEMENT at (0,0), not just its artboard. For a
162
+ // selection capture the element sits at an offset INSIDE the pinned
163
+ // artboard (padding, layout), while the viewport was just sized to the
164
+ // ELEMENT — so a clip at the element's rect ran past the viewport edge
165
+ // and Playwright refused with "Clipped area is either empty or outside
166
+ // the resulting image". The canvas page doesn't scroll (the world plane
167
+ // owns overflow), so the old scrollIntoView was a no-op there; shifting
168
+ // the pinned artboard by the element's offset is deterministic.
160
169
  await widenedHandle.evaluate((el) => {
161
- el.scrollIntoView({ block: 'start', inline: 'start' });
170
+ const r = el.getBoundingClientRect();
171
+ const ab = el.closest('[data-dc-screen]') ?? el;
172
+ if (r.left !== 0 || r.top !== 0) {
173
+ ab.style.left = `${parseFloat(ab.style.left || '0') - r.left}px`;
174
+ ab.style.top = `${parseFloat(ab.style.top || '0') - r.top}px`;
175
+ }
162
176
  window.scrollTo(0, 0);
163
177
  });
164
- // After scroll, recompute rect — it's now anchored near (0,0).
165
178
  const finalRect = await widenedHandle.evaluate((el) => {
166
179
  const r = el.getBoundingClientRect();
167
180
  return { x: r.left, y: r.top, width: r.width, height: r.height };
168
181
  });
182
+ // Clamp to the viewport — a clip must never extend past it.
183
+ const vp = page.viewportSize();
184
+ const clipX = Math.min(Math.max(0, Math.floor(finalRect.x)), Math.max(0, vp.width - 1));
185
+ const clipY = Math.min(Math.max(0, Math.floor(finalRect.y)), Math.max(0, vp.height - 1));
169
186
  await page.screenshot({
170
187
  path: target,
171
188
  clip: {
172
- x: Math.max(0, Math.floor(finalRect.x)),
173
- y: Math.max(0, Math.floor(finalRect.y)),
174
- width: Math.max(1, Math.ceil(finalRect.width)),
175
- height: Math.max(1, Math.ceil(finalRect.height)),
189
+ x: clipX,
190
+ y: clipY,
191
+ width: Math.max(1, Math.min(Math.ceil(finalRect.width), vp.width - clipX)),
192
+ height: Math.max(1, Math.min(Math.ceil(finalRect.height), vp.height - clipY)),
176
193
  },
177
194
  });
178
195
  written.push(target);
@@ -36,7 +36,10 @@ mkdirSync(dirname(out), { recursive: true });
36
36
 
37
37
  const browser = await launchChromium();
38
38
  try {
39
- const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
39
+ const ctx = await browser.newContext({
40
+ viewport: { width: 1440, height: 900 },
41
+ serviceWorkers: 'block',
42
+ });
40
43
  const page = await ctx.newPage();
41
44
  await page.goto(url, { waitUntil: 'networkidle', timeout: timeoutMs });
42
45
  await page.evaluate(() => document.fonts.ready);
@@ -165,3 +165,51 @@ export function assertRenderOutputSizeOk(widthCss, heightCss, deviceScaleFactor,
165
165
  process.exit(1);
166
166
  }
167
167
  }
168
+
169
+ /**
170
+ * Inject a JS bundle into a page whose CSP refuses inline scripts.
171
+ *
172
+ * `page.addScriptTag({ content | path })` writes an INLINE `<script>`, which the
173
+ * canvas origin's shell CSP (`script-src 'self' 'sha256-…'`) refuses outright —
174
+ * and the render worker (DDR-230) always loads the canvas from that origin, so
175
+ * every shim that injected this way failed there (worker-lane SVG, then mp4 /
176
+ * webm / gif: "Executing inline script violates the following Content Security
177
+ * Policy directive"). See DDR-232 §3.
178
+ *
179
+ * This serves the bundle at a SAME-ORIGIN virtual URL through Playwright
180
+ * request interception and adds it as `<script src>`. CSP is enforced on the
181
+ * script's URL origin, which is `'self'`, so it passes — and because the tag is
182
+ * a real module script in the document, bare specifiers still resolve through
183
+ * the shell's importmap (the render lib NEEDS that: it externalizes `remotion`
184
+ * to share the page's instance, DDR-148). Measured, not assumed: a route-
185
+ * fulfilled module under the exact CSP shape resolves an importmap specifier;
186
+ * `addInitScript` cannot (it runs before the importmap exists).
187
+ *
188
+ * No CSP is relaxed. On the main origin (desktop), where the CSP is env-gated
189
+ * off, this is simply equivalent to the inline injection it replaces.
190
+ *
191
+ * @param {import('playwright').Page} page
192
+ * @param {{ content: string, type?: 'module' | undefined, name?: string, timeout?: number }} opts
193
+ */
194
+ let injectedSeq = 0;
195
+ export async function addScriptCspSafe(page, { content, type, name = 'lib', timeout }) {
196
+ const origin = new URL(page.url()).origin;
197
+ injectedSeq += 1;
198
+ const url = `${origin}/__maude-inject/${injectedSeq}-${name}.js`;
199
+ // `page.route` is per-URL; route each bundle exactly once and unroute after so
200
+ // a long-lived page (the frame-step loop) carries no stale handlers.
201
+ await page.route(url, (route) =>
202
+ route.fulfill({
203
+ status: 200,
204
+ contentType: 'text/javascript; charset=utf-8',
205
+ headers: { 'cache-control': 'no-store' },
206
+ body: content,
207
+ })
208
+ );
209
+ try {
210
+ await page.addScriptTag({ url, ...(type ? { type } : {}) });
211
+ } finally {
212
+ await page.unroute(url).catch(() => {});
213
+ }
214
+ void timeout;
215
+ }
@@ -18,7 +18,7 @@
18
18
 
19
19
  import { mkdirSync, writeFileSync } from 'node:fs';
20
20
  import { dirname, join } from 'node:path';
21
- import { launchChromium } from './_pw-launch.mjs';
21
+ import { launchChromium, safeArtboardFilename } from './_pw-launch.mjs';
22
22
 
23
23
  const args = Object.fromEntries(
24
24
  process.argv.slice(2).reduce((acc, cur, i, all) => {
@@ -53,7 +53,10 @@ const timeoutMs = Number(timeout) * 1000;
53
53
 
54
54
  const browser = await launchChromium();
55
55
  try {
56
- const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
56
+ const ctx = await browser.newContext({
57
+ viewport: { width: 1440, height: 900 },
58
+ serviceWorkers: 'block',
59
+ });
57
60
  const page = await ctx.newPage();
58
61
  await page.goto(url, { waitUntil: 'load', timeout: timeoutMs });
59
62
  await page.evaluate(() => document.fonts.ready);
@@ -73,15 +76,41 @@ try {
73
76
  world.style.transform = 'none';
74
77
  }
75
78
  });
76
- // Inject dom-to-svg (IIFE — attaches `window.domToSvg`) and the SHARED
77
- // capture spine (module — attaches `window.__maudeCaptureCore`). The core is
78
- // the SAME source the canvas runtime's export-capture bridge imports
79
- // (exporters/capture-core.ts, DDR-231 single-spine): the serializer logic
80
- // must never be re-inlined here — it forked once and the browser lane would
81
- // silently drift from this shim.
82
- await page.addScriptTag({ path: bundlePath });
83
- await page.addScriptTag({ path: corePath, type: 'module' });
84
- await page.waitForFunction(() => !!window.__maudeCaptureCore, { timeout: timeoutMs });
79
+ // Get dom-to-svg (`window.domToSvg`) and the SHARED capture spine
80
+ // (`window.__maudeCaptureCore`) into the page. The core is the SAME source
81
+ // the canvas runtime's export-capture bridge imports (exporters/
82
+ // capture-core.ts, DDR-231 single-spine): the serializer logic must never be
83
+ // re-inlined here — it forked once and the browser lane would silently drift
84
+ // from this shim.
85
+ //
86
+ // PREFER WHAT THE PAGE ALREADY HAS (DDR-231 Phase 2 T4). A real canvas
87
+ // bundles capture-core through canvas-lib, so `__maudeCaptureCore` is
88
+ // already set, and `dom-to-svg` resolves through the shell's importmap.
89
+ // `addScriptTag({path})` injects an INLINE script, which the canvas origin's
90
+ // strict shell CSP (`script-src 'self' 'sha256-…'`) refuses outright — so on
91
+ // the render-worker lane, where the canvas is ALWAYS loaded from the canvas
92
+ // origin, every SVG export died with "Executing inline script violates the
93
+ // following Content Security Policy directive". Reaching for the in-page
94
+ // copy first fixes that lane without relaxing anyone's CSP, and makes the
95
+ // worker run literally the same code the member's browser runs.
96
+ const reuse = await page.evaluate(async () => {
97
+ const w = window;
98
+ if (!w.domToSvg) {
99
+ try {
100
+ w.domToSvg = await import('dom-to-svg');
101
+ } catch {
102
+ /* no importmap on this page — the injection fallback below covers it */
103
+ }
104
+ }
105
+ return !!w.__maudeCaptureCore && !!w.domToSvg;
106
+ });
107
+ if (!reuse) {
108
+ await page.addScriptTag({ path: bundlePath });
109
+ await page.addScriptTag({ path: corePath, type: 'module' });
110
+ }
111
+ await page.waitForFunction(() => !!window.__maudeCaptureCore && !!window.domToSvg, {
112
+ timeout: timeoutMs,
113
+ });
85
114
 
86
115
  const written = [];
87
116
 
@@ -130,7 +159,11 @@ try {
130
159
  // Each multi handle is already a `[data-dc-screen]` artboard — no widen.
131
160
  const svg = await serializeOne(handle, false);
132
161
  await restore();
133
- const target = join(outDir, `${id}.svg`);
162
+ // `id` is the tenant-authored `data-dc-screen` (untrusted, DDR-054) — a
163
+ // `../`-laden id in `join(outDir, `${id}.svg`)` is an arbitrary-path
164
+ // write of attacker bytes (security review F1). The png shim already
165
+ // routes through this; the svg/html siblings did not.
166
+ const target = join(outDir, safeArtboardFilename(id, i, 'svg'));
134
167
  writeFileSync(target, svg, 'utf8');
135
168
  written.push(target);
136
169
  console.log(`MAUDE_PROGRESS {"current":${i + 1},"total":${screens.length}}`);