@1agh/maude 0.53.1 → 0.54.0

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 (58) hide show
  1. package/apps/studio/api.ts +13 -7
  2. package/apps/studio/bin/screenshot.sh +173 -17
  3. package/apps/studio/build.ts +37 -3
  4. package/apps/studio/canvas-build-sandbox.ts +385 -0
  5. package/apps/studio/canvas-build-worker.ts +85 -0
  6. package/apps/studio/client/app.jsx +308 -21
  7. package/apps/studio/client/canvas-url.js +7 -0
  8. package/apps/studio/client/export-center.jsx +10 -2
  9. package/apps/studio/client/panels/SettingsPanel.jsx +37 -12
  10. package/apps/studio/client/report-bug.jsx +125 -30
  11. package/apps/studio/client/styles/4-components-maude.css +5 -0
  12. package/apps/studio/client/styles/4-components.css +75 -0
  13. package/apps/studio/cloud-build.ts +208 -0
  14. package/apps/studio/collab/room.ts +43 -7
  15. package/apps/studio/context.ts +12 -5
  16. package/apps/studio/dist/client.bundle.js +910 -910
  17. package/apps/studio/dist/comment-mount.js +2 -2
  18. package/apps/studio/dist/styles.css +1 -1
  19. package/apps/studio/git/repo-lock.ts +305 -0
  20. package/apps/studio/git/service.ts +171 -21
  21. package/apps/studio/http.ts +383 -31
  22. package/apps/studio/inspect.ts +66 -6
  23. package/apps/studio/paths.ts +8 -0
  24. package/apps/studio/server.ts +216 -53
  25. package/apps/studio/session-scope.ts +101 -0
  26. package/apps/studio/sync/autocommit.ts +84 -46
  27. package/apps/studio/sync/index.ts +20 -0
  28. package/apps/studio/test/canvas-build-sandbox.test.ts +69 -0
  29. package/apps/studio/test/canvas-origin-gate.test.ts +66 -0
  30. package/apps/studio/test/canvas-shell-base.test.ts +107 -0
  31. package/apps/studio/test/canvas-url.test.ts +26 -0
  32. package/apps/studio/test/cloud-build.test.ts +95 -0
  33. package/apps/studio/test/cloud-session-role.test.ts +131 -0
  34. package/apps/studio/test/cloud-shell-surfaces.test.ts +119 -0
  35. package/apps/studio/test/collab-readonly-gate.test.ts +194 -0
  36. package/apps/studio/test/config-projection.test.ts +104 -0
  37. package/apps/studio/test/git-system-engine.test.ts +135 -0
  38. package/apps/studio/test/read-only-gate.test.ts +58 -0
  39. package/apps/studio/test/repo-concurrency.test.ts +146 -0
  40. package/apps/studio/test/repo-lock.test.ts +350 -0
  41. package/apps/studio/test/server-lifecycle.test.ts +11 -1
  42. package/apps/studio/test/session-runtime-state.test.ts +226 -0
  43. package/apps/studio/test/session-scope.test.ts +100 -0
  44. package/apps/studio/test/shell-importmap.test.ts +8 -1
  45. package/apps/studio/test/sync-autocommit.test.ts +76 -0
  46. package/apps/studio/test/sync-hardening.test.ts +75 -0
  47. package/apps/studio/test/workspace-containment.test.ts +90 -11
  48. package/apps/studio/use-collab.tsx +21 -1
  49. package/apps/studio/whats-new.json +47 -2
  50. package/apps/studio/workspace-mode.ts +161 -28
  51. package/apps/studio/ws.ts +74 -8
  52. package/cli/commands/kg.mjs +49 -19
  53. package/cli/lib/gitignore-block.mjs +3 -0
  54. package/package.json +11 -9
  55. package/plugins/design/dependencies.json +1 -1
  56. package/plugins/design/templates/_shell.html +66 -32
  57. package/plugins/flow/.claude-plugin/config.schema.json +1 -1
  58. package/plugins/flow/dependencies.json +1 -1
