@1agh/maude 0.53.2 → 0.55.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 (55) hide show
  1. package/apps/studio/annotations-layer.tsx +11 -1
  2. package/apps/studio/api.ts +13 -7
  3. package/apps/studio/bin/screenshot.sh +43 -3
  4. package/apps/studio/build.ts +37 -3
  5. package/apps/studio/canvas-build-sandbox.ts +101 -9
  6. package/apps/studio/canvas-build-worker.ts +7 -1
  7. package/apps/studio/canvas-build.ts +9 -1
  8. package/apps/studio/client/app.jsx +226 -25
  9. package/apps/studio/client/export-center.jsx +10 -2
  10. package/apps/studio/client/github.js +7 -0
  11. package/apps/studio/client/panels/CloudBar.jsx +461 -40
  12. package/apps/studio/client/panels/SettingsPanel.jsx +6 -1
  13. package/apps/studio/client/styles/3-shell-maude.css +30 -0
  14. package/apps/studio/client/styles/4-components.css +40 -0
  15. package/apps/studio/cloud/endpoints.ts +48 -6
  16. package/apps/studio/cloud-build.ts +208 -0
  17. package/apps/studio/collab/room.ts +43 -7
  18. package/apps/studio/context.ts +12 -5
  19. package/apps/studio/dist/client.bundle.js +983 -983
  20. package/apps/studio/dist/comment-mount.js +2 -2
  21. package/apps/studio/dist/styles.css +1 -1
  22. package/apps/studio/git/repo-lock.ts +305 -0
  23. package/apps/studio/git/service.ts +171 -21
  24. package/apps/studio/http.ts +159 -13
  25. package/apps/studio/inspect.ts +66 -6
  26. package/apps/studio/server.ts +114 -17
  27. package/apps/studio/session-scope.ts +101 -0
  28. package/apps/studio/sync/autocommit.ts +84 -46
  29. package/apps/studio/sync/index.ts +20 -0
  30. package/apps/studio/test/canvas-origin-gate.test.ts +61 -0
  31. package/apps/studio/test/canvas-shell-base.test.ts +107 -0
  32. package/apps/studio/test/cloud-build.test.ts +95 -0
  33. package/apps/studio/test/cloud-endpoints.test.ts +231 -4
  34. package/apps/studio/test/cloud-shell-surfaces.test.ts +31 -2
  35. package/apps/studio/test/collab-readonly-gate.test.ts +194 -0
  36. package/apps/studio/test/git-system-engine.test.ts +135 -0
  37. package/apps/studio/test/read-only-gate.test.ts +8 -0
  38. package/apps/studio/test/repo-concurrency.test.ts +146 -0
  39. package/apps/studio/test/repo-lock.test.ts +350 -0
  40. package/apps/studio/test/server-lifecycle.test.ts +11 -1
  41. package/apps/studio/test/session-runtime-state.test.ts +226 -0
  42. package/apps/studio/test/session-scope.test.ts +100 -0
  43. package/apps/studio/test/shell-importmap.test.ts +8 -1
  44. package/apps/studio/test/sync-autocommit.test.ts +76 -0
  45. package/apps/studio/test/sync-hardening.test.ts +75 -0
  46. package/apps/studio/use-collab.tsx +21 -1
  47. package/apps/studio/whats-new.json +54 -0
  48. package/apps/studio/ws.ts +44 -8
  49. package/cli/commands/kg.mjs +49 -19
  50. package/cli/lib/gitignore-block.mjs +3 -0
  51. package/package.json +11 -9
  52. package/plugins/design/dependencies.json +1 -1
  53. package/plugins/design/templates/_shell.html +66 -32
  54. package/plugins/flow/.claude-plugin/config.schema.json +1 -1
  55. 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 {
@@ -22,7 +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
+ import { buildCanvasSandboxed, buildStats } from './canvas-build-sandbox.ts';
26
26
  import { canvasLibPath } from './canvas-lib-resolver.ts';
27
27
  import { TranspileError } from './canvas-pipeline.ts';
28
28
  import { createCloudEndpoints } from './cloud/endpoints.ts';
@@ -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,36 @@ 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,
1111
+ // Cloud Phase 26 Stage 4 — the build sandbox's own counters, so the
1112
+ // €3/cell model finally has figures instead of a prediction. Counts and
1113
+ // durations ONLY: never a canvas name, never a path, and never the text
1114
+ // of a rejected import specifier (that specifier is tenant-authored
1115
+ // content — the operational fact is that a rejection happened, not what
1116
+ // it said).
1117
+ //
1118
+ // On `/_health`, which the untrusted canvas origin may read, and that
1119
+ // is deliberate: every figure here is about the reader's OWN cell, so
1120
+ // it discloses nothing across a tenant boundary. Inventing a privileged
1121
+ // route would have meant inventing an auth path for the supervisor's
1122
+ // loopback probe, for data the tenant already has.
1123
+ render: buildStats(),
1040
1124
  }),
