@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
@@ -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 })
@@ -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
+ });