@1agh/maude 0.53.0 → 0.53.2
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/_import-asset.mjs +1 -6
- package/apps/studio/bin/_import-brand.mjs +1 -1
- package/apps/studio/bin/_video-playwright.mjs +5 -1
- package/apps/studio/bin/screenshot.sh +131 -15
- package/apps/studio/canvas-build-sandbox.ts +385 -0
- package/apps/studio/canvas-build-worker.ts +85 -0
- package/apps/studio/client/app.jsx +112 -8
- package/apps/studio/client/canvas-url.js +7 -0
- package/apps/studio/client/panels/SettingsPanel.jsx +32 -12
- package/apps/studio/client/report-bug.jsx +125 -30
- package/apps/studio/client/styles/4-components-maude.css +5 -0
- package/apps/studio/client/styles/4-components.css +35 -0
- package/apps/studio/dist/client.bundle.js +831 -831
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/http.ts +252 -23
- package/apps/studio/paths.ts +8 -0
- package/apps/studio/server.ts +102 -36
- package/apps/studio/test/canvas-build-sandbox.test.ts +69 -0
- package/apps/studio/test/canvas-origin-gate.test.ts +5 -0
- package/apps/studio/test/canvas-url.test.ts +26 -0
- package/apps/studio/test/cloud-session-role.test.ts +131 -0
- package/apps/studio/test/cloud-shell-surfaces.test.ts +90 -0
- package/apps/studio/test/config-projection.test.ts +104 -0
- package/apps/studio/test/read-only-gate.test.ts +50 -0
- package/apps/studio/test/report-proxy.test.ts +125 -0
- package/apps/studio/test/workspace-containment.test.ts +90 -11
- package/apps/studio/whats-new.json +11 -2
- package/apps/studio/workspace-mode.ts +161 -28
- package/apps/studio/ws.ts +30 -0
- package/package.json +8 -8
package/apps/studio/http.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// top-level fall-through for paths Bun's `routes` field doesn't cover.
|
|
6
6
|
|
|
7
7
|
import { createHash } from 'node:crypto';
|
|
8
|
-
import { existsSync, readFileSync, watch } from 'node:fs';
|
|
8
|
+
import { existsSync, readFileSync, unlinkSync, watch } from 'node:fs';
|
|
9
9
|
import { dirname, join, posix, relative, resolve, sep } from 'node:path';
|
|
10
10
|
|
|
11
11
|
import {
|
|
@@ -22,6 +22,7 @@ import { type Api, ASSET_MAX_BYTES, ASSET_MAX_VIDEO_BYTES } from './api.ts';
|
|
|
22
22
|
import { ImportAssetError, importSvg, SVG_MAX_BYTES } from './bin/_import-asset.mjs';
|
|
23
23
|
import { ImportBrandError, importBrand } from './bin/_import-brand.mjs';
|
|
24
24
|
import { buildCanvasModule } from './canvas-build.ts';
|
|
25
|
+
import { buildCanvasSandboxed } from './canvas-build-sandbox.ts';
|
|
25
26
|
import { canvasLibPath } from './canvas-lib-resolver.ts';
|
|
26
27
|
import { TranspileError } from './canvas-pipeline.ts';
|
|
27
28
|
import { createCloudEndpoints } from './cloud/endpoints.ts';
|
|
@@ -82,7 +83,7 @@ import { gitShowFile } from './git/service.ts';
|
|
|
82
83
|
import { createGitHubEndpoints } from './github/endpoints.ts';
|
|
83
84
|
import type { Inspect } from './inspect.ts';
|
|
84
85
|
import { canvasSlug, writeLocator } from './locator.ts';
|
|
85
|
-
import { DEV_SERVER_ROOT, MEDIA_DIR, STICKERS_DIR } from './paths.ts';
|
|
86
|
+
import { BIN_DIR, DEV_SERVER_ROOT, MEDIA_DIR, STICKERS_DIR } from './paths.ts';
|
|
86
87
|
import { createPhotoStore, PHOTO_EDIT_MAX_BYTES } from './photo-store.ts';
|
|
87
88
|
import { probeReadiness } from './readiness.ts';
|
|
88
89
|
import { getRuntimeBundle, packageForSlug } from './runtime-bundle.ts';
|
|
@@ -91,6 +92,7 @@ import { isHubReadOnly } from './sync/hubs-config.ts';
|
|
|
91
92
|
import { signInToWorkspace, workspaceDisclosure } from './sync/workspace-signin.ts';
|
|
92
93
|
import { readUiPrefs, type UiPrefs, writeUiPrefs } from './ui-prefs.ts';
|
|
93
94
|
import { loadWhatsNew, resolveMaudeVersion } from './whats-new.ts';
|
|
95
|
+
import { isWorkspaceMode } from './workspace-mode.ts';
|
|
94
96
|
import { isLoopbackHost } from './ws.ts';
|
|
95
97
|
|
|
96
98
|
// Real disk install root — never the virtual `/$bunfs/root` of compiled bins.
|
|
@@ -317,6 +319,14 @@ export const READ_ONLY_ALLOWED_WRITES = new Set([
|
|
|
317
319
|
'/_api/export-jobs/download',
|
|
318
320
|
'/_api/report', // bug reports are about Maude, not the project
|
|
319
321
|
'/_api/report-fallback',
|
|
322
|
+
// Cloud Phase 27 — COMMENT IS THE ONE WRITE A VIEWER HOLDS. The role matrix
|
|
323
|
+
// has said so since Phase 25 C4 (`viewer.comment === true`), the People page
|
|
324
|
+
// promises it in those words, and the cell's proxy allows it — but this list
|
|
325
|
+
// did not, so a viewer's comment was accepted by the authority and then
|
|
326
|
+
// refused by the defence-in-depth layer behind it. Found by commenting as a
|
|
327
|
+
// viewer against a real cell. A second gate that is STRICTER than the first is
|
|
328
|
+
// still a gate that is wrong.
|
|
329
|
+
'/_comments',
|
|
320
330
|
'/_api/hub/link', // link/unlink ≈ session management (cell allows /auth/logout)
|
|
321
331
|
'/_api/workspace/sign-in', // signing in is how the role is (re)learned
|
|
322
332
|
'/_api/workspace/disclosure',
|
|
@@ -328,6 +338,18 @@ export const READ_ONLY_ALLOWED_WRITES = new Set([
|
|
|
328
338
|
'/_api/git/checkout', // viewing another branch, not changing one
|
|
329
339
|
]);
|
|
330
340
|
|
|
341
|
+
/**
|
|
342
|
+
* Write paths a read-only session may use whose members are GENERATED rather
|
|
343
|
+
* than declared, so an exact-match set cannot name them.
|
|
344
|
+
*
|
|
345
|
+
* Deliberately one entry. Every regex here is a hole nobody can see by reading
|
|
346
|
+
* the list above, so the bar for adding one is that an exact path genuinely
|
|
347
|
+
* cannot express it.
|
|
348
|
+
*/
|
|
349
|
+
export const READ_ONLY_ALLOWED_WRITE_PATTERNS: RegExp[] = [
|
|
350
|
+
/^\/_api\/comments\/[A-Za-z0-9_-]+\/reply$/,
|
|
351
|
+
];
|
|
352
|
+
|
|
331
353
|
/** The refusal a read-only session gets for a project-mutating write. */
|
|
332
354
|
export function readOnlyRefusalResponse(): Response {
|
|
333
355
|
return Response.json(
|
|
@@ -502,6 +524,40 @@ async function serveCanvasTsx(
|
|
|
502
524
|
const source = await file.text();
|
|
503
525
|
const deps = localDepsFromSource(source, absPath, ctx.paths.designRoot);
|
|
504
526
|
let result: Awaited<ReturnType<typeof buildCanvasModule>>;
|
|
527
|
+
// DDR-209 A′2 — SAME ENGINE, DIFFERENT HOST. On a desktop the process that
|
|
528
|
+
// parses your canvas is the process you own, so an in-process build costs
|
|
529
|
+
// nothing. In a cell it holds HUB_SECRET and the tenant's storage
|
|
530
|
+
// credentials, and the source is written by somebody who is not us — so the
|
|
531
|
+
// build goes out of process, with an empty environment, an import allowlist
|
|
532
|
+
// and wall-clock + RSS ceilings (Cloud Phase 25 A1's contract, unchanged).
|
|
533
|
+
// This branch is also what `sandboxArmed` in the containment boot-assert
|
|
534
|
+
// attests: the cell may serve the canvas surfaces BECAUSE this exists.
|
|
535
|
+
if (isWorkspaceMode()) {
|
|
536
|
+
const built = await buildCanvasSandboxed({
|
|
537
|
+
designRoot: ctx.paths.designRoot,
|
|
538
|
+
canvasAbs: absPath,
|
|
539
|
+
});
|
|
540
|
+
if (!built.ok) {
|
|
541
|
+
return new Response(`Canvas build error: ${built.error}`, {
|
|
542
|
+
status: built.kind === 'build' ? 422 : 500,
|
|
543
|
+
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
|
|
544
|
+
});
|
|
545
|
+
}
|
|
546
|
+
result = {
|
|
547
|
+
js: built.js,
|
|
548
|
+
locator: built.locator,
|
|
549
|
+
etag: built.etag,
|
|
550
|
+
} as Awaited<ReturnType<typeof buildCanvasModule>>;
|
|
551
|
+
cached = {
|
|
552
|
+
sig: canvasFreshnessSig(absPath, deps),
|
|
553
|
+
etag: `${result.etag}-${RUNTIME_BOOT_ID}-${CHROME_EPOCH}`,
|
|
554
|
+
js: result.js,
|
|
555
|
+
deps,
|
|
556
|
+
};
|
|
557
|
+
canvasCache.set(absPath, cached);
|
|
558
|
+
await writeLocator(locatorAbsPath, canvasSlug(absPath, ctx.paths.designRoot), result.locator);
|
|
559
|
+
return respondWithCanvasModule(req, cached);
|
|
560
|
+
}
|
|
505
561
|
try {
|
|
506
562
|
result = await buildCanvasModule(absPath, source, {
|
|
507
563
|
designRoot: ctx.paths.designRoot,
|
|
@@ -538,6 +594,13 @@ async function serveCanvasTsx(
|
|
|
538
594
|
await writeLocator(locatorAbsPath, canvasSlug(absPath, ctx.paths.designRoot), result.locator);
|
|
539
595
|
}
|
|
540
596
|
|
|
597
|
+
return respondWithCanvasModule(req, cached);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/** The conditional-GET half of serving a built canvas module. Shared by the
|
|
601
|
+
* in-process (desktop) and sandboxed (cell) build paths so the two cannot
|
|
602
|
+
* disagree about caching semantics. */
|
|
603
|
+
function respondWithCanvasModule(req: Request, cached: { js: string; etag: string }): Response {
|
|
541
604
|
const ifNoneMatch = req.headers.get('if-none-match');
|
|
542
605
|
if (ifNoneMatch === cached.etag) {
|
|
543
606
|
return new Response(null, {
|
|
@@ -650,14 +713,50 @@ async function serveHistoricalCanvas(
|
|
|
650
713
|
});
|
|
651
714
|
}
|
|
652
715
|
|
|
716
|
+
/**
|
|
717
|
+
* How long a served file may be reused — Cloud Phase 27 B2.
|
|
718
|
+
*
|
|
719
|
+
* `no-store` on everything is correct on a laptop, where the round trip is a
|
|
720
|
+
* memcpy and the designer is editing the file you just served. Over the
|
|
721
|
+
* internet it means a teammate re-downloads every photograph on every pan, on
|
|
722
|
+
* a project whose media is 266 MB. So:
|
|
723
|
+
*
|
|
724
|
+
* content-addressed name → immutable, a year. The name IS the content
|
|
725
|
+
* (sha8), so a changed file is a changed URL and
|
|
726
|
+
* a stale answer is impossible by construction.
|
|
727
|
+
* everything else → revalidate (`no-cache` + ETag). A designer
|
|
728
|
+
* editing `hero.png` in place still sees their
|
|
729
|
+
* edit; the wire cost of not having changed it is
|
|
730
|
+
* a 304, not the file.
|
|
731
|
+
*
|
|
732
|
+
* Universal, not cloud-only: the rule is true on all three shells, and a
|
|
733
|
+
* caching policy that differs per shell is a bug report nobody can reproduce.
|
|
734
|
+
*/
|
|
735
|
+
export function cacheControlFor(absPath: string): { cacheControl: string; addEtag: boolean } {
|
|
736
|
+
const name = absPath.slice(absPath.lastIndexOf('/') + 1);
|
|
737
|
+
// The shape every writer emits — timeline insert/replace/assemble, the asset
|
|
738
|
+
// upload route, `maude design fetch-asset`: <sha8>.<ext>, lowercase hex.
|
|
739
|
+
if (/^[0-9a-f]{8,64}\.[A-Za-z0-9]+$/.test(name)) {
|
|
740
|
+
return { cacheControl: 'public, max-age=31536000, immutable', addEtag: false };
|
|
741
|
+
}
|
|
742
|
+
return { cacheControl: 'no-cache', addEtag: true };
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
/** A weak validator from the file's own metadata — no read, no hash. */
|
|
746
|
+
function etagFor(file: { size: number; lastModified: number }): string {
|
|
747
|
+
return `W/"${file.size.toString(16)}-${Math.trunc(file.lastModified).toString(16)}"`;
|
|
748
|
+
}
|
|
749
|
+
|
|
653
750
|
async function serveFile(absPath: string, headers: Record<string, string> = {}): Promise<Response> {
|
|
654
751
|
const file = Bun.file(absPath);
|
|
655
752
|
if (!(await file.exists())) return new Response('Not found', { status: 404 });
|
|
656
753
|
const e = ext(absPath);
|
|
754
|
+
const policy = cacheControlFor(absPath);
|
|
657
755
|
return new Response(file, {
|
|
658
756
|
headers: {
|
|
659
757
|
'Content-Type': MIME[e] || 'application/octet-stream',
|
|
660
|
-
'Cache-Control':
|
|
758
|
+
'Cache-Control': policy.cacheControl,
|
|
759
|
+
...(policy.addEtag ? { ETag: etagFor(file) } : {}),
|
|
661
760
|
...headers,
|
|
662
761
|
},
|
|
663
762
|
});
|
|
@@ -685,7 +784,9 @@ async function serveMediaFile(
|
|
|
685
784
|
const size = file.size;
|
|
686
785
|
const base = {
|
|
687
786
|
'Content-Type': MIME[ext(absPath)] || 'application/octet-stream',
|
|
688
|
-
|
|
787
|
+
// B2 — same policy as serveFile. Media is the heaviest thing a member
|
|
788
|
+
// fetches, so the content-addressed case matters most here.
|
|
789
|
+
'Cache-Control': cacheControlFor(absPath).cacheControl,
|
|
689
790
|
'Accept-Ranges': 'bytes',
|
|
690
791
|
...headers,
|
|
691
792
|
};
|
|
@@ -1218,11 +1319,32 @@ export function createHttp(
|
|
|
1218
1319
|
// knows before it draws anything. It decides what the UI OFFERS and is
|
|
1219
1320
|
// never what stops a write — the cell enforces that (Phase 25 C1),
|
|
1220
1321
|
// whatever a patched client believes.
|
|
1221
|
-
'/_config': () =>
|
|
1322
|
+
'/_config': (req: Request) =>
|
|
1222
1323
|
Response.json({
|
|
1223
1324
|
...ctx.cfg,
|
|
1224
1325
|
canvasOrigin: ctx.canvasOrigin,
|
|
1225
|
-
readOnly: projectReadOnly(),
|
|
1326
|
+
readOnly: projectReadOnly(req),
|
|
1327
|
+
// Cloud Phase 27 (DDR-209) — the capability that opens the cookieless
|
|
1328
|
+
// canvas origin, minted per session by the proxy. Absent on a desktop,
|
|
1329
|
+
// where the canvas origin is loopback and needs none. `canvasUrl()`
|
|
1330
|
+
// appends it exactly the way it already appends `readOnly`, which is
|
|
1331
|
+
// what keeps this a FLAG on the shared client rather than a fork of it.
|
|
1332
|
+
...(req.headers.get('x-maude-canvas-token')
|
|
1333
|
+
? { canvasToken: req.headers.get('x-maude-canvas-token') }
|
|
1334
|
+
: {}),
|
|
1335
|
+
// Cloud Phase 27 C2/C4 — the ONE cloud-only input the shared client
|
|
1336
|
+
// takes. Its presence says "you are in a browser tab, on somebody
|
|
1337
|
+
// else's machine", which is what lets the client state the agent's
|
|
1338
|
+
// absence and offer a way back to the dashboard. A flag, not a fork:
|
|
1339
|
+
// every other difference between the three shells stays zero.
|
|
1340
|
+
...(isWorkspaceMode()
|
|
1341
|
+
? {
|
|
1342
|
+
cloud: {
|
|
1343
|
+
dashboardUrl: process.env.HUB_DASHBOARD_URL ?? 'https://cloud.maude.sh',
|
|
1344
|
+
projectName: process.env.MAUDE_PROJECT_NAME ?? ctx.cfg.name ?? null,
|
|
1345
|
+
},
|
|
1346
|
+
}
|
|
1347
|
+
: {}),
|
|
1226
1348
|
}),
|
|
1227
1349
|
|
|
1228
1350
|
// What's New feed (DDR-A) — read-only product-update list surfaced in the
|
|
@@ -1251,6 +1373,88 @@ export function createHttp(
|
|
|
1251
1373
|
);
|
|
1252
1374
|
},
|
|
1253
1375
|
|
|
1376
|
+
// feature-bug-report-button — capture the STUDIO SHELL (menubar, sidebar,
|
|
1377
|
+
// status bar, toasts) as a PNG. `/_api/export` can only ever render a canvas
|
|
1378
|
+
// headlessly, so Maude's own chrome — where most reported UI bugs actually
|
|
1379
|
+
// live — has never been capturable from inside the app. This shells out to
|
|
1380
|
+
// the same `screenshot.sh` spine every `/design:*` capture uses, in its
|
|
1381
|
+
// `--shell` mode, so it inherits engine resolution (the desktop bundle's
|
|
1382
|
+
// agent-browser via MAUDE_AGENT_BROWSER, DDR-144), one-time browser
|
|
1383
|
+
// provisioning, and the playwright fallback.
|
|
1384
|
+
//
|
|
1385
|
+
// The capture is a SEPARATE headless session, not a mirror of the user's
|
|
1386
|
+
// window: it reproduces the app's chrome and the active canvas, but not
|
|
1387
|
+
// transient state (an open dropdown, a stuck spinner). That's why the
|
|
1388
|
+
// dialog keeps the manual attach/paste lane alongside this.
|
|
1389
|
+
//
|
|
1390
|
+
// PRIVILEGED: main-origin only + double gate — it spawns a process and
|
|
1391
|
+
// renders the project, so the untrusted canvas origin must never reach it
|
|
1392
|
+
// (absent from CANVAS_SAFE_API + startCanvasServer routes, per DDR-088).
|
|
1393
|
+
'/_api/shell-shot': async (req: Request) => {
|
|
1394
|
+
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
1395
|
+
if (!sameOriginWrite(req)) return new Response('cross-origin rejected', { status: 403 });
|
|
1396
|
+
if (!isLoopbackHost(req.headers.get('host')))
|
|
1397
|
+
return new Response('local request required (DNS-rebinding guard)', { status: 403 });
|
|
1398
|
+
// Target the very server handling this request — no _server.json read, so
|
|
1399
|
+
// a stale/absent state file can't point the capture at another instance.
|
|
1400
|
+
const port = new URL(req.url).port;
|
|
1401
|
+
if (!/^\d+$/.test(port)) {
|
|
1402
|
+
return Response.json({ error: 'no port on the request URL' }, { status: 500 });
|
|
1403
|
+
}
|
|
1404
|
+
// Which canvas to open is the CLIENT's call, always sent explicitly —
|
|
1405
|
+
// `null` means "open nothing". The server's `_active.json` is global and
|
|
1406
|
+
// sticky (it outlives every closed tab and any session may write it), so
|
|
1407
|
+
// resolving it here would photograph a canvas the reporter never had open.
|
|
1408
|
+
const body = await readJson<{ canvas?: unknown }>(req);
|
|
1409
|
+
const canvas = typeof body?.canvas === 'string' ? body.canvas : '';
|
|
1410
|
+
const out = join(ctx.paths.designRoot, '_reports', `shell-${Date.now()}.png`);
|
|
1411
|
+
try {
|
|
1412
|
+
const proc = Bun.spawn(
|
|
1413
|
+
[
|
|
1414
|
+
'bash',
|
|
1415
|
+
join(BIN_DIR, 'screenshot.sh'),
|
|
1416
|
+
'--shell',
|
|
1417
|
+
'--port',
|
|
1418
|
+
port,
|
|
1419
|
+
'--out',
|
|
1420
|
+
out,
|
|
1421
|
+
'--root',
|
|
1422
|
+
ctx.paths.repoRoot,
|
|
1423
|
+
'--canvas',
|
|
1424
|
+
canvas,
|
|
1425
|
+
],
|
|
1426
|
+
{ stdout: 'ignore', stderr: 'pipe' }
|
|
1427
|
+
);
|
|
1428
|
+
// Booting a browser, loading the studio, opening the active canvas and
|
|
1429
|
+
// painting it runs well past a default fetch timeout on a cold cache;
|
|
1430
|
+
// the dialog shows a pending row meanwhile. Kill rather than hang.
|
|
1431
|
+
const timer = setTimeout(() => proc.kill(), 60_000);
|
|
1432
|
+
const code = await proc.exited;
|
|
1433
|
+
clearTimeout(timer);
|
|
1434
|
+
if (code !== 0 || !existsSync(out)) {
|
|
1435
|
+
const why = (await new Response(proc.stderr).text())
|
|
1436
|
+
.trim()
|
|
1437
|
+
.split('\n')
|
|
1438
|
+
.slice(-3)
|
|
1439
|
+
.join(' ');
|
|
1440
|
+
return Response.json({ error: `shell capture failed: ${why}` }, { status: 502 });
|
|
1441
|
+
}
|
|
1442
|
+
const png = await Bun.file(out).arrayBuffer();
|
|
1443
|
+
// The PNG is handed to the client and never needed again — leaving it on
|
|
1444
|
+
// disk would quietly grow `_reports/` on every dialog open.
|
|
1445
|
+
try {
|
|
1446
|
+
unlinkSync(out);
|
|
1447
|
+
} catch {
|
|
1448
|
+
/* already gone — nothing to clean up */
|
|
1449
|
+
}
|
|
1450
|
+
return new Response(png, {
|
|
1451
|
+
headers: { 'content-type': 'image/png', 'Cache-Control': 'no-store' },
|
|
1452
|
+
});
|
|
1453
|
+
} catch (e) {
|
|
1454
|
+
return Response.json({ error: `shell capture failed: ${String(e)}` }, { status: 502 });
|
|
1455
|
+
}
|
|
1456
|
+
},
|
|
1457
|
+
|
|
1254
1458
|
// feature-bug-report-button — submit proxy. The client never talks to
|
|
1255
1459
|
// cloud.maude.sh directly (no CORS surface to open, and the endpoint can be
|
|
1256
1460
|
// overridden for self-hosters/tests via MAUDE_REPORT_URL). Forwards the
|
|
@@ -1263,7 +1467,13 @@ export function createHttp(
|
|
|
1263
1467
|
return new Response('local request required (DNS-rebinding guard)', { status: 403 });
|
|
1264
1468
|
const endpoint = process.env.MAUDE_REPORT_URL || 'https://cloud.maude.sh/report';
|
|
1265
1469
|
try {
|
|
1266
|
-
|
|
1470
|
+
// `globalThis.fetch` — NOT bare `fetch`: this module declares its own
|
|
1471
|
+
// `async function fetch(req: Request)` (the server fall-through handler)
|
|
1472
|
+
// in the same scope, which shadows the global. A bare call re-entered the
|
|
1473
|
+
// fall-through with a URL string, so `new URL(req.url)` threw
|
|
1474
|
+
// `"undefined" cannot be parsed as a URL` and every report died with a
|
|
1475
|
+
// plain-text HTTP 500 without ever leaving the machine.
|
|
1476
|
+
const upstream = await globalThis.fetch(endpoint, {
|
|
1267
1477
|
method: 'POST',
|
|
1268
1478
|
body: await req.formData(),
|
|
1269
1479
|
signal: AbortSignal.timeout(30_000),
|
|
@@ -1365,7 +1575,7 @@ export function createHttp(
|
|
|
1365
1575
|
// the api layer — DDR-115). The layout lane mutates the versioned
|
|
1366
1576
|
// `.meta.json`, so a read-only session is refused here, in-handler,
|
|
1367
1577
|
// where the two lanes are distinguishable.
|
|
1368
|
-
if (projectReadOnly() && 'layout' in body.patch) {
|
|
1578
|
+
if (projectReadOnly(req) && 'layout' in body.patch) {
|
|
1369
1579
|
return readOnlyRefusalResponse();
|
|
1370
1580
|
}
|
|
1371
1581
|
const next = await api.patchCanvasMeta(body.file, body.patch);
|
|
@@ -3948,7 +4158,11 @@ export function createHttp(
|
|
|
3948
4158
|
'/index.html': () => serveFile(join(CLIENT_DIR, 'index.html')),
|
|
3949
4159
|
} satisfies Record<string, (req: Request) => Response | Promise<Response>>;
|
|
3950
4160
|
|
|
3951
|
-
|
|
4161
|
+
// Named `handleFallthrough`, not `fetch` — a same-named local function shadows
|
|
4162
|
+
// the global `fetch` for every call site in this module's scope, which is how
|
|
4163
|
+
// the `/_api/report` route ended up calling itself instead of the network (see
|
|
4164
|
+
// the `globalThis.fetch` comment on that route; RCA issue-report-a-bug-http-500).
|
|
4165
|
+
async function handleFallthrough(req: Request): Promise<Response> {
|
|
3952
4166
|
try {
|
|
3953
4167
|
const url = new URL(req.url);
|
|
3954
4168
|
const pathname = url.pathname;
|
|
@@ -4062,17 +4276,13 @@ export function createHttp(
|
|
|
4062
4276
|
if (RANGE_MEDIA_EXTS.has(ext(name))) {
|
|
4063
4277
|
return serveMediaFile(abs, req, { 'X-Content-Type-Options': 'nosniff' });
|
|
4064
4278
|
}
|
|
4065
|
-
|
|
4066
|
-
|
|
4067
|
-
|
|
4068
|
-
|
|
4069
|
-
|
|
4070
|
-
|
|
4071
|
-
|
|
4072
|
-
},
|
|
4073
|
-
});
|
|
4074
|
-
}
|
|
4075
|
-
return new Response('Not found', { status: 404 });
|
|
4279
|
+
// B2 — the SAME caching policy as every other static lane. This
|
|
4280
|
+
// route is the one a canvas's photographs actually come down, so
|
|
4281
|
+
// `no-store` here is the difference between a teammate re-fetching
|
|
4282
|
+
// 266 MB of media on every pan and re-fetching none of it. Content-
|
|
4283
|
+
// addressed names (`<sha8>.<ext>`, which is what every writer emits)
|
|
4284
|
+
// are immutable by construction; anything else revalidates.
|
|
4285
|
+
return serveFile(abs, { 'X-Content-Type-Options': 'nosniff' });
|
|
4076
4286
|
}
|
|
4077
4287
|
}
|
|
4078
4288
|
|
|
@@ -4317,13 +4527,28 @@ export function createHttp(
|
|
|
4317
4527
|
// covers all three doors at once: the main-origin `routes` table, the
|
|
4318
4528
|
// canvas-origin `routes` allowlist in server.ts (it references these same
|
|
4319
4529
|
// handlers), and the dynamic-path `fetch` fall-through (comment replies).
|
|
4320
|
-
function projectReadOnly(): boolean {
|
|
4530
|
+
function projectReadOnly(req?: Request): boolean {
|
|
4531
|
+
// ---- Cloud Phase 27 A3/A4 (DDR-209): the role is PER SESSION ----------
|
|
4532
|
+
//
|
|
4533
|
+
// In a cell this process serves an owner and a viewer at the same time, so
|
|
4534
|
+
// the on-disk answer below — one role per hub URL — is not merely stale, it
|
|
4535
|
+
// is the wrong SHAPE. The proxy in front vouches a role per request and
|
|
4536
|
+
// injects the capability it derived from the one role table.
|
|
4537
|
+
//
|
|
4538
|
+
// AND IT DEFAULTS CLOSED. The local path fails OPEN by design: `catch`
|
|
4539
|
+
// returns false, an unset `linkedHub` returns false, and a fully writable
|
|
4540
|
+
// studio is the correct answer for a tool running on your own laptop. On
|
|
4541
|
+
// the internet it is the whole ballgame — so in a cloud build read-only is
|
|
4542
|
+
// the default and an edit role requires positive proof.
|
|
4543
|
+
if (isWorkspaceMode()) {
|
|
4544
|
+
return req?.headers.get('x-maude-readonly') !== '0';
|
|
4545
|
+
}
|
|
4321
4546
|
return ctx.cfg.linkedHub ? isHubReadOnly(ctx.cfg.linkedHub.url) : false;
|
|
4322
4547
|
}
|
|
4323
4548
|
|
|
4324
4549
|
function readOnlyRefusal(req: Request): Response | null {
|
|
4325
4550
|
if (READ_ONLY_SAFE_METHODS.has(req.method)) return null;
|
|
4326
|
-
if (!projectReadOnly()) return null;
|
|
4551
|
+
if (!projectReadOnly(req)) return null;
|
|
4327
4552
|
let pathname: string;
|
|
4328
4553
|
try {
|
|
4329
4554
|
pathname = new URL(req.url).pathname;
|
|
@@ -4331,6 +4556,10 @@ export function createHttp(
|
|
|
4331
4556
|
return readOnlyRefusalResponse();
|
|
4332
4557
|
}
|
|
4333
4558
|
if (READ_ONLY_ALLOWED_WRITES.has(pathname)) return null;
|
|
4559
|
+
// The dynamic half of the comment lane. An exact-match set cannot express
|
|
4560
|
+
// it, and leaving it out would mean a viewer may leave a comment but not
|
|
4561
|
+
// reply to one — a distinction nobody promised and nobody wants.
|
|
4562
|
+
if (READ_ONLY_ALLOWED_WRITE_PATTERNS.some((re) => re.test(pathname))) return null;
|
|
4334
4563
|
return readOnlyRefusalResponse();
|
|
4335
4564
|
}
|
|
4336
4565
|
|
|
@@ -4342,7 +4571,7 @@ export function createHttp(
|
|
|
4342
4571
|
) as typeof routes;
|
|
4343
4572
|
|
|
4344
4573
|
async function guardedFetch(req: Request): Promise<Response> {
|
|
4345
|
-
return readOnlyRefusal(req) ??
|
|
4574
|
+
return readOnlyRefusal(req) ?? handleFallthrough(req);
|
|
4346
4575
|
}
|
|
4347
4576
|
|
|
4348
4577
|
return { routes: guardedRoutes, fetch: guardedFetch, serveCanvasShell, isCanvasSafeRoute };
|
package/apps/studio/paths.ts
CHANGED
|
@@ -48,6 +48,14 @@ export const CLIENT_DIR: string = join(DEV_SERVER_ROOT, 'client');
|
|
|
48
48
|
/** `<DEV_SERVER_ROOT>/dist/runtime/` — pre-built /_canvas-runtime/*.js bundles. */
|
|
49
49
|
export const RUNTIME_BUNDLES_DIR: string = join(DIST_DIR, 'runtime');
|
|
50
50
|
|
|
51
|
+
/**
|
|
52
|
+
* `<DEV_SERVER_ROOT>/bin/` — the shell helpers behind `maude design <verb>`.
|
|
53
|
+
* Resolved here rather than locally per DDR-045: inside a `bun --compile`
|
|
54
|
+
* binary a local `dirname(fileURLToPath(import.meta.url))` yields the virtual
|
|
55
|
+
* `/$bunfs/root`, and every `existsSync` against it silently returns false.
|
|
56
|
+
*/
|
|
57
|
+
export const BIN_DIR: string = join(DEV_SERVER_ROOT, 'bin');
|
|
58
|
+
|
|
51
59
|
/**
|
|
52
60
|
* `<DEV_SERVER_ROOT>/stickers/` — bundled whiteboard sticker packs
|
|
53
61
|
* (feature-whiteboard-annotation-improvements, Phase 4). Ships with the
|
package/apps/studio/server.ts
CHANGED
|
@@ -14,13 +14,12 @@
|
|
|
14
14
|
// else binds to a port. The orchestrator (slash commands) reads _server.json
|
|
15
15
|
// to detect a live instance and avoid duplicate boots.
|
|
16
16
|
|
|
17
|
-
import { spawn } from 'node:child_process';
|
|
18
|
-
|
|
19
17
|
import { createAcp } from './acp/index.ts';
|
|
20
18
|
import { cancelInstall, cancelSignin } from './acp/login-state.ts';
|
|
21
19
|
import { createActivity } from './activity.ts';
|
|
22
20
|
import { ASSET_MAX_VIDEO_BYTES, createApi } from './api.ts';
|
|
23
21
|
import { bootSelfHeal } from './boot-self-heal.ts';
|
|
22
|
+
import { isSandboxArmed } from './canvas-build-sandbox.ts';
|
|
24
23
|
import { createCanvasListWatch } from './canvas-list-watch.ts';
|
|
25
24
|
import { type AiActivityEntry, createAiActivity } from './collab/ai-activity.ts';
|
|
26
25
|
import { createGitLifecycle } from './collab/git-lifecycle.ts';
|
|
@@ -177,8 +176,22 @@ const MAX_REQUEST_BODY = ASSET_MAX_VIDEO_BYTES + 8 * 1024 * 1024;
|
|
|
177
176
|
// Checked against the ACTUAL route table (`http.routes` keys plus the paths the
|
|
178
177
|
// `fetch` fall-through owns), not a hand-maintained list, so a future route can
|
|
179
178
|
// only escape it by being invisible to both.
|
|
180
|
-
|
|
179
|
+
//
|
|
180
|
+
// Split by verdict (DDR-209 A′1), because the two halves are asserted
|
|
181
|
+
// differently. `/_ws/acp` is FORBIDDEN and the fall-through 404s it in a cell,
|
|
182
|
+
// so it is only asserted outside workspace mode (where it is genuinely
|
|
183
|
+
// reachable). The canvas surfaces are SANDBOXED — a cell serves them — so they
|
|
184
|
+
// are asserted ALWAYS, which is what makes the sandbox attestation load-bearing
|
|
185
|
+
// rather than decorative.
|
|
186
|
+
const FETCH_ROUTES_FORBIDDEN_IN_WORKSPACE = ['/_ws/acp'];
|
|
187
|
+
const FETCH_ROUTES_SANDBOXED = ['/_canvas-shell.html', '/_canvas-runtime/'];
|
|
181
188
|
const WORKSPACE = isWorkspaceMode();
|
|
189
|
+
// DDR-209 A′1 — the contract that lets a cell serve `/_canvas-shell` +
|
|
190
|
+
// `/_canvas-runtime` at all: the canvas build runs out of process, with an empty
|
|
191
|
+
// environment, an import allowlist and ceilings. Computed here rather than
|
|
192
|
+
// inside workspace-mode.ts, which has no business importing the build host —
|
|
193
|
+
// and passed in, so an unstated contract reads as an unmet one.
|
|
194
|
+
const SANDBOX_ARMED = isSandboxArmed();
|
|
182
195
|
// PRUNE first, then assert over what survived — so the assert is a
|
|
183
196
|
// post-condition on the pruning rather than a second, driftable opinion. A
|
|
184
197
|
// prefix added to the vocabulary then both prunes and is verified, together.
|
|
@@ -191,24 +204,32 @@ if (WORKSPACE && pruned.removed.length > 0) {
|
|
|
191
204
|
);
|
|
192
205
|
}
|
|
193
206
|
try {
|
|
194
|
-
assertContainment(
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
207
|
+
assertContainment(
|
|
208
|
+
[
|
|
209
|
+
...Object.keys(SERVER_ROUTES),
|
|
210
|
+
...FETCH_ROUTES_SANDBOXED,
|
|
211
|
+
...(WORKSPACE ? [] : FETCH_ROUTES_FORBIDDEN_IN_WORKSPACE),
|
|
212
|
+
],
|
|
213
|
+
{
|
|
214
|
+
sandboxArmed: SANDBOX_ARMED,
|
|
215
|
+
// Presence of the dependency is the signal: a cell image that ships
|
|
216
|
+
// Playwright is one import() away from rendering tenant content. Skippable
|
|
217
|
+
// in a dev checkout, where Playwright is a legitimate devDependency of the
|
|
218
|
+
// E2E harness and would otherwise make workspace mode untestable locally.
|
|
219
|
+
// A BUILT cell image has no such escape — scripts/check-containment.sh
|
|
220
|
+
// enforces the runtime-dependency half at build time.
|
|
221
|
+
resolveModule:
|
|
222
|
+
process.env.MAUDE_WORKSPACE_ALLOW_DEV_MODULES === '1'
|
|
223
|
+
? undefined
|
|
224
|
+
: (specifier) => {
|
|
225
|
+
try {
|
|
226
|
+
return !!import.meta.resolveSync?.(specifier);
|
|
227
|
+
} catch {
|
|
228
|
+
return false;
|
|
229
|
+
}
|
|
230
|
+
},
|
|
231
|
+
}
|
|
232
|
+
);
|
|
212
233
|
} catch (err) {
|
|
213
234
|
console.error(`\n${(err as Error).message}\n`);
|
|
214
235
|
process.exit(1);
|
|
@@ -228,10 +249,15 @@ function startServer(port: number): BunServer {
|
|
|
228
249
|
const pathname = new URL(req.url).pathname;
|
|
229
250
|
|
|
230
251
|
// Containment (DDR-193 §2) — the `fetch` fall-through owns paths that are
|
|
231
|
-
// not in the route table (`/_ws/acp
|
|
232
|
-
//
|
|
233
|
-
//
|
|
234
|
-
//
|
|
252
|
+
// not in the route table (`/_ws/acp`), so pruning the table alone would
|
|
253
|
+
// leave them reachable. 404, not 403: a cell should look like it never had
|
|
254
|
+
// the feature, rather than like it is refusing one.
|
|
255
|
+
//
|
|
256
|
+
// `/_canvas-shell.html` and `/_canvas-runtime/*` used to be caught here
|
|
257
|
+
// too. DDR-209 A′1 reclassified them: a cell SERVES them (the browser is
|
|
258
|
+
// what evaluates), attested by the build sandbox at boot. They are not in
|
|
259
|
+
// `isForbiddenRoute` any more, so they fall through to `http.fetch` — on
|
|
260
|
+
// purpose, and the boot-assert is what keeps that honest.
|
|
235
261
|
if (WORKSPACE && isForbiddenRoute(pathname)) {
|
|
236
262
|
return new Response('not found', { status: 404 });
|
|
237
263
|
}
|
|
@@ -293,6 +319,12 @@ function startServer(port: number): BunServer {
|
|
|
293
319
|
id: crypto.randomUUID(),
|
|
294
320
|
remote: req.headers.get('x-forwarded-for') ?? '127.0.0.1',
|
|
295
321
|
kind: 'inspector',
|
|
322
|
+
// Cloud Phase 27 — stamp the role onto the socket at the handshake,
|
|
323
|
+
// the one moment the session is unambiguous. Fails CLOSED in a cell
|
|
324
|
+
// for the same reason the HTTP gate does: an absent header is an
|
|
325
|
+
// unproven session, and this channel can delete other people's
|
|
326
|
+
// comments.
|
|
327
|
+
readOnly: WORKSPACE ? req.headers.get('x-maude-readonly') !== '0' : false,
|
|
296
328
|
},
|
|
297
329
|
});
|
|
298
330
|
if (ok) return undefined as unknown as Response;
|
|
@@ -468,12 +500,25 @@ ctx.mainOrigin = `http://localhost:${server.port} http://127.0.0.1:${server.port
|
|
|
468
500
|
const CANVAS_ORIGIN_SPLIT = !/^(0|false|off|no)$/i.test(
|
|
469
501
|
process.env.MAUDE_CANVAS_ORIGIN_SPLIT ?? ''
|
|
470
502
|
);
|
|
471
|
-
// The canvas origin exists to SERVE
|
|
472
|
-
//
|
|
473
|
-
//
|
|
474
|
-
//
|
|
475
|
-
|
|
476
|
-
|
|
503
|
+
// The canvas origin exists to SERVE canvases into a segregated origin (DDR-063
|
|
504
|
+
// / DDR-054). A workspace cell used to skip it, back when a cell was forbidden
|
|
505
|
+
// to serve them at all. DDR-209 A′1 reverses that, and in a cell the split is
|
|
506
|
+
// not merely protective — it is the boundary between one tenant's executing
|
|
507
|
+
// canvas and everything else on that host, which is the strongest reason any
|
|
508
|
+
// deployment has ever had to keep it on.
|
|
509
|
+
const canvasServer = CANVAS_ORIGIN_SPLIT ? startCanvasServer(0) : null;
|
|
510
|
+
// D4 — PUBLIC IDENTITY COMES FROM CONFIGURATION, NEVER FROM THE REQUEST.
|
|
511
|
+
//
|
|
512
|
+
// The loopback origin is what this process BINDS; behind a reverse proxy it is
|
|
513
|
+
// not the address the member's browser can reach, and deriving it from the Host
|
|
514
|
+
// header is exactly the bug Cloud Phase 25 shipped into production twice (a
|
|
515
|
+
// member signing in was sent to an address that was not their project). So a
|
|
516
|
+
// cell states its canvas origin explicitly and we use it verbatim.
|
|
517
|
+
const canvasOrigin = process.env.MAUDE_PUBLIC_CANVAS_ORIGIN?.replace(/\/+$/, '')
|
|
518
|
+
? process.env.MAUDE_PUBLIC_CANVAS_ORIGIN.replace(/\/+$/, '')
|
|
519
|
+
: canvasServer
|
|
520
|
+
? `http://localhost:${canvasServer.port}`
|
|
521
|
+
: undefined;
|
|
477
522
|
if (canvasOrigin) ctx.canvasOrigin = canvasOrigin;
|
|
478
523
|
|
|
479
524
|
await Bun.write(
|
|
@@ -484,6 +529,10 @@ await Bun.write(
|
|
|
484
529
|
port: server.port,
|
|
485
530
|
url: `http://localhost:${server.port}`,
|
|
486
531
|
...(canvasOrigin ? { canvasOrigin } : {}),
|
|
532
|
+
// The port the canvas listener actually BOUND, as distinct from the
|
|
533
|
+
// public `canvasOrigin` name above. A co-located reverse proxy (the cell's
|
|
534
|
+
// hub) forwards canvas-origin traffic here; nothing else needs it.
|
|
535
|
+
...(canvasServer ? { canvasPort: canvasServer.port } : {}),
|
|
487
536
|
started: new Date().toISOString(),
|
|
488
537
|
project: ctx.cfg.name,
|
|
489
538
|
config_source: ctx.cfg._source,
|
|
@@ -553,11 +602,28 @@ console.log(` Design: ${ctx.paths.designRoot}`);
|
|
|
553
602
|
console.log(` Active: ${ctx.paths.activeFile}`);
|
|
554
603
|
console.log(' Press Ctrl+C to stop.\n');
|
|
555
604
|
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
605
|
+
// A workspace cell has nobody at the keyboard, and no browser to open — the
|
|
606
|
+
// member is already looking at this project through the proxy in front of it.
|
|
607
|
+
// Attempting it there was not merely pointless: the spawn threw ENOENT on a
|
|
608
|
+
// headless image and took the whole server down with it, which the supervisor
|
|
609
|
+
// then dutifully restarted into the same crash. Two fixes, because they are two
|
|
610
|
+
// separate mistakes: do not TRY in a cell, and do not DIE when it fails.
|
|
611
|
+
if (!process.env.NO_OPEN && !WORKSPACE) {
|
|
612
|
+
const opener =
|
|
613
|
+
process.platform === 'darwin' ? 'open' : process.platform === 'linux' ? 'xdg-open' : null;
|
|
614
|
+
if (opener) {
|
|
615
|
+
// `Bun.spawn`, not `node:child_process.spawn` — it reports a missing
|
|
616
|
+
// executable SYNCHRONOUSLY, so one try/catch genuinely covers it. The node
|
|
617
|
+
// shim signals ENOENT through an async 'error' event, which a try/catch
|
|
618
|
+
// does not catch and an unhandled listener turns into a process-level
|
|
619
|
+
// throw. That is precisely how a headless image with no `xdg-open` took the
|
|
620
|
+
// whole server down. (DDR-009 also prefers Bun.* here.)
|
|
621
|
+
try {
|
|
622
|
+
Bun.spawn([opener, url], { stdin: 'ignore', stdout: 'ignore', stderr: 'ignore' }).unref();
|
|
623
|
+
} catch {
|
|
624
|
+
console.log(` (could not open a browser automatically — visit ${url})`);
|
|
625
|
+
}
|
|
626
|
+
}
|
|
561
627
|
}
|
|
562
628
|
|
|
563
629
|
async function shutdown() {
|