1041
1125
 
1042
- '/_active': () => Response.json(inspect.state),
1126
+ '/_active': () => Response.json(inspect().state),
1043
1127
 
1044
1128
  // Phase 31 (DDR-123) — ACP chat readiness. Cheap, side-effect-free probe
1045
1129
  // (is the adapter present + is `claude` on PATH); no subprocess spawned.
@@ -1342,6 +1426,19 @@ export function createHttp(
1342
1426
  cloud: {
1343
1427
  dashboardUrl: process.env.HUB_DASHBOARD_URL ?? 'https://cloud.maude.sh',
1344
1428
  projectName: process.env.MAUDE_PROJECT_NAME ?? ctx.cfg.name ?? null,
1429
+ // WHO the tab is signed in as, and WHAT that makes them —
1430
+ // per REQUEST, from the proxy's injected headers, never from
1431
+ // this process's environment (one cell serves an owner and a
1432
+ // viewer at the same time).
1433
+ //
1434
+ // The owner who could not edit his own project could not see
1435
+ // why: the stamp said VIEW ONLY and nothing on screen said
1436
+ // which account that verdict was about. Naming the account and
1437
+ // the role turns "this is broken" into "I am signed in as the
1438
+ // wrong person", which is a thing someone can act on — and the
1439
+ // sign-out beside it is how they act on it.
1440
+ user: req.headers.get('x-maude-user') || null,
1441
+ role: req.headers.get('x-maude-role') || null,
1345
1442
  },
1346
1443
  }
1347
1444
  : {}),
