@1agh/maude 1.0.7 → 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.
- package/apps/studio/bin/_html-playwright.mjs +198 -19
- package/apps/studio/bin/_pdf-playwright.mjs +1 -0
- package/apps/studio/bin/_png-playwright.mjs +23 -6
- package/apps/studio/bin/_pptx-playwright.mjs +4 -1
- package/apps/studio/bin/_pw-launch.mjs +48 -0
- package/apps/studio/bin/_svg-playwright.mjs +45 -12
- package/apps/studio/bin/_video-playwright.mjs +123 -11
- package/apps/studio/canvas-edit.ts +33 -0
- package/apps/studio/canvas-lib.tsx +7 -0
- package/apps/studio/client/app.jsx +84 -23
- package/apps/studio/client/export-lane.js +37 -2
- package/apps/studio/dist/client.bundle.js +601 -601
- package/apps/studio/export-dialog.tsx +24 -19
- package/apps/studio/exporters/_runtime.ts +31 -1
- package/apps/studio/exporters/capture-chrome.ts +111 -0
- package/apps/studio/exporters/capture-core.ts +659 -3
- package/apps/studio/exporters/format-scopes.ts +88 -0
- package/apps/studio/exporters/html.ts +3 -2
- package/apps/studio/exporters/jobs.ts +180 -18
- package/apps/studio/exporters/pdf.ts +21 -1
- package/apps/studio/exporters/remote.ts +73 -4
- package/apps/studio/exporters/scope.ts +14 -3
- package/apps/studio/exporters/video.ts +75 -8
- package/apps/studio/http.ts +74 -7
- package/apps/studio/test/canvas-hide-chrome.test.ts +30 -0
- package/apps/studio/test/export-capture-hygiene.test.ts +675 -0
- package/apps/studio/test/export-e2e-lanes.test.ts +857 -0
- package/apps/studio/test/export-format-scope-coherence.test.ts +89 -0
- package/apps/studio/test/exporters/jobs.test.ts +73 -0
- package/apps/studio/test/exporters/scope.test.ts +33 -0
- package/apps/studio/test/fixtures/tiny-subset.woff2 +0 -0
- package/apps/studio/test/pdf-print-boxes.test.ts +33 -0
- package/apps/studio/whats-new.json +9 -0
- 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 {
|
|
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
|
-
|
|
79
|
-
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
|
package/apps/studio/http.ts
CHANGED
|
@@ -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 —
|
|
4296
|
-
//
|
|
4297
|
-
//
|
|
4298
|
-
|
|
4299
|
-
|
|
4300
|
-
|
|
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
|
+
});
|