@1agh/maude 0.53.2 → 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 (46) hide show
  1. package/apps/studio/api.ts +13 -7
  2. package/apps/studio/bin/screenshot.sh +43 -3
  3. package/apps/studio/build.ts +37 -3
  4. package/apps/studio/client/app.jsx +208 -25
  5. package/apps/studio/client/export-center.jsx +10 -2
  6. package/apps/studio/client/panels/SettingsPanel.jsx +6 -1
  7. package/apps/studio/client/styles/4-components.css +40 -0
  8. package/apps/studio/cloud-build.ts +208 -0
  9. package/apps/studio/collab/room.ts +43 -7
  10. package/apps/studio/context.ts +12 -5
  11. package/apps/studio/dist/client.bundle.js +1007 -1007
  12. package/apps/studio/dist/comment-mount.js +2 -2
  13. package/apps/studio/dist/styles.css +1 -1
  14. package/apps/studio/git/repo-lock.ts +305 -0
  15. package/apps/studio/git/service.ts +171 -21
  16. package/apps/studio/http.ts +145 -12
  17. package/apps/studio/inspect.ts +66 -6
  18. package/apps/studio/server.ts +114 -17
  19. package/apps/studio/session-scope.ts +101 -0
  20. package/apps/studio/sync/autocommit.ts +84 -46
  21. package/apps/studio/sync/index.ts +20 -0
  22. package/apps/studio/test/canvas-origin-gate.test.ts +61 -0
  23. package/apps/studio/test/canvas-shell-base.test.ts +107 -0
  24. package/apps/studio/test/cloud-build.test.ts +95 -0
  25. package/apps/studio/test/cloud-shell-surfaces.test.ts +31 -2
  26. package/apps/studio/test/collab-readonly-gate.test.ts +194 -0
  27. package/apps/studio/test/git-system-engine.test.ts +135 -0
  28. package/apps/studio/test/read-only-gate.test.ts +8 -0
  29. package/apps/studio/test/repo-concurrency.test.ts +146 -0
  30. package/apps/studio/test/repo-lock.test.ts +350 -0
  31. package/apps/studio/test/server-lifecycle.test.ts +11 -1
  32. package/apps/studio/test/session-runtime-state.test.ts +226 -0
  33. package/apps/studio/test/session-scope.test.ts +100 -0
  34. package/apps/studio/test/shell-importmap.test.ts +8 -1
  35. package/apps/studio/test/sync-autocommit.test.ts +76 -0
  36. package/apps/studio/test/sync-hardening.test.ts +75 -0
  37. package/apps/studio/use-collab.tsx +21 -1
  38. package/apps/studio/whats-new.json +36 -0
  39. package/apps/studio/ws.ts +44 -8
  40. package/cli/commands/kg.mjs +49 -19
  41. package/cli/lib/gitignore-block.mjs +3 -0
  42. package/package.json +11 -9
  43. package/plugins/design/dependencies.json +1 -1
  44. package/plugins/design/templates/_shell.html +66 -32
  45. package/plugins/flow/.claude-plugin/config.schema.json +1 -1
  46. 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, unlinkSync, 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 {
@@ -81,12 +81,13 @@ import {
81
81
  import { createGitEndpoints } from './git/endpoints.ts';
82
82
  import { gitShowFile } from './git/service.ts';
83
83
  import { createGitHubEndpoints } from './github/endpoints.ts';
84
- import type { Inspect } from './inspect.ts';
84
+ import type { InspectRegistry } from './inspect.ts';
85
85
  import { canvasSlug, writeLocator } from './locator.ts';
86
86
  import { BIN_DIR, DEV_SERVER_ROOT, MEDIA_DIR, STICKERS_DIR } from './paths.ts';
87
87
  import { createPhotoStore, PHOTO_EDIT_MAX_BYTES } from './photo-store.ts';
88
88
  import { probeReadiness } from './readiness.ts';
89
89
  import { getRuntimeBundle, packageForSlug } from './runtime-bundle.ts';
90
+ import { currentSession } from './session-scope.ts';
90
91
  import { linkHub } from './sync/hub-link.ts';
91
92
  import { isHubReadOnly } from './sync/hubs-config.ts';
92
93
  import { signInToWorkspace, workspaceDisclosure } from './sync/workspace-signin.ts';
@@ -180,6 +181,25 @@ function ext(p: string): string {
180
181
  * do not read it as precedent for adding further hosts without the same
181
182
  * scrutiny (exact hostname, no wildcard subdomain, a DDR record).
182
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
+
183
203
  export function cspForCanvasShell(html: string, mainOrigin?: string): string {
184
204
  const hashes: string[] = [];
185
205
  // Match inline <script> blocks only (no src=). `[^>]*` excludes any with src.
@@ -333,9 +353,20 @@ export const READ_ONLY_ALLOWED_WRITES = new Set([
333
353
  '/_api/cloud/signin/start', // account session, not project state
334
354
  '/_api/cloud/signin/poll',
335
355
  '/_api/cloud/signout',
336
- '/_api/git/fetch', // syncing the project DOWN is a read of the project
337
- '/_api/git/pull',
338
- '/_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.
339
370
  ]);
340
371
 
341
372
  /**
@@ -747,6 +778,25 @@ function etagFor(file: { size: number; lastModified: number }): string {
747
778
  return `W/"${file.size.toString(16)}-${Math.trunc(file.lastModified).toString(16)}"`;
748
779
  }
749
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
+
750
800
  async function serveFile(absPath: string, headers: Record<string, string> = {}): Promise<Response> {
751
801
  const file = Bun.file(absPath);
752
802
  if (!(await file.exists())) return new Response('Not found', { status: 404 });
@@ -757,6 +807,7 @@ async function serveFile(absPath: string, headers: Record<string, string> = {}):
757
807
  'Content-Type': MIME[e] || 'application/octet-stream',
758
808
  'Cache-Control': policy.cacheControl,
759
809
  ...(policy.addEtag ? { ETag: etagFor(file) } : {}),
810
+ ...(e === '.svg' ? { 'Content-Security-Policy': INERT_DOCUMENT_CSP } : {}),
760
811
  ...headers,
761
812
  },
762
813
  });
@@ -839,11 +890,18 @@ export interface Http {
839
890
  export function createHttp(
840
891
  ctx: Context,
841
892
  api: Api,
842
- 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,
843
896
  ai: AiActivity,
844
897
  exportJobs: ExportJobQueue,
845
898
  generateJobs: GenerationJobQueue
846
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
+
847
905
  // Task 2.7 (approach A) — in-flight whisper-model download state (one at a
848
906
  // time), polled by the Settings "Download model" card via GET
849
907
  // /_api/generate/whisper-model. Closure-scoped: one server, one download.
@@ -1006,7 +1064,7 @@ export function createHttp(
1006
1064
  req: Request,
1007
1065
  body: { format: Format; scope: Scope; options?: Record<string, unknown> }
1008
1066
  ) {
1009
- const activeJson = inspect.state as unknown as ActiveJsonShape;
1067
+ const activeJson = inspect().state as unknown as ActiveJsonShape;
1010
1068
  return {
1011
1069
  format: body.format,
1012
1070
  scope: body.scope,
@@ -1036,10 +1094,23 @@ export function createHttp(
1036
1094
  ok: true,
1037
1095
  app: 'design',
1038
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),
1039
1110
  pid: process.pid,
1040
1111
  }),
1041
1112
 
1042
- '/_active': () => Response.json(inspect.state),
1113
+ '/_active': () => Response.json(inspect().state),
1043
1114
 
1044
1115
  // Phase 31 (DDR-123) — ACP chat readiness. Cheap, side-effect-free probe
1045
1116
  // (is the adapter present + is `claude` on PATH); no subprocess spawned.
@@ -1342,6 +1413,19 @@ export function createHttp(
1342
1413
  cloud: {
1343
1414
  dashboardUrl: process.env.HUB_DASHBOARD_URL ?? 'https://cloud.maude.sh',
1344
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,
1345
1429
  },
1346
1430
  }
1347
1431
  : {}),
@@ -1366,7 +1450,7 @@ export function createHttp(
1366
1450
  buildDebugBundle({
1367
1451
  maudeVersion: resolveMaudeVersion(),
1368
1452
  projectName: ctx.cfg.name ?? null,
1369
- activeCanvas: (inspect.state as { active?: string | null }).active ?? null,
1453
+ activeCanvas: (inspect().state as { active?: string | null }).active ?? null,
1370
1454
  repoRoot: ctx.paths.repoRoot,
1371
1455
  }),
1372
1456
  { headers: { 'Cache-Control': 'no-store' } }
@@ -1708,6 +1792,17 @@ export function createHttp(
1708
1792
  // identity. Color-hash derives from this; falls back to anonymous-<pid>
1709
1793
  // client-side when empty.
1710
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
+ }
1711
1806
  const name = await api.gitCurrentUser();
1712
1807
  return Response.json({ name }, { headers: { 'Cache-Control': 'no-store' } });
1713
1808
  },
@@ -4381,15 +4476,38 @@ export function createHttp(
4381
4476
  return serveMediaFile(fp, req, { 'X-Content-Type-Options': 'nosniff' });
4382
4477
  }
4383
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);
4384
4491
  return new Response(file, {
4385
4492
  headers: {
4386
4493
  'Content-Type': MIME[e] || 'application/octet-stream',
4387
- '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
+ : {}),
4388
4500
  // DDR-088 follow-up — never let a browser MIME-sniff a served file
4389
4501
  // (e.g. an uploaded GIF/WEBP polyglot) into a richer type. Assets are
4390
4502
  // only referenced via <image href> + the canvas CSP blocks script, so
4391
4503
  // this is defense-in-depth on the static lane.
4392
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 } : {}),
4393
4511
  },
4394
4512
  });
4395
4513
  } catch (err) {
@@ -4401,7 +4519,7 @@ export function createHttp(
4401
4519
  async function serveCanvasShell(applyCsp: boolean, capture = false): Promise<Response> {
4402
4520
  const shellHtml = await Bun.file(join(TEMPLATES_DIR, '_shell.html')).text();
4403
4521
  // Inject inspector overlay — Cmd+Click selection + add-comment flow.
4404
- const injected = inspect.injectInspector(shellHtml);
4522
+ const injected = inspect().injectInspector(shellHtml);
4405
4523
  const headers: Record<string, string> = {
4406
4524
  'Content-Type': 'text/html; charset=utf-8',
4407
4525
  'Cache-Control': 'no-store',
@@ -4504,7 +4622,22 @@ export function createHttp(
4504
4622
  if (safe.startsWith(designPrefix)) {
4505
4623
  const rest = safe.slice(designPrefix.length);
4506
4624
  // Reject runtime/state dirs+files (_comments, _sync.json, _history, …).
4507
- 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;
4508
4641
  return CANVAS_ASSET_EXTS.has(ext(safe));
4509
4642
  }
4510
4643
  // DDR-150 dogfood — `/assets/<file>`: the canvas-RELATIVE form every writer
@@ -4,6 +4,7 @@
4
4
  import path from 'node:path';
5
5
 
6
6
  import type { Context } from './context.ts';
7
+ import { normalizeSessionKey, sessionFile } from './session-scope.ts';
7
8
 
8
9
  export interface SelectedElement {
9
10
  file: string;
@@ -96,6 +97,8 @@ type SetSelectedInput =
96
97
  | null;
97
98
 
98
99
  export interface Inspect {
100
+ /** Whose state this is — `''` for the shared singleton (D3). */
101
+ sessionKey: string;
99
102
  state: ActiveState;
100
103
  load(): Promise<void>;
101
104
  setActive(file: string): void;
@@ -126,9 +129,16 @@ const NEW = (): ActiveState => ({
126
129
 
127
130
  export function createInspect(
128
131
  ctx: Context,
129
- loadActiveComments: (file: string) => Promise<unknown[]>
132
+ loadActiveComments: (file: string) => Promise<unknown[]>,
133
+ /**
134
+ * Whose state this is — Cloud Phase 27 D3. `''` is the shared singleton every
135
+ * desktop has always had; a non-empty key gives one member of a cell their
136
+ * own selection, their own open tab, and their own `_active.<key>.json`.
137
+ */
138
+ sessionKey = ''
130
139
  ): Inspect {
131
140
  const state: ActiveState = NEW();
141
+ const activeFile = sessionFile(ctx.paths.activeFile, sessionKey);
132
142
  let saveQueued = false;
133
143
 
134
144
  async function save() {
@@ -144,7 +154,7 @@ export function createInspect(
144
154
  }
145
155
  }
146
156
  const enriched = { ...state, active_comments };
147
- await Bun.write(ctx.paths.activeFile, JSON.stringify(enriched, null, 2));
157
+ await Bun.write(activeFile, JSON.stringify(enriched, null, 2));
148
158
  } catch (e) {
149
159
  const msg = e instanceof Error ? e.message : String(e);
150
160
  console.error(' warn: failed to save _active.json:', msg);
@@ -159,7 +169,7 @@ export function createInspect(
159
169
 
160
170
  async function load() {
161
171
  try {
162
- const raw = await Bun.file(ctx.paths.activeFile).text();
172
+ const raw = await Bun.file(activeFile).text();
163
173
  const prev = JSON.parse(raw);
164
174
  Object.assign(state, prev, { session_started: new Date().toISOString() });
165
175
  // Pre-selections _active.json (or a hand-edited one) → keep the invariant.
@@ -222,10 +232,10 @@ export function createInspect(
222
232
  state.selected = file ? restoreFor(file) : null;
223
233
  state.last_change = new Date().toISOString();
224
234
  scheduleSave();
225
- ctx.bus.emit('active', state.active);
235
+ ctx.bus.emit('active', state.active, { session: sessionKey });
226
236
  // Clients (StatusBar, shell halo, chat context chip) must see the restored
227
237
  // selection, not assume the pre-switch null.
228
- ctx.bus.emit('selected', state.selected);
238
+ ctx.bus.emit('selected', state.selected, { session: sessionKey });
229
239
  }
230
240
 
231
241
  function setOpenTabs(tabs: string[]) {
@@ -320,7 +330,7 @@ export function createInspect(
320
330
  }
321
331
  state.last_change = new Date().toISOString();
322
332
  scheduleSave();
323
- ctx.bus.emit('selected', state.selected);
333
+ ctx.bus.emit('selected', state.selected, { session: sessionKey });
324
334
  }
325
335
 
326
336
  function retarget(fromFile: string, toFile: string): boolean {
@@ -379,6 +389,7 @@ export function createInspect(
379
389
  }
380
390
 
381
391
  return {
392
+ sessionKey,
382
393
  state,
383
394
  load,
384
395
  setActive,
@@ -390,6 +401,55 @@ export function createInspect(
390
401
  };
391
402
  }
392
403
 
404
+ /**
405
+ * One `Inspect` per member — Cloud Phase 27 D3.
406
+ *
407
+ * A desktop asks for `for('')` forever and gets the single instance it always
408
+ * had. A cell asks with the proxy's vouched session key and gets one per
409
+ * member, so an owner and a viewer stop overwriting each other's open tab and
410
+ * selection.
411
+ *
412
+ * Instances are created on demand and kept: they are small (one state object),
413
+ * a member reconnects to the same key across reloads, and evicting one would
414
+ * lose exactly the state this exists to preserve. The key space is bounded by
415
+ * the project's membership, not by anything a client can invent —
416
+ * `normalizeSessionKey` rejects a value that is not proxy-shaped, and the proxy
417
+ * strips inbound `x-maude-*` before injecting its own.
418
+ */
419
+ export interface InspectRegistry {
420
+ /** The instance for this session key, created on first use. */
421
+ for(sessionKey?: string | null): Inspect;
422
+ /** Every live instance. */
423
+ all(): Inspect[];
424
+ }
425
+
426
+ export function createInspectRegistry(
427
+ ctx: Context,
428
+ loadActiveComments: (file: string) => Promise<unknown[]>
429
+ ): InspectRegistry {
430
+ const instances = new Map<string, Inspect>();
431
+
432
+ function get(sessionKey?: string | null): Inspect {
433
+ const key = normalizeSessionKey(sessionKey);
434
+ let found = instances.get(key);
435
+ if (!found) {
436
+ found = createInspect(ctx, loadActiveComments, key);
437
+ instances.set(key, found);
438
+ // A member returning after a reload picks their own place back up. Fire
439
+ // and forget: `load()` is a best-effort read of a file that usually does
440
+ // not exist yet, and blocking a request on it would trade a correctness
441
+ // nicety for latency on every first touch.
442
+ if (key) void found.load();
443
+ }
444
+ return found;
445
+ }
446
+
447
+ return {
448
+ for: get,
449
+ all: () => [...instances.values()],
450
+ };
451
+ }
452
+
393
453
  // ---------- Inspector script injection ----------
394
454
 
395
455
  function injectInspector(html: string): string {
@@ -31,8 +31,9 @@ import { createFsWatch } from './fs-watch.ts';
31
31
  import { createGenerationJobQueue } from './generation/jobs.ts';
32
32
  import { createGitWatch } from './git/watch.ts';
33
33
  import { createHttp } from './http.ts';
34
- import { createInspect } from './inspect.ts';
34
+ import { createInspectRegistry } from './inspect.ts';
35
35
  import { startHeapWatch } from './mem.ts';
36
+ import { normalizeSessionKey, runInSession, SESSION_HEADER } from './session-scope.ts';
36
37
  import { createSyncRuntime } from './sync/index.ts';
37
38
  import {
38
39
  assertContainment,
@@ -71,7 +72,7 @@ let collab: ReturnType<typeof createCollab> | null = null;
71
72
  // Forward-declared for the same reason — moveCanvas (feature-file-tree-
72
73
  // drag-drop-folders, Task 3) retargets `_active.json` through the live
73
74
  // Inspect instance, which is constructed after `api`.
74
- let inspectHandle: ReturnType<typeof createInspect> | null = null;
75
+ let inspectHandle: ReturnType<typeof createInspectRegistry> | null = null;
75
76
 
76
77
  const api = createApi(ctx, {
77
78
  onCommentsChanged: async (file) => {
@@ -100,13 +101,17 @@ const api = createApi(ctx, {
100
101
  if (collab) await collab.registry.forceDrop(slug);
101
102
  },
102
103
  retargetActive: (fromFile, toFile) => {
103
- inspectHandle?.retarget(fromFile, toFile);
104
+ // D3 — a canvas moved on disk moved for EVERY member, so every member's
105
+ // state is retargeted, not just the one whose request did it.
106
+ for (const one of inspectHandle?.all() ?? []) one.retarget(fromFile, toFile);
104
107
  },
105
108
  });
106
109
 
107
- const inspect = createInspect(ctx, (file) => api.loadCommentsForFile(file));
108
- inspectHandle = inspect;
109
- await inspect.load();
110
+ const inspects = createInspectRegistry(ctx, (file) => api.loadCommentsForFile(file));
111
+ inspectHandle = inspects;
112
+ // The shared instance is the desktop's, and a cell's fallback for anything that
113
+ // arrives without a vouched session.
114
+ await inspects.for('').load();
110
115
 
111
116
  collab = createCollab(ctx, api);
112
117
  const aiActivity = createAiActivity(ctx);
@@ -134,11 +139,11 @@ const activity = createActivity(ctx);
134
139
  // editing" banner + presence as /design:edit (RC5,
135
140
  // rca/issue-canvas-hmr-optimistic-update-consistency).
136
141
  const acp = createAcp(ctx, aiActivity);
137
- const ws = createWs(ctx, api, inspect, collab, activity, acp);
142
+ const ws = createWs(ctx, api, inspects, collab, activity, acp);
138
143
  const exportJobs = createExportJobQueue(ctx.bus, ctx.paths.designRoot);
139
144
  // feature-ai-media-generation (DDR-16x) — background AI-media generation queue.
140
145
  const generateJobs = createGenerationJobQueue(ctx.bus, ctx.paths.designRoot);
141
- const http = createHttp(ctx, api, inspect, aiActivity, exportJobs, generateJobs);
146
+ const http = createHttp(ctx, api, inspects, aiActivity, exportJobs, generateJobs);
142
147
  const fsWatch = createFsWatch(ctx);
143
148
 
144
149
  // Port: --port arg > $PORT > $MDCC_DEV_PORT > 4399.
@@ -196,7 +201,51 @@ const SANDBOX_ARMED = isSandboxArmed();
196
201
  // post-condition on the pruning rather than a second, driftable opinion. A
197
202
  // prefix added to the vocabulary then both prunes and is verified, together.
198
203
  const pruned = WORKSPACE ? pruneForWorkspace(http.routes) : { routes: http.routes, removed: [] };
199
- const SERVER_ROUTES = pruned.routes as typeof http.routes;
204
+ /**
205
+ * Establish WHOSE request this is, once, at the only place every request passes
206
+ * through — Cloud Phase 27 D3.
207
+ *
208
+ * Bun matches `routes` BEFORE `fetch`, so a wrapper on the fall-through alone
209
+ * would miss the entire route table (the same asymmetry that made a
210
+ * canvas-origin route 404 in Phase 23). Wrapping here covers both halves, and
211
+ * the leaves — `canvasViewPath`, `canvasStatePath` — read the ambient key
212
+ * instead of every function between here and there growing a parameter.
213
+ *
214
+ * Outside workspace mode this resolves to `''` on every request, which
215
+ * `runInSession` treats as "no scope at all" and skips.
216
+ */
217
+ function withSession<T extends unknown[]>(
218
+ handler: (req: Request, ...rest: T) => Response | Promise<Response>
219
+ ): (req: Request, ...rest: T) => Response | Promise<Response> {
220
+ return (req, ...rest) =>
221
+ runInSession(WORKSPACE ? normalizeSessionKey(req.headers.get(SESSION_HEADER)) : '', () =>
222
+ handler(req, ...rest)
223
+ );
224
+ }
225
+
226
+ /** Bun route entries are either a handler or a `{ GET, POST, … }` map. */
227
+ function scopeRoutes<R extends Record<string, unknown>>(routes: R): R {
228
+ const out: Record<string, unknown> = {};
229
+ for (const [path, entry] of Object.entries(routes)) {
230
+ if (typeof entry === 'function') {
231
+ out[path] = withSession(entry as (req: Request) => Response | Promise<Response>);
232
+ } else if (entry && typeof entry === 'object') {
233
+ const byMethod: Record<string, unknown> = {};
234
+ for (const [method, fn] of Object.entries(entry as Record<string, unknown>)) {
235
+ byMethod[method] =
236
+ typeof fn === 'function'
237
+ ? withSession(fn as (req: Request) => Response | Promise<Response>)
238
+ : fn;
239
+ }
240
+ out[path] = byMethod;
241
+ } else {
242
+ out[path] = entry;
243
+ }
244
+ }
245
+ return out as R;
246
+ }
247
+
248
+ const SERVER_ROUTES = scopeRoutes(pruned.routes) as typeof http.routes;
200
249
  if (WORKSPACE && pruned.removed.length > 0) {
201
250
  console.log(
202
251
  `[studio] workspace mode — withheld ${pruned.removed.length} route(s) that would evaluate ` +
@@ -265,9 +314,20 @@ function startServer(port: number): BunServer {
265
314
  // Phase 8 — collab WS, binary y-websocket protocol. Loopback-only;
266
315
  // DDR-047 makes cross-machine collab a Phase 9 hub-deploy story, not
267
316
  // a `--bind 0.0.0.0` flag on this server.
317
+ //
318
+ // IN A CELL the loopback gate can never pass — the hub's authenticating
319
+ // proxy rewrites Host to the public URL (D4) on purpose — and it must not
320
+ // have to: the proxy already terminated the session and vouches the role
321
+ // per request with injected `x-maude-*` headers (stripped-then-injected,
322
+ // the Cloud Phase 27 model the inspector branch below already trusts).
323
+ // Without this vouched path every cloud collab upgrade 403'd here and
324
+ // cursors/annotations/live-sync were dead in the browser door — RCA
325
+ // issue-cloud-live-collaboration-dead. Outside workspace mode the DDR-047
326
+ // gate is verbatim unchanged.
268
327
  const collabSlug = parseCollabSlug(pathname);
269
328
  if (collabSlug !== null) {
270
- if (!isLoopbackHost(req.headers.get('host'))) {
329
+ const vouchedRole = WORKSPACE ? req.headers.get('x-maude-role') : null;
330
+ if (!isLoopbackHost(req.headers.get('host')) && !vouchedRole) {
271
331
  return new Response('cross-machine collab requires Phase 9 hub deploy', {
272
332
  status: 403,
273
333
  });
@@ -278,8 +338,15 @@ function startServer(port: number): BunServer {
278
338
  remote: req.headers.get('x-forwarded-for') ?? '127.0.0.1',
279
339
  kind: 'collab',
280
340
  slug: collabSlug,
281
- // Privileged shell origin — ungated (see collab/origins.ts).
282
- realm: 'main',
341
+ // Privileged shell origin — ungated (see collab/origins.ts) —
342
+ // UNLESS the proxy marked this socket as canvas-realm (a cell's
343
+ // canvas lane forwards here when the canvas listener is the same
344
+ // process; defense in depth either way).
345
+ realm:
346
+ WORKSPACE && req.headers.get('x-maude-collab-realm') === 'canvas' ? 'canvas' : 'main',
347
+ // Same fail-closed posture as the inspector branch: in a cell an
348
+ // absent header is an unproven session.
349
+ readOnly: WORKSPACE ? req.headers.get('x-maude-readonly') !== '0' : false,
283
350
  },
284
351
  });
285
352
  if (ok) return undefined as unknown as Response;
@@ -319,6 +386,9 @@ function startServer(port: number): BunServer {
319
386
  id: crypto.randomUUID(),
320
387
  remote: req.headers.get('x-forwarded-for') ?? '127.0.0.1',
321
388
  kind: 'inspector',
389
+ // D3 — whose socket this is, from the proxy's vouched header. Same
390
+ // handshake-time reasoning as `readOnly` below; `''` on a desktop.
391
+ session: WORKSPACE ? normalizeSessionKey(req.headers.get(SESSION_HEADER)) : '',
322
392
  // Cloud Phase 27 — stamp the role onto the socket at the handshake,
323
393
  // the one moment the session is unambiguous. Fails CLOSED in a cell
324
394
  // for the same reason the HTTP gate does: an absent header is an
@@ -330,7 +400,8 @@ function startServer(port: number): BunServer {
330
400
  if (ok) return undefined as unknown as Response;
331
401
  return new Response('Upgrade failed', { status: 400 });
332
402
  }
333
- return http.fetch(req);
403
+ // D3 — the fall-through half of the same scope the route table gets above.
404
+ return withSession(http.fetch)(req);
334
405
  },
335
406
  websocket: ws.handler,
336
407
  error(e) {
@@ -382,10 +453,15 @@ function startCanvasServer(port: number): BunServer {
382
453
  async fetch(req, srv) {
383
454
  const pathname = new URL(req.url).pathname;
384
455
 
385
- // Collab WS — shared registry, loopback-only (same gate as the main origin).
456
+ // Collab WS — shared registry, loopback-only (same gate as the main
457
+ // origin), with the same workspace-mode vouched path: in a cell the hub's
458
+ // canvas lane forwards the iframe's collab socket HERE, capability-
459
+ // authenticated, with Host rewritten to the public canvas origin (D4) —
460
+ // RCA issue-cloud-live-collaboration-dead.
386
461
  const collabSlug = parseCollabSlug(pathname);
387
462
  if (collabSlug !== null) {
388
- if (!isLoopbackHost(req.headers.get('host'))) {
463
+ const vouchedRole = WORKSPACE ? req.headers.get('x-maude-role') : null;
464
+ if (!isLoopbackHost(req.headers.get('host')) && !vouchedRole) {
389
465
  return new Response('cross-machine collab requires Phase 9 hub deploy', { status: 403 });
390
466
  }
391
467
  const ok = srv.upgrade(req, {
@@ -396,8 +472,13 @@ function startCanvasServer(port: number): BunServer {
396
472
  slug: collabSlug,
397
473
  // UNTRUSTED canvas iframe origin (DDR-063 split). Every sync frame
398
474
  // from here goes through the origin gate and may never write a
399
- // body lane — DDR-122 follow-up, collab/origins.ts.
475
+ // body lane — DDR-122 follow-up, collab/origins.ts. ALWAYS
476
+ // 'canvas' on this listener — the proxy's realm marker is not
477
+ // consulted, so a forged header cannot promote the socket.
400
478
  realm: 'canvas',
479
+ // Fail closed in a cell (absent header = unproven session);
480
+ // loopback desktop keeps full capability as before.
481
+ readOnly: WORKSPACE ? req.headers.get('x-maude-readonly') !== '0' : false,
401
482
  },
402
483
  });
403
484
  if (ok) return undefined as unknown as Response;
@@ -483,7 +564,23 @@ let server: BunServer;
483
564
  // listed: the server binds 127.0.0.1, so a user who opens the printed URL as
484
565
  // `127.0.0.1:<port>` is the same legit embedder — with only `localhost` allowed
485
566
  // the canvas iframe was silently refused (blank sad-page, no error anywhere).
486
- ctx.mainOrigin = `http://localhost:${server.port} http://127.0.0.1:${server.port}`;
567
+ //
568
+ // D4 AGAIN — the PUBLIC name, not the bound one. In a cell the parent frame is
569
+ // `https://<project>.cloud.maude.sh`; listing only the loopback origins made the
570
+ // browser refuse to render the canvas iframe at all ("refused to connect"),
571
+ // which looks like a network failure and is a CSP one. The loopback spellings
572
+ // stay for local tooling (screenshots, smoke) — a cell simply has one more legit
573
+ // embedder, and naming it is configuration, never a Host header.
574
+ const publicShellOrigin = (() => {
575
+ const url = process.env.HUB_PUBLIC_URL;
576
+ if (!url) return '';
577
+ try {
578
+ return ` ${new URL(url).origin}`;
579
+ } catch {
580
+ return '';
581
+ }
582
+ })();
583
+ ctx.mainOrigin = `http://localhost:${server.port} http://127.0.0.1:${server.port}${publicShellOrigin}`;
487
584
 
488
585
  // T2 (9.1-A) — segregated canvas-content origin. ON BY DEFAULT (opt-OUT) since
489
586
  // phase-9.1: a second listener binds an OS-assigned free port, advertised as