@@ -1366,7 +1463,7 @@ export function createHttp(
1366
1463
  buildDebugBundle({
1367
1464
  maudeVersion: resolveMaudeVersion(),
1368
1465
  projectName: ctx.cfg.name ?? null,
1369
- activeCanvas: (inspect.state as { active?: string | null }).active ?? null,
1466
+ activeCanvas: (inspect().state as { active?: string | null }).active ?? null,
1370
1467
  repoRoot: ctx.paths.repoRoot,
1371
1468
  }),
1372
1469
  { headers: { 'Cache-Control': 'no-store' } }
@@ -1708,6 +1805,17 @@ export function createHttp(
1708
1805
  // identity. Color-hash derives from this; falls back to anonymous-<pid>
1709
1806
  // client-side when empty.
1710
1807
  if (req.method !== 'GET') return new Response('Method not allowed', { status: 405 });
1808
+ // Cloud Phase 27 — IN A CELL, `git config user.name` IS THE MACHINE.
1809
+ //
1810
+ // It is "Maude Workspace", the committer the autosave agent signs with
1811
+ // (sync/autocommit.ts), and it is the right answer for a commit and the
1812
+ // wrong one for a person: a member opened their project and the presence
1813
+ // chip introduced them as the server. The proxy already vouches who this
1814
+ // is per request, so in a cell that is who it is.
1815
+ const vouched = req.headers.get('x-maude-user');
1816
+ if (isWorkspaceMode() && vouched) {
1817
+ return Response.json({ name: vouched }, { headers: { 'Cache-Control': 'no-store' } });
1818
+ }
1711
1819
  const name = await api.gitCurrentUser();
1712
1820
  return Response.json({ name }, { headers: { 'Cache-Control': 'no-store' } });
1713
1821
  },
@@ -4381,15 +4489,38 @@ export function createHttp(
4381
4489
  return serveMediaFile(fp, req, { 'X-Content-Type-Options': 'nosniff' });
4382
4490
  }
4383
4491
  // Bun.file streams transparently for binary content.
4492
+ //
4493
+ // B2 — THIS is the lane a design system's own photographs and webfonts
4494
+ // come down (`system/<ds>/assets/graphics/camo-bg.png`, 446 kB;
4495
+ // `…/fonts/*.woff2`). `no-store` here meant a teammate re-downloaded all
4496
+ // of it on every pan, across the internet, on a project whose media is
4497
+ // 266 MB. Verified against the real one after the first cloud deploy said
4498
+ // `no-store` on exactly these files.
4499
+ //
4500
+ // `cacheControlFor` gives content-addressed names a year and everything
4501
+ // else a revalidation — so a designer editing `hero.png` in place still
4502
+ // sees the edit, at the cost of a 304 rather than the file.
4503
+ const policy = cacheControlFor(fp);
4384
4504
  return new Response(file, {
4385
4505
  headers: {
4386
4506
  'Content-Type': MIME[e] || 'application/octet-stream',
4387
- 'Cache-Control': 'no-store',
4507
+ 'Cache-Control': policy.cacheControl,
4508
+ ...(policy.addEtag
4509
+ ? {
4510
+ ETag: `W/"${file.size.toString(16)}-${Math.trunc(file.lastModified).toString(16)}"`,
4511
+ }
4512
+ : {}),
4388
4513
  // DDR-088 follow-up — never let a browser MIME-sniff a served file
4389
4514
  // (e.g. an uploaded GIF/WEBP polyglot) into a richer type. Assets are
4390
4515
  // only referenced via <image href> + the canvas CSP blocks script, so
4391
4516
  // this is defense-in-depth on the static lane.
4392
4517
  'X-Content-Type-Options': 'nosniff',
4518
+ // …and `nosniff` is not enough for the one type that is a DOCUMENT
4519
+ // whether you sniff it or not. See INERT_DOCUMENT_CSP: an SVG
4520
+ // NAVIGATED TO is a page with `<script>` in it, executing on this
4521
+ // origin, and "the canvas CSP blocks script" above is true only of
4522
+ // the shell — not of the asset served on its own.
4523
+ ...(e === '.svg' ? { 'Content-Security-Policy': INERT_DOCUMENT_CSP } : {}),
4393
4524
  },
4394
4525
  });
4395
4526
  } catch (err) {
@@ -4401,7 +4532,7 @@ export function createHttp(
4401
4532
  async function serveCanvasShell(applyCsp: boolean, capture = false): Promise<Response> {
4402
4533
  const shellHtml = await Bun.file(join(TEMPLATES_DIR, '_shell.html')).text();
4403
4534
  // Inject inspector overlay — Cmd+Click selection + add-comment flow.
4404
- const injected = inspect.injectInspector(shellHtml);
4535
+ const injected = inspect().injectInspector(shellHtml);
4405
4536
  const headers: Record<string, string> = {
4406
4537
  'Content-Type': 'text/html; charset=utf-8',
4407
4538
  'Cache-Control': 'no-store',
@@ -4504,7 +4635,22 @@ export function createHttp(
4504
4635
  if (safe.startsWith(designPrefix)) {
4505
4636
  const rest = safe.slice(designPrefix.length);
4506
4637
  // Reject runtime/state dirs+files (_comments, _sync.json, _history, …).
4507
- if (rest.split('/').some((seg) => seg.startsWith('_'))) return false;
4638
+ //
4639
+ // FIRST SEGMENT ONLY, and that is the taxonomy rather than a relaxation.
4640
+ // Every runtime-state path DDR-115 names lives at the top level of the
4641
+ // designRoot — `_history/`, `_canvas-state/`, `_state/`, `_chat/`,
4642
+ // `_comments/`, `_untrusted/`, `_trash/`, `_draw/`, `_smoke/`,
4643
+ // `_server.json`, `_active.json` — so a first-segment test rejects the
4644
+ // whole of it, including everything nested underneath.
4645
+ //
4646
+ // Testing EVERY segment additionally rejected files that are versioned,
4647
+ // shipped, and required: a design system's `system/<ds>/preview/
4648
+ // _components.css` is the stylesheet the shell itself names in the
4649
+ // iframe URL, and the underscore there is the DS's own convention for
4650
+ // "aggregate, not a specimen". It 403'd, so every canvas rendered with
4651
+ // its component styles missing and looked broken in a way that pointed
4652
+ // nowhere near this line.
4653
+ if (rest.split('/')[0].startsWith('_')) return false;
4508
4654
  return CANVAS_ASSET_EXTS.has(ext(safe));
4509
4655
  }
4510
4656
  // 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