@@ -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, realpathSync, 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';
@@ -80,17 +81,19 @@ import {
80
81
  import { createGitEndpoints } from './git/endpoints.ts';
81
82
  import { gitShowFile } from './git/service.ts';
82
83
  import { createGitHubEndpoints } from './github/endpoints.ts';
83
- import type { Inspect } from './inspect.ts';
84
+ import type { InspectRegistry } 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';
90
+ import { currentSession } from './session-scope.ts';
89
91
  import { linkHub } from './sync/hub-link.ts';
90
92
  import { isHubReadOnly } from './sync/hubs-config.ts';
91
93
  import { signInToWorkspace, workspaceDisclosure } from './sync/workspace-signin.ts';
92
94
  import { readUiPrefs, type UiPrefs, writeUiPrefs } from './ui-prefs.ts';
93
95
  import { loadWhatsNew, resolveMaudeVersion } from './whats-new.ts';
96
+ import { isWorkspaceMode } from './workspace-mode.ts';
94
97
  import { isLoopbackHost } from './ws.ts';
95
98
 
96
99
  // Real disk install root — never the virtual `/$bunfs/root` of compiled bins.
@@ -178,6 +181,25 @@ function ext(p: string): string {
178
181
  * do not read it as precedent for adding further hosts without the same
179
182
  * scrutiny (exact hostname, no wildcard subdomain, a DDR record).
180
183
  */
184
+ /**
185
+ * A stable, non-revealing name for the tree this process is serving.
186
+ *
187
+ * `realpath` first, so a bind-mount and a symlink to the same checkout are the
188
+ * same identity rather than two — the supervisor computes it from its own
189
+ * configured path and the two must agree. Falls back to the path as given when
190
+ * it cannot be resolved: an unresolvable root is a difference worth reporting,
191
+ * not one worth hiding behind a throw.
192
+ */
193
+ export function rootIdentity(root: string): string {
194
+ let resolved = root;
195
+ try {
196
+ resolved = realpathSync(root);
197
+ } catch {
198
+ /* not on disk (yet) — hash what we were told */
199
+ }
200
+ return createHash('sha256').update(resolved, 'utf8').digest('hex').slice(0, 12);
201
+ }
202
+
181
203
  export function cspForCanvasShell(html: string, mainOrigin?: string): string {
182
204
  const hashes: string[] = [];
183
205
  // Match inline <script> blocks only (no src=). `[^>]*` excludes any with src.
@@ -317,17 +339,48 @@ export const READ_ONLY_ALLOWED_WRITES = new Set([
317
339
  '/_api/export-jobs/download',
318
340
  '/_api/report', // bug reports are about Maude, not the project
319
341
  '/_api/report-fallback',
342
+ // Cloud Phase 27 — COMMENT IS THE ONE WRITE A VIEWER HOLDS. The role matrix
343
+ // has said so since Phase 25 C4 (`viewer.comment === true`), the People page
344
+ // promises it in those words, and the cell's proxy allows it — but this list
345
+ // did not, so a viewer's comment was accepted by the authority and then
346
+ // refused by the defence-in-depth layer behind it. Found by commenting as a
347
+ // viewer against a real cell. A second gate that is STRICTER than the first is
348
+ // still a gate that is wrong.
349
+ '/_comments',
320
350
  '/_api/hub/link', // link/unlink ≈ session management (cell allows /auth/logout)
321
351
  '/_api/workspace/sign-in', // signing in is how the role is (re)learned
322
352
  '/_api/workspace/disclosure',
323
353
  '/_api/cloud/signin/start', // account session, not project state
324
354
  '/_api/cloud/signin/poll',
325
355
  '/_api/cloud/signout',
326
- '/_api/git/fetch', // syncing the project DOWN is a read of the project
327
- '/_api/git/pull',
328
- '/_api/git/checkout', // viewing another branch, not changing one
356
+ // Fetch moves remote-tracking refs and nothing else — no working tree, no
357
+ // index. It stays a read.
358
+ '/_api/git/fetch',
359
+ // `pull` and `checkout` USED TO BE HERE, on the reasoning that "viewing
360
+ // another branch is not changing one". Cloud Phase 27 D2 retired that: both
361
+ // rewrite the working tree, and the tree is SHARED — in a cell by every
362
+ // member, and on a hub-linked desktop by whoever else has that project open.
363
+ // A viewer switching branches replaces the files under someone mid-edit.
364
+ //
365
+ // The proxy's manifest already refuses them, but this gate is the one the
366
+ // hub-linked desktop and self-host path consult (`isHubReadOnly`), where no
367
+ // manifest runs at all — so leaving them here would have left the hole open
368
+ // exactly where the proxy cannot reach. Two gates, and neither is trusted to
369
+ // be the last word.
329
370
  ]);
330
371
 
372
+ /**
373
+ * Write paths a read-only session may use whose members are GENERATED rather
374
+ * than declared, so an exact-match set cannot name them.
375
+ *
376
+ * Deliberately one entry. Every regex here is a hole nobody can see by reading
377
+ * the list above, so the bar for adding one is that an exact path genuinely
378
+ * cannot express it.
379
+ */
380
+ export const READ_ONLY_ALLOWED_WRITE_PATTERNS: RegExp[] = [
381
+ /^\/_api\/comments\/[A-Za-z0-9_-]+\/reply$/,
382
+ ];
383
+
331
384
  /** The refusal a read-only session gets for a project-mutating write. */
332
385
  export function readOnlyRefusalResponse(): Response {
333
386
  return Response.json(
@@ -502,6 +555,40 @@ async function serveCanvasTsx(
502
555
  const source = await file.text();
503
556
  const deps = localDepsFromSource(source, absPath, ctx.paths.designRoot);
504
557
  let result: Awaited<ReturnType<typeof buildCanvasModule>>;
558
+ // DDR-209 A′2 — SAME ENGINE, DIFFERENT HOST. On a desktop the process that
559
+ // parses your canvas is the process you own, so an in-process build costs
560
+ // nothing. In a cell it holds HUB_SECRET and the tenant's storage
561
+ // credentials, and the source is written by somebody who is not us — so the
562
+ // build goes out of process, with an empty environment, an import allowlist
563
+ // and wall-clock + RSS ceilings (Cloud Phase 25 A1's contract, unchanged).
564
+ // This branch is also what `sandboxArmed` in the containment boot-assert
565
+ // attests: the cell may serve the canvas surfaces BECAUSE this exists.
566
+ if (isWorkspaceMode()) {
567
+ const built = await buildCanvasSandboxed({
568
+ designRoot: ctx.paths.designRoot,
569
+ canvasAbs: absPath,
570
+ });
571
+ if (!built.ok) {
572
+ return new Response(`Canvas build error: ${built.error}`, {
573
+ status: built.kind === 'build' ? 422 : 500,
574
+ headers: { 'Content-Type': 'text/plain; charset=utf-8' },
575
+ });
576
+ }
577
+ result = {
578
+ js: built.js,
579
+ locator: built.locator,
580
+ etag: built.etag,
581
+ } as Awaited<ReturnType<typeof buildCanvasModule>>;
582
+ cached = {
583
+ sig: canvasFreshnessSig(absPath, deps),
584
+ etag: `${result.etag}-${RUNTIME_BOOT_ID}-${CHROME_EPOCH}`,
585
+ js: result.js,
586
+ deps,
587
+ };
588
+ canvasCache.set(absPath, cached);
589
+ await writeLocator(locatorAbsPath, canvasSlug(absPath, ctx.paths.designRoot), result.locator);
590
+ return respondWithCanvasModule(req, cached);
591
+ }
505
592
  try {
506
593
  result = await buildCanvasModule(absPath, source, {
507
594
  designRoot: ctx.paths.designRoot,
@@ -538,6 +625,13 @@ async function serveCanvasTsx(
538
625
  await writeLocator(locatorAbsPath, canvasSlug(absPath, ctx.paths.designRoot), result.locator);
539
626
  }
540
627
 
628
+ return respondWithCanvasModule(req, cached);
629
+ }
630
+
631
+ /** The conditional-GET half of serving a built canvas module. Shared by the
632
+ * in-process (desktop) and sandboxed (cell) build paths so the two cannot
633
+ * disagree about caching semantics. */
634
+ function respondWithCanvasModule(req: Request, cached: { js: string; etag: string }): Response {
541
635
  const ifNoneMatch = req.headers.get('if-none-match');
542
636
  if (ifNoneMatch === cached.etag) {
543
637
  return new Response(null, {
@@ -650,14 +744,70 @@ async function serveHistoricalCanvas(
650
744
  });
651
745
  }
652
746
 
747
+ /**
748
+ * How long a served file may be reused — Cloud Phase 27 B2.
749
+ *
750
+ * `no-store` on everything is correct on a laptop, where the round trip is a
751
+ * memcpy and the designer is editing the file you just served. Over the
752
+ * internet it means a teammate re-downloads every photograph on every pan, on
753
+ * a project whose media is 266 MB. So:
754
+ *
755
+ * content-addressed name → immutable, a year. The name IS the content
756
+ * (sha8), so a changed file is a changed URL and
757
+ * a stale answer is impossible by construction.
758
+ * everything else → revalidate (`no-cache` + ETag). A designer
759
+ * editing `hero.png` in place still sees their
760
+ * edit; the wire cost of not having changed it is
761
+ * a 304, not the file.
762
+ *
763
+ * Universal, not cloud-only: the rule is true on all three shells, and a
764
+ * caching policy that differs per shell is a bug report nobody can reproduce.
765
+ */
766
+ export function cacheControlFor(absPath: string): { cacheControl: string; addEtag: boolean } {
767
+ const name = absPath.slice(absPath.lastIndexOf('/') + 1);
768
+ // The shape every writer emits — timeline insert/replace/assemble, the asset
769
+ // upload route, `maude design fetch-asset`: <sha8>.<ext>, lowercase hex.
770
+ if (/^[0-9a-f]{8,64}\.[A-Za-z0-9]+$/.test(name)) {
771
+ return { cacheControl: 'public, max-age=31536000, immutable', addEtag: false };
772
+ }
773
+ return { cacheControl: 'no-cache', addEtag: true };
774
+ }
775
+
776
+ /** A weak validator from the file's own metadata — no read, no hash. */
777
+ function etagFor(file: { size: number; lastModified: number }): string {
778
+ return `W/"${file.size.toString(16)}-${Math.trunc(file.lastModified).toString(16)}"`;
779
+ }
780
+
781
+ /**
782
+ * An SVG is a DOCUMENT, and a document runs script.
783
+ *
784
+ * `image/svg+xml` is inert as an `<img>`, a CSS `url()` or a `<use>` target —
785
+ * none of those are browsing contexts. Navigate to one directly and it is a
786
+ * page, with `<script>` in it, executing on whatever origin served it. On the
787
+ * canvas origin that is a stored-XSS primitive: canvases carry the tenant's own
788
+ * SVGs, only the shell HTML gets a CSP, and every other static response here
789
+ * has carried `nosniff` and nothing else.
790
+ *
791
+ * `default-src 'none'; sandbox` costs the legitimate uses nothing (an image is
792
+ * not a document, so the policy never applies to them) and makes the
793
+ * navigate-to-it case a blank page instead of a foothold. Found by an
794
+ * adversarial pass as step one of a three-link cross-tenant chain; the other
795
+ * two links are closed in the same change, and this is the cheapest of the
796
+ * three to break.
797
+ */
798
+ const INERT_DOCUMENT_CSP = "default-src 'none'; sandbox";
799
+
653
800
  async function serveFile(absPath: string, headers: Record<string, string> = {}): Promise<Response> {
654
801
  const file = Bun.file(absPath);
655
802
  if (!(await file.exists())) return new Response('Not found', { status: 404 });
656
803
  const e = ext(absPath);
804
+ const policy = cacheControlFor(absPath);
657
805
  return new Response(file, {
658
806
  headers: {
659
807
  'Content-Type': MIME[e] || 'application/octet-stream',
660
- 'Cache-Control': 'no-store',
808
+ 'Cache-Control': policy.cacheControl,
809
+ ...(policy.addEtag ? { ETag: etagFor(file) } : {}),
810
+ ...(e === '.svg' ? { 'Content-Security-Policy': INERT_DOCUMENT_CSP } : {}),
661
811
  ...headers,
662
812
  },
663
813
  });
@@ -685,7 +835,9 @@ async function serveMediaFile(
685
835
  const size = file.size;
686
836
  const base = {
687
837
  'Content-Type': MIME[ext(absPath)] || 'application/octet-stream',
688
- 'Cache-Control': 'no-store',
838
+ // B2 — same policy as serveFile. Media is the heaviest thing a member
839
+ // fetches, so the content-addressed case matters most here.
840
+ 'Cache-Control': cacheControlFor(absPath).cacheControl,
689
841
  'Accept-Ranges': 'bytes',
690
842
  ...headers,
691
843
  };
@@ -738,11 +890,18 @@ export interface Http {
738
890
  export function createHttp(
739
891
  ctx: Context,
740
892
  api: Api,
741
- inspect: Inspect,
893
+ /** D3 — one inspector per member. `inspect()` resolves the ambient session's
894
+ * instance, which on a desktop is the single one this used to be. */
895
+ inspects: InspectRegistry,
742
896
  ai: AiActivity,
743
897
  exportJobs: ExportJobQueue,
744
898
  generateJobs: GenerationJobQueue
745
899
  ): Http {
900
+ /** The current request's inspector state (Cloud Phase 27 D3). Resolved per
901
+ * call rather than captured, because "whose" changes per request and a
902
+ * captured instance would hand one member another's open canvas. */
903
+ const inspect = () => inspects.for(currentSession());
904
+
746
905
  // Task 2.7 (approach A) — in-flight whisper-model download state (one at a
747
906
  // time), polled by the Settings "Download model" card via GET
748
907
  // /_api/generate/whisper-model. Closure-scoped: one server, one download.
@@ -905,7 +1064,7 @@ export function createHttp(
905
1064
  req: Request,
906
1065
  body: { format: Format; scope: Scope; options?: Record<string, unknown> }
907
1066
  ) {
908
- const activeJson = inspect.state as unknown as ActiveJsonShape;
1067
+ const activeJson = inspect().state as unknown as ActiveJsonShape;
909
1068
  return {
910
1069
  format: body.format,
911
1070
  scope: body.scope,
@@ -935,10 +1094,23 @@ export function createHttp(
935
1094
  ok: true,
936
1095
  app: 'design',
937
1096
  project: ctx.cfg.name,
1097
+ // WHICH TREE THIS PROCESS IS ACTUALLY SERVING — Cloud Phase 27 D5.
1098
+ //
1099
+ // A supervisor that only asks "did something answer" cannot tell a
1100
+ // studio serving the tenant's checkout from one serving whatever was
1101
+ // left in the working directory, and "boots, looks fine, serves the
1102
+ // wrong root" is exactly the mistake `studioLaunch` warns about one
1103
+ // process up. A tag is not an identity; a hash is.
1104
+ //
1105
+ // The HASH and not the path: this route is on the canvas origin's
1106
+ // allowlist, so the tenant's own untrusted code can read it, and a
1107
+ // server filesystem path is not something it should learn. The
1108
+ // supervisor hashes its own expected root and compares.
1109
+ rootId: rootIdentity(ctx.paths.repoRoot),
938
1110
  pid: process.pid,
939
1111
  }),
940
1112
 
941
- '/_active': () => Response.json(inspect.state),
1113
+ '/_active': () => Response.json(inspect().state),
942
1114
 
943
1115
  // Phase 31 (DDR-123) — ACP chat readiness. Cheap, side-effect-free probe
944
1116
  // (is the adapter present + is `claude` on PATH); no subprocess spawned.
@@ -1218,11 +1390,45 @@ export function createHttp(
1218
1390
  // knows before it draws anything. It decides what the UI OFFERS and is
1219
1391
  // never what stops a write — the cell enforces that (Phase 25 C1),
1220
1392
  // whatever a patched client believes.
1221
- '/_config': () =>
1393
+ '/_config': (req: Request) =>
1222
1394
  Response.json({
1223
1395
  ...ctx.cfg,
1224
1396
  canvasOrigin: ctx.canvasOrigin,
1225
- readOnly: projectReadOnly(),
1397
+ readOnly: projectReadOnly(req),
1398
+ // Cloud Phase 27 (DDR-209) — the capability that opens the cookieless
1399
+ // canvas origin, minted per session by the proxy. Absent on a desktop,
1400
+ // where the canvas origin is loopback and needs none. `canvasUrl()`
1401
+ // appends it exactly the way it already appends `readOnly`, which is
1402
+ // what keeps this a FLAG on the shared client rather than a fork of it.
1403
+ ...(req.headers.get('x-maude-canvas-token')
1404
+ ? { canvasToken: req.headers.get('x-maude-canvas-token') }
1405
+ : {}),
1406
+ // Cloud Phase 27 C2/C4 — the ONE cloud-only input the shared client
1407
+ // takes. Its presence says "you are in a browser tab, on somebody
1408
+ // else's machine", which is what lets the client state the agent's
1409
+ // absence and offer a way back to the dashboard. A flag, not a fork:
1410
+ // every other difference between the three shells stays zero.
1411
+ ...(isWorkspaceMode()
1412
+ ? {
1413
+ cloud: {
1414
+ dashboardUrl: process.env.HUB_DASHBOARD_URL ?? 'https://cloud.maude.sh',
1415
+ projectName: process.env.MAUDE_PROJECT_NAME ?? ctx.cfg.name ?? null,
1416
+ // WHO the tab is signed in as, and WHAT that makes them —
1417
+ // per REQUEST, from the proxy's injected headers, never from
1418
+ // this process's environment (one cell serves an owner and a
1419
+ // viewer at the same time).
1420
+ //
1421
+ // The owner who could not edit his own project could not see
1422
+ // why: the stamp said VIEW ONLY and nothing on screen said
1423
+ // which account that verdict was about. Naming the account and
1424
+ // the role turns "this is broken" into "I am signed in as the
1425
+ // wrong person", which is a thing someone can act on — and the
1426
+ // sign-out beside it is how they act on it.
1427
+ user: req.headers.get('x-maude-user') || null,
1428
+ role: req.headers.get('x-maude-role') || null,
1429
+ },
1430
+ }
1431
+ : {}),
1226
1432
  }),
1227
1433
 
1228
1434
  // What's New feed (DDR-A) — read-only product-update list surfaced in the
@@ -1244,13 +1450,95 @@ export function createHttp(
1244
1450
  buildDebugBundle({
1245
1451
  maudeVersion: resolveMaudeVersion(),
1246
1452
  projectName: ctx.cfg.name ?? null,
1247
- activeCanvas: (inspect.state as { active?: string | null }).active ?? null,
1453
+ activeCanvas: (inspect().state as { active?: string | null }).active ?? null,
1248
1454
  repoRoot: ctx.paths.repoRoot,
1249
1455
  }),
1250
1456
  { headers: { 'Cache-Control': 'no-store' } }
1251
1457
  );
1252
1458
  },
1253
1459
 
1460
+ // feature-bug-report-button — capture the STUDIO SHELL (menubar, sidebar,
1461
+ // status bar, toasts) as a PNG. `/_api/export` can only ever render a canvas
1462
+ // headlessly, so Maude's own chrome — where most reported UI bugs actually
1463
+ // live — has never been capturable from inside the app. This shells out to
1464
+ // the same `screenshot.sh` spine every `/design:*` capture uses, in its
1465
+ // `--shell` mode, so it inherits engine resolution (the desktop bundle's
1466
+ // agent-browser via MAUDE_AGENT_BROWSER, DDR-144), one-time browser
1467
+ // provisioning, and the playwright fallback.
1468
+ //
1469
+ // The capture is a SEPARATE headless session, not a mirror of the user's
1470
+ // window: it reproduces the app's chrome and the active canvas, but not
1471
+ // transient state (an open dropdown, a stuck spinner). That's why the
1472
+ // dialog keeps the manual attach/paste lane alongside this.
1473
+ //
1474
+ // PRIVILEGED: main-origin only + double gate — it spawns a process and
1475
+ // renders the project, so the untrusted canvas origin must never reach it
1476
+ // (absent from CANVAS_SAFE_API + startCanvasServer routes, per DDR-088).
1477
+ '/_api/shell-shot': async (req: Request) => {
1478
+ if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
1479
+ if (!sameOriginWrite(req)) return new Response('cross-origin rejected', { status: 403 });
1480
+ if (!isLoopbackHost(req.headers.get('host')))
1481
+ return new Response('local request required (DNS-rebinding guard)', { status: 403 });
1482
+ // Target the very server handling this request — no _server.json read, so
1483
+ // a stale/absent state file can't point the capture at another instance.
1484
+ const port = new URL(req.url).port;
1485
+ if (!/^\d+$/.test(port)) {
1486
+ return Response.json({ error: 'no port on the request URL' }, { status: 500 });
1487
+ }
1488
+ // Which canvas to open is the CLIENT's call, always sent explicitly —
1489
+ // `null` means "open nothing". The server's `_active.json` is global and
1490
+ // sticky (it outlives every closed tab and any session may write it), so
1491
+ // resolving it here would photograph a canvas the reporter never had open.
1492
+ const body = await readJson<{ canvas?: unknown }>(req);
1493
+ const canvas = typeof body?.canvas === 'string' ? body.canvas : '';
1494
+ const out = join(ctx.paths.designRoot, '_reports', `shell-${Date.now()}.png`);
1495
+ try {
1496
+ const proc = Bun.spawn(
1497
+ [
1498
+ 'bash',
1499
+ join(BIN_DIR, 'screenshot.sh'),
1500
+ '--shell',
1501
+ '--port',
1502
+ port,
1503
+ '--out',
1504
+ out,
1505
+ '--root',
1506
+ ctx.paths.repoRoot,
1507
+ '--canvas',
1508
+ canvas,
1509
+ ],
1510
+ { stdout: 'ignore', stderr: 'pipe' }
1511
+ );
1512
+ // Booting a browser, loading the studio, opening the active canvas and
1513
+ // painting it runs well past a default fetch timeout on a cold cache;
1514
+ // the dialog shows a pending row meanwhile. Kill rather than hang.
1515
+ const timer = setTimeout(() => proc.kill(), 60_000);
1516
+ const code = await proc.exited;
1517
+ clearTimeout(timer);
1518
+ if (code !== 0 || !existsSync(out)) {
1519
+ const why = (await new Response(proc.stderr).text())
1520
+ .trim()
1521
+ .split('\n')
1522
+ .slice(-3)
1523
+ .join(' ');
1524
+ return Response.json({ error: `shell capture failed: ${why}` }, { status: 502 });
1525
+ }
1526
+ const png = await Bun.file(out).arrayBuffer();
1527
+ // The PNG is handed to the client and never needed again — leaving it on
1528
+ // disk would quietly grow `_reports/` on every dialog open.
1529
+ try {
1530
+ unlinkSync(out);
1531
+ } catch {
1532
+ /* already gone — nothing to clean up */
1533
+ }
1534
+ return new Response(png, {
1535
+ headers: { 'content-type': 'image/png', 'Cache-Control': 'no-store' },
1536
+ });
1537
+ } catch (e) {
1538
+ return Response.json({ error: `shell capture failed: ${String(e)}` }, { status: 502 });
1539
+ }
1540
+ },
1541
+
1254
1542
  // feature-bug-report-button — submit proxy. The client never talks to
1255
1543
  // cloud.maude.sh directly (no CORS surface to open, and the endpoint can be
1256
1544
  // overridden for self-hosters/tests via MAUDE_REPORT_URL). Forwards the
@@ -1371,7 +1659,7 @@ export function createHttp(
1371
1659
  // the api layer — DDR-115). The layout lane mutates the versioned
1372
1660
  // `.meta.json`, so a read-only session is refused here, in-handler,
1373
1661
  // where the two lanes are distinguishable.
1374
- if (projectReadOnly() && 'layout' in body.patch) {
1662
+ if (projectReadOnly(req) && 'layout' in body.patch) {
1375
1663
  return readOnlyRefusalResponse();
1376
1664
  }
1377
1665
  const next = await api.patchCanvasMeta(body.file, body.patch);
@@ -1504,6 +1792,17 @@ export function createHttp(
1504
1792
  // identity. Color-hash derives from this; falls back to anonymous-<pid>
1505
1793
  // client-side when empty.
1506
1794
  if (req.method !== 'GET') return new Response('Method not allowed', { status: 405 });
1795
+ // Cloud Phase 27 — IN A CELL, `git config user.name` IS THE MACHINE.
1796
+ //
1797
+ // It is "Maude Workspace", the committer the autosave agent signs with
1798
+ // (sync/autocommit.ts), and it is the right answer for a commit and the
1799
+ // wrong one for a person: a member opened their project and the presence
1800
+ // chip introduced them as the server. The proxy already vouches who this
1801
+ // is per request, so in a cell that is who it is.
1802
+ const vouched = req.headers.get('x-maude-user');
1803
+ if (isWorkspaceMode() && vouched) {
1804
+ return Response.json({ name: vouched }, { headers: { 'Cache-Control': 'no-store' } });
1805
+ }
1507
1806
  const name = await api.gitCurrentUser();
1508
1807
  return Response.json({ name }, { headers: { 'Cache-Control': 'no-store' } });
1509
1808
  },
@@ -4072,17 +4371,13 @@ export function createHttp(
4072
4371
  if (RANGE_MEDIA_EXTS.has(ext(name))) {
4073
4372
  return serveMediaFile(abs, req, { 'X-Content-Type-Options': 'nosniff' });
4074
4373
  }
4075
- const f = Bun.file(abs);
4076
- if (await f.exists()) {
4077
- return new Response(f, {
4078
- headers: {
4079
- 'Content-Type': MIME[ext(name)] || 'application/octet-stream',
4080
- 'Cache-Control': 'no-store',
4081
- 'X-Content-Type-Options': 'nosniff',
4082
- },
4083
- });
4084
- }
4085
- return new Response('Not found', { status: 404 });
4374
+ // B2 — the SAME caching policy as every other static lane. This
4375
+ // route is the one a canvas's photographs actually come down, so
4376
+ // `no-store` here is the difference between a teammate re-fetching
4377
+ // 266 MB of media on every pan and re-fetching none of it. Content-
4378
+ // addressed names (`<sha8>.<ext>`, which is what every writer emits)
4379
+ // are immutable by construction; anything else revalidates.
4380
+ return serveFile(abs, { 'X-Content-Type-Options': 'nosniff' });
4086
4381
  }
4087
4382
  }
4088
4383
 
@@ -4181,15 +4476,38 @@ export function createHttp(
4181
4476
  return serveMediaFile(fp, req, { 'X-Content-Type-Options': 'nosniff' });
4182
4477
  }
4183
4478
  // Bun.file streams transparently for binary content.
4479
+ //
4480
+ // B2 — THIS is the lane a design system's own photographs and webfonts
4481
+ // come down (`system/<ds>/assets/graphics/camo-bg.png`, 446 kB;
4482
+ // `…/fonts/*.woff2`). `no-store` here meant a teammate re-downloaded all
4483
+ // of it on every pan, across the internet, on a project whose media is
4484
+ // 266 MB. Verified against the real one after the first cloud deploy said
4485
+ // `no-store` on exactly these files.
4486
+ //
4487
+ // `cacheControlFor` gives content-addressed names a year and everything
4488
+ // else a revalidation — so a designer editing `hero.png` in place still
4489
+ // sees the edit, at the cost of a 304 rather than the file.
4490
+ const policy = cacheControlFor(fp);
4184
4491
  return new Response(file, {
4185
4492
  headers: {
4186
4493
  'Content-Type': MIME[e] || 'application/octet-stream',
4187
- 'Cache-Control': 'no-store',
4494
+ 'Cache-Control': policy.cacheControl,
4495
+ ...(policy.addEtag
4496
+ ? {
4497
+ ETag: `W/"${file.size.toString(16)}-${Math.trunc(file.lastModified).toString(16)}"`,
4498
+ }
4499
+ : {}),
4188
4500
  // DDR-088 follow-up — never let a browser MIME-sniff a served file
4189
4501
  // (e.g. an uploaded GIF/WEBP polyglot) into a richer type. Assets are
4190
4502
  // only referenced via <image href> + the canvas CSP blocks script, so
4191
4503
  // this is defense-in-depth on the static lane.
4192
4504
  'X-Content-Type-Options': 'nosniff',
4505
+ // …and `nosniff` is not enough for the one type that is a DOCUMENT
4506
+ // whether you sniff it or not. See INERT_DOCUMENT_CSP: an SVG
4507
+ // NAVIGATED TO is a page with `<script>` in it, executing on this
4508
+ // origin, and "the canvas CSP blocks script" above is true only of
4509
+ // the shell — not of the asset served on its own.
4510
+ ...(e === '.svg' ? { 'Content-Security-Policy': INERT_DOCUMENT_CSP } : {}),
4193
4511
  },
4194
4512
  });
4195
4513
  } catch (err) {
@@ -4201,7 +4519,7 @@ export function createHttp(
4201
4519
  async function serveCanvasShell(applyCsp: boolean, capture = false): Promise<Response> {
4202
4520
  const shellHtml = await Bun.file(join(TEMPLATES_DIR, '_shell.html')).text();
4203
4521
  // Inject inspector overlay — Cmd+Click selection + add-comment flow.
4204
- const injected = inspect.injectInspector(shellHtml);
4522
+ const injected = inspect().injectInspector(shellHtml);
4205
4523
  const headers: Record<string, string> = {
4206
4524
  'Content-Type': 'text/html; charset=utf-8',
4207
4525
  'Cache-Control': 'no-store',
@@ -4304,7 +4622,22 @@ export function createHttp(
4304
4622
  if (safe.startsWith(designPrefix)) {
4305
4623
  const rest = safe.slice(designPrefix.length);
4306
4624
  // Reject runtime/state dirs+files (_comments, _sync.json, _history, …).
4307
- if (rest.split('/').some((seg) => seg.startsWith('_'))) return false;
4625
+ //
4626
+ // FIRST SEGMENT ONLY, and that is the taxonomy rather than a relaxation.
4627
+ // Every runtime-state path DDR-115 names lives at the top level of the
4628
+ // designRoot — `_history/`, `_canvas-state/`, `_state/`, `_chat/`,
4629
+ // `_comments/`, `_untrusted/`, `_trash/`, `_draw/`, `_smoke/`,
4630
+ // `_server.json`, `_active.json` — so a first-segment test rejects the
4631
+ // whole of it, including everything nested underneath.
4632
+ //
4633
+ // Testing EVERY segment additionally rejected files that are versioned,
4634
+ // shipped, and required: a design system's `system/<ds>/preview/
4635
+ // _components.css` is the stylesheet the shell itself names in the
4636
+ // iframe URL, and the underscore there is the DS's own convention for
4637
+ // "aggregate, not a specimen". It 403'd, so every canvas rendered with
4638
+ // its component styles missing and looked broken in a way that pointed
4639
+ // nowhere near this line.
4640
+ if (rest.split('/')[0].startsWith('_')) return false;
4308
4641
  return CANVAS_ASSET_EXTS.has(ext(safe));
4309
4642
  }
4310
4643
  // DDR-150 dogfood — `/assets/<file>`: the canvas-RELATIVE form every writer
@@ -4327,13 +4660,28 @@ export function createHttp(
4327
4660
  // covers all three doors at once: the main-origin `routes` table, the
4328
4661
  // canvas-origin `routes` allowlist in server.ts (it references these same
4329
4662
  // handlers), and the dynamic-path `fetch` fall-through (comment replies).
4330
- function projectReadOnly(): boolean {
4663
+ function projectReadOnly(req?: Request): boolean {
4664
+ // ---- Cloud Phase 27 A3/A4 (DDR-209): the role is PER SESSION ----------
4665
+ //
4666
+ // In a cell this process serves an owner and a viewer at the same time, so
4667
+ // the on-disk answer below — one role per hub URL — is not merely stale, it
4668
+ // is the wrong SHAPE. The proxy in front vouches a role per request and
4669
+ // injects the capability it derived from the one role table.
4670
+ //
4671
+ // AND IT DEFAULTS CLOSED. The local path fails OPEN by design: `catch`
4672
+ // returns false, an unset `linkedHub` returns false, and a fully writable
4673
+ // studio is the correct answer for a tool running on your own laptop. On
4674
+ // the internet it is the whole ballgame — so in a cloud build read-only is
4675
+ // the default and an edit role requires positive proof.
4676
+ if (isWorkspaceMode()) {
4677
+ return req?.headers.get('x-maude-readonly') !== '0';
4678
+ }
4331
4679
  return ctx.cfg.linkedHub ? isHubReadOnly(ctx.cfg.linkedHub.url) : false;
4332
4680
  }
4333
4681
 
4334
4682
  function readOnlyRefusal(req: Request): Response | null {
4335
4683
  if (READ_ONLY_SAFE_METHODS.has(req.method)) return null;
4336
- if (!projectReadOnly()) return null;
4684
+ if (!projectReadOnly(req)) return null;
4337
4685
  let pathname: string;
4338
4686
  try {
4339
4687
  pathname = new URL(req.url).pathname;
@@ -4341,6 +4689,10 @@ export function createHttp(
4341
4689
  return readOnlyRefusalResponse();
4342
4690
  }
4343
4691
  if (READ_ONLY_ALLOWED_WRITES.has(pathname)) return null;
4692
+ // The dynamic half of the comment lane. An exact-match set cannot express
4693
+ // it, and leaving it out would mean a viewer may leave a comment but not
4694
+ // reply to one — a distinction nobody promised and nobody wants.
4695
+ if (READ_ONLY_ALLOWED_WRITE_PATTERNS.some((re) => re.test(pathname))) return null;
4344
4696
  return readOnlyRefusalResponse();
4345
4697
  }
4346
4698