@1agh/maude 0.48.0 → 0.49.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 (33) hide show
  1. package/apps/studio/api.ts +18 -2
  2. package/apps/studio/assets-s3.ts +291 -0
  3. package/apps/studio/collab/origins.ts +150 -0
  4. package/apps/studio/collab/protocol.ts +36 -9
  5. package/apps/studio/collab/room.ts +124 -0
  6. package/apps/studio/context.ts +9 -0
  7. package/apps/studio/dist/client.bundle.js +1 -1
  8. package/apps/studio/dist/comment-mount.js +2 -2
  9. package/apps/studio/http.ts +45 -0
  10. package/apps/studio/server.ts +75 -2
  11. package/apps/studio/sync/autocommit.ts +299 -0
  12. package/apps/studio/sync/doc-name.ts +228 -0
  13. package/apps/studio/sync/index.ts +95 -1
  14. package/apps/studio/sync/workspace-signin.ts +301 -0
  15. package/apps/studio/test/assets-s3.test.ts +249 -0
  16. package/apps/studio/test/canvas-origin-gate.test.ts +7 -0
  17. package/apps/studio/test/collab-origin-gate.test.ts +323 -0
  18. package/apps/studio/test/sync-autocommit.test.ts +334 -0
  19. package/apps/studio/test/sync-doc-name.test.ts +281 -0
  20. package/apps/studio/test/workspace-containment.test.ts +258 -0
  21. package/apps/studio/test/workspace-signin.test.ts +270 -0
  22. package/apps/studio/use-collab.tsx +28 -1
  23. package/apps/studio/workspace-mode.ts +210 -0
  24. package/apps/studio/ws.ts +11 -2
  25. package/cli/commands/hub-workspace.mjs +341 -0
  26. package/cli/commands/hub.mjs +325 -3
  27. package/cli/commands/hub.test.mjs +14 -1
  28. package/cli/lib/cell-plan.mjs +302 -0
  29. package/cli/lib/cell-plan.test.mjs +225 -0
  30. package/cli/lib/gitignore-block.mjs +12 -1
  31. package/cli/lib/workspace-plan.mjs +422 -0
  32. package/cli/lib/workspace-plan.test.mjs +223 -0
  33. package/package.json +8 -8
@@ -0,0 +1,270 @@
1
+ // Cloud Phase 3 Task 3 — "Sign in to workspace".
2
+ //
3
+ // The persona is the invited teammate who has never used git. So alongside the
4
+ // mechanics (probe, login, credential custody) these tests assert the WORDS:
5
+ // a flow that says "paste your bearer token" has already told that person the
6
+ // product isn't for them (DDR-193 §5).
7
+
8
+ import { describe, expect, test } from 'bun:test';
9
+
10
+ import {
11
+ type DisclosureItem,
12
+ signInToWorkspace,
13
+ workspaceDisclosure,
14
+ } from '../sync/workspace-signin.ts';
15
+
16
+ /** A fetch stand-in scripted per URL suffix, recording what it was called with. */
17
+ function scriptedFetch(routes: Record<string, () => Response>) {
18
+ const calls: Array<{ url: string; init?: RequestInit }> = [];
19
+ const impl = (async (url: string | URL | Request, init?: RequestInit) => {
20
+ const href = String(url);
21
+ calls.push({ url: href, init });
22
+ for (const [suffix, make] of Object.entries(routes)) {
23
+ if (href.endsWith(suffix)) return make();
24
+ }
25
+ throw new Error(`unrouted ${href}`);
26
+ }) as unknown as typeof fetch;
27
+ return { impl, calls };
28
+ }
29
+
30
+ const json = (body: unknown, status = 200) =>
31
+ new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
32
+
33
+ const HEALTHY = () => json({ ok: true, version: '0.48.0' });
34
+ const LOGGED_IN = () =>
35
+ json({
36
+ token: 'mau_0123456789abcdef0123456789abcdef',
37
+ label: 'u-abcdef012345',
38
+ expiresAt: 1_800_000_000_000,
39
+ user: { email: 'alice@example.com', role: 'member' },
40
+ });
41
+
42
+ const CREDS = {
43
+ url: 'https://team.example.com',
44
+ email: 'alice@example.com',
45
+ password: 'hunter2222222',
46
+ };
47
+
48
+ describe('input validation speaks to a person, not a developer', () => {
49
+ test('each missing field names what to do next', async () => {
50
+ const saved: string[] = [];
51
+ const save = (u: string) => void saved.push(u);
52
+ for (const [input, expected] of [
53
+ [{}, /address your team sent you/i],
54
+ [{ url: 'https://x.example.com' }, /email address/i],
55
+ [{ url: 'https://x.example.com', email: 'a@b.com' }, /password/i],
56
+ ] as const) {
57
+ const res = await signInToWorkspace(input, { save, fetchImpl: scriptedFetch({}).impl });
58
+ expect(res.status).toBe(400);
59
+ expect(res.json.ok).toBe(false);
60
+ expect((res.json as { error: string }).error).toMatch(expected);
61
+ }
62
+ expect(saved).toEqual([]);
63
+ });
64
+
65
+ test('a non-http address is refused before anything is sent', async () => {
66
+ const { impl, calls } = scriptedFetch({});
67
+ const res = await signInToWorkspace(
68
+ { ...CREDS, url: 'file:///etc/passwd' },
69
+ { fetchImpl: impl, save: () => {} }
70
+ );
71
+ expect(res.status).toBe(400);
72
+ expect(calls).toEqual([]);
73
+ });
74
+ });
75
+
76
+ describe('credential custody', () => {
77
+ test('the health probe is TOKENLESS and carries no password', async () => {
78
+ // A lookalike address must never receive a credential (phase-29 A2).
79
+ const { impl, calls } = scriptedFetch({ '/health': HEALTHY, '/auth/login': LOGGED_IN });
80
+ await signInToWorkspace(CREDS, { fetchImpl: impl, save: () => {} });
81
+
82
+ const probe = calls.find((c) => c.url.endsWith('/health'));
83
+ expect(probe).toBeDefined();
84
+ expect(probe?.init?.body).toBeUndefined();
85
+ expect(JSON.stringify(probe?.init?.headers ?? {})).not.toContain('Authorization');
86
+ });
87
+
88
+ test('the password goes ONLY to the typed address, once, and is never stored', async () => {
89
+ const stored: Array<{ url: string; token: string }> = [];
90
+ const { impl, calls } = scriptedFetch({ '/health': HEALTHY, '/auth/login': LOGGED_IN });
91
+ await signInToWorkspace(CREDS, {
92
+ fetchImpl: impl,
93
+ save: (url, token) => void stored.push({ url, token }),
94
+ });
95
+
96
+ const withPassword = calls.filter((c) => String(c.init?.body ?? '').includes('hunter2222222'));
97
+ expect(withPassword).toHaveLength(1);
98
+ expect(withPassword[0]?.url).toBe('https://team.example.com/auth/login');
99
+
100
+ // What is persisted is the minted TOKEN — never the password.
101
+ expect(stored).toHaveLength(1);
102
+ expect(stored[0]?.token).toBe('mau_0123456789abcdef0123456789abcdef');
103
+ expect(JSON.stringify(stored)).not.toContain('hunter2222222');
104
+ });
105
+
106
+ test('nothing is saved when sign-in fails', async () => {
107
+ const stored: string[] = [];
108
+ const { impl } = scriptedFetch({
109
+ '/health': HEALTHY,
110
+ '/auth/login': () => json({ error: 'invalid email or password' }, 401),
111
+ });
112
+ const res = await signInToWorkspace(CREDS, {
113
+ fetchImpl: impl,
114
+ save: (u) => void stored.push(u),
115
+ });
116
+ expect(res.status).toBe(401);
117
+ expect(stored).toEqual([]);
118
+ });
119
+ });
120
+
121
+ describe('failure modes are distinguishable and actionable', () => {
122
+ test('an unreachable address says so, and never reaches login', async () => {
123
+ const { impl, calls } = scriptedFetch({
124
+ '/health': () => {
125
+ throw new Error('ECONNREFUSED');
126
+ },
127
+ });
128
+ const res = await signInToWorkspace(CREDS, { fetchImpl: impl, save: () => {} });
129
+ expect((res.json as { reason: string }).reason).toBe('unreachable');
130
+ expect((res.json as { error: string }).error).toMatch(/couldn't reach/i);
131
+ expect(calls.some((c) => c.url.includes('/auth/login'))).toBe(false);
132
+ });
133
+
134
+ test('an address that answers but is not a Maude workspace is named as such', async () => {
135
+ // A typo'd address that lands on someone else's server would otherwise fail
136
+ // later as a mysterious auth error.
137
+ const { impl } = scriptedFetch({ '/health': () => json({ status: 'ok, but not maude' }) });
138
+ const res = await signInToWorkspace(CREDS, { fetchImpl: impl, save: () => {} });
139
+ expect((res.json as { reason: string }).reason).toBe('not-a-maude-workspace');
140
+ expect((res.json as { error: string }).error).toMatch(/isn't a Maude workspace/i);
141
+ });
142
+
143
+ test('bad credentials do not guess WHICH half was wrong', async () => {
144
+ // The hub refuses to be a user-existence oracle, so the client must not
145
+ // invent one by saying "wrong password" when the account may not exist.
146
+ const { impl } = scriptedFetch({
147
+ '/health': HEALTHY,
148
+ '/auth/login': () => json({ error: 'invalid email or password' }, 401),
149
+ });
150
+ const res = await signInToWorkspace(CREDS, { fetchImpl: impl, save: () => {} });
151
+ const error = (res.json as { error: string }).error;
152
+ expect(error).not.toMatch(/no such (user|account)/i);
153
+ expect(error).not.toMatch(/wrong password/i);
154
+ expect(error).toMatch(/not accepted/i);
155
+ });
156
+
157
+ test('rate limiting says to wait rather than to retry', async () => {
158
+ const { impl } = scriptedFetch({
159
+ '/health': HEALTHY,
160
+ '/auth/login': () => json({ error: 'rate limit exceeded' }, 429),
161
+ });
162
+ const res = await signInToWorkspace(CREDS, { fetchImpl: impl, save: () => {} });
163
+ expect((res.json as { reason: string }).reason).toBe('rate-limited');
164
+ expect((res.json as { error: string }).error).toMatch(/wait a minute/i);
165
+ });
166
+
167
+ test('a failed save is reported instead of pretending success', async () => {
168
+ const { impl } = scriptedFetch({ '/health': HEALTHY, '/auth/login': LOGGED_IN });
169
+ const res = await signInToWorkspace(CREDS, {
170
+ fetchImpl: impl,
171
+ save: () => {
172
+ throw new Error('read-only fs');
173
+ },
174
+ });
175
+ expect((res.json as { reason: string }).reason).toBe('save-failed');
176
+ });
177
+ });
178
+
179
+ describe('success', () => {
180
+ test('returns the person and their session, never the token', async () => {
181
+ const { impl } = scriptedFetch({ '/health': HEALTHY, '/auth/login': LOGGED_IN });
182
+ const res = await signInToWorkspace(CREDS, { fetchImpl: impl, save: () => {} });
183
+
184
+ expect(res.status).toBe(200);
185
+ expect(res.json).toEqual({
186
+ ok: true,
187
+ url: 'https://team.example.com',
188
+ user: { email: 'alice@example.com', role: 'member' },
189
+ expiresAt: 1_800_000_000_000,
190
+ version: '0.48.0',
191
+ });
192
+ // The token must not travel back to the UI layer — it has no use for it and
193
+ // every surface that holds one is a surface that can leak one.
194
+ expect(JSON.stringify(res.json)).not.toContain('mau_');
195
+ });
196
+
197
+ test('a trailing slash or bare host normalizes to the same workspace', async () => {
198
+ const stored: string[] = [];
199
+ for (const url of ['https://team.example.com/', 'https://team.example.com']) {
200
+ const { impl } = scriptedFetch({ '/health': HEALTHY, '/auth/login': LOGGED_IN });
201
+ await signInToWorkspace(
202
+ { ...CREDS, url },
203
+ { fetchImpl: impl, save: (u) => void stored.push(u) }
204
+ );
205
+ }
206
+ expect(new Set(stored).size).toBe(1);
207
+ });
208
+ });
209
+
210
+ describe('the disclosure panel — DDR-054 made visible (DDR-192 §6)', () => {
211
+ const text = (items: DisclosureItem[]) => items.map((i) => `${i.title} ${i.detail}`).join(' ');
212
+
213
+ test('it says what the operator CAN see, not only what they cannot', () => {
214
+ // A disclosure that only lists reassurances is marketing. The point is that
215
+ // the person can decide, which requires the uncomfortable half.
216
+ const items = workspaceDisclosure({ operator: 'your team', aiAvailable: true });
217
+ const sees = items.filter((i) => i.kind === 'sees');
218
+ expect(sees.length).toBeGreaterThanOrEqual(3);
219
+ expect(text(sees)).toMatch(/edit history/i);
220
+ expect(text(sees)).toMatch(/when you are editing/i);
221
+ expect(text(sees)).toMatch(/comments/i);
222
+ });
223
+
224
+ test('it names the containment invariant in words a person understands', () => {
225
+ const items = workspaceDisclosure({ operator: 'Maude Cloud', aiAvailable: true });
226
+ expect(text(items)).toMatch(/never executes them/i);
227
+ expect(text(items)).toMatch(/render here, on your machine/i);
228
+ });
229
+
230
+ test('it names the operator, so "who can see this" has an answer', () => {
231
+ const items = workspaceDisclosure({ operator: 'Acme Design Ltd', aiAvailable: true });
232
+ expect(text(items)).toContain('Acme Design Ltd');
233
+ });
234
+
235
+ test('the AI-less state is stated as a fact, not an error', () => {
236
+ // Phase 6's persona: an invitee without a Claude subscription gets a
237
+ // first-class product, not a broken one.
238
+ const off = workspaceDisclosure({ operator: 'your team', aiAvailable: false });
239
+ const ai = off.find((i) => i.title.includes('AI'));
240
+ expect(ai?.detail).toMatch(/Everything else works normally/i);
241
+ expect(ai?.detail).not.toMatch(/error|unavailable|failed|missing/i);
242
+
243
+ const on = workspaceDisclosure({ operator: 'your team', aiAvailable: true });
244
+ expect(on.find((i) => i.title.includes('AI'))?.detail).toMatch(/your own subscription/i);
245
+ });
246
+
247
+ test('it promises the exit, because that is what makes the rest believable', () => {
248
+ const items = workspaceDisclosure({ operator: 'Maude Cloud', aiAvailable: true });
249
+ expect(text(items)).toMatch(/opens without Maude/i);
250
+ });
251
+
252
+ test('no jargon reaches the person', () => {
253
+ // DDR-193 §5 — the product word is "project"/"workspace". These words on an
254
+ // invite path tell a non-developer the product is not for them.
255
+ const items = workspaceDisclosure({ operator: 'your team', aiAvailable: false });
256
+ const all = text(items).toLowerCase();
257
+ for (const word of [
258
+ 'token',
259
+ 'repository',
260
+ 'repo ',
261
+ 'oauth',
262
+ 'bearer',
263
+ 'crdt',
264
+ 'yjs',
265
+ 'commit',
266
+ ]) {
267
+ expect(all).not.toContain(word);
268
+ }
269
+ });
270
+ });
@@ -33,6 +33,8 @@ import { Awareness, applyAwarenessUpdate, encodeAwarenessUpdate } from 'y-protoc
33
33
  import { readSyncMessage, writeSyncStep1, writeUpdate } from 'y-protocols/sync';
34
34
  import * as Y from 'yjs';
35
35
 
36
+ import { isTrustedOrigin, touchesBodyLane } from './collab/origins.ts';
37
+
36
38
  const MESSAGE_SYNC = 0;
37
39
  const MESSAGE_AWARENESS = 1;
38
40
 
@@ -677,10 +679,35 @@ function createSession(slug: string): CollabSession {
677
679
 
678
680
  // Wire doc updates → broadcast to server. Origin tagged with the ws so
679
681
  // server-side updates we receive don't echo back.
680
- const onDocUpdate = (update: Uint8Array, origin: unknown) => {
682
+ //
683
+ // ORIGIN GATE, lock 1 (DDR-122 follow-up — see collab/origins.ts). This file
684
+ // runs in the UNTRUSTED canvas realm, so any script sharing the realm can
685
+ // reach this doc through `useCollab().doc` and write the body lanes — the
686
+ // canvas's own source. Refuse to broadcast such an op unless it carries a
687
+ // trusted origin sentinel, which canvas script cannot obtain. Comments,
688
+ // annotations, and presentation are untouched: the canvas realm co-authors
689
+ // those by design, and same-machine boards must keep working.
690
+ let bodyLaneWarned = false;
691
+ const onDocUpdate = (
692
+ update: Uint8Array,
693
+ origin: unknown,
694
+ _doc: Y.Doc,
695
+ transaction: Y.Transaction
696
+ ) => {
681
697
  const ws = wsRef;
682
698
  if (!ws || ws.readyState !== WebSocket.OPEN) return;
683
699
  if (origin === ws) return; // came from server, don't echo
700
+ if (touchesBodyLane(transaction) && !isTrustedOrigin(origin)) {
701
+ if (!bodyLaneWarned) {
702
+ bodyLaneWarned = true;
703
+ console.warn(
704
+ '[collab] refused to broadcast a canvas-realm write to the canvas source ' +
705
+ '(html/css/meta). Untrusted canvas script may not edit the document body — ' +
706
+ 'DDR-122 follow-up.'
707
+ );
708
+ }
709
+ return;
710
+ }
684
711
  broadcastSyncUpdate(ws, update);
685
712
  };
686
713
  doc.on('update', onDocUpdate);
@@ -0,0 +1,210 @@
1
+ // Workspace mode + the containment invariant's first enforcement point.
2
+ //
3
+ // DDR-193 states the invariant:
4
+ //
5
+ // > No tenant-authored TSX is ever evaluated by vendor-operated compute.
6
+ // > A cell runs sync + git + asset storage — nothing else. It never renders a
7
+ // > canvas, never builds a bundle, never executes a canvas module, never runs
8
+ // > a headless browser against tenant content.
9
+ //
10
+ // and states how it is kept: "enforced, not asserted — a boot-assert in the cell
11
+ // image plus a CI grep gate." This module is the boot-assert. The grep gate is
12
+ // `scripts/check-containment.sh`.
13
+ //
14
+ // WHY A BOOT-ASSERT AND NOT A CODE REVIEW RULE. Every canvas is code the tenant
15
+ // wrote. The moment vendor compute evaluates it, an anonymous signup has
16
+ // arbitrary code execution inside our perimeter, next to other tenants' designs
17
+ // and the control plane's credentials. A convention protects that for exactly as
18
+ // long as everyone remembers it; a process that refuses to start does not
19
+ // forget. The failure mode this prevents is not "someone adds a bad route on
20
+ // purpose" — it is "someone adds a thumbnail endpoint in 2027 without knowing
21
+ // this line exists."
22
+ //
23
+ // It fails CLOSED and LOUD: the cell refuses to boot, naming the exact surfaces
24
+ // that broke it. A cell that will not start is a page; a cell that quietly
25
+ // renders tenant code is an incident.
26
+
27
+ /** True when this process is a vendor-operated workspace cell. */
28
+ export function isWorkspaceMode(env: NodeJS.ProcessEnv = process.env): boolean {
29
+ return env.MAUDE_WORKSPACE_MODE === '1';
30
+ }
31
+
32
+ /**
33
+ * Route paths a workspace cell must not expose, with the reason each one
34
+ * violates containment. The reason travels with the entry so the error message
35
+ * teaches rather than just refusing.
36
+ *
37
+ * These are prefixes: `/_api/export-jobs` is caught by `/_api/export` and that
38
+ * is intended — a variant of a forbidden surface is still forbidden.
39
+ */
40
+ export const FORBIDDEN_ROUTE_PREFIXES: ReadonlyArray<{ prefix: string; why: string }> =
41
+ Object.freeze([
42
+ {
43
+ prefix: '/_api/export',
44
+ why: 'export renders the canvas through a headless browser — it EVALUATES tenant TSX',
45
+ },
46
+ {
47
+ prefix: '/_api/photo-edit',
48
+ why: 'photo editing decodes and processes tenant-supplied media in-process',
49
+ },
50
+ {
51
+ prefix: '/_api/generate',
52
+ why: 'media generation runs tenant-authored prompts against a provider key held here',
53
+ },
54
+ {
55
+ prefix: '/_canvas-shell',
56
+ why: 'the canvas shell is the surface that mounts and executes a canvas module',
57
+ },
58
+ {
59
+ prefix: '/_canvas-runtime',
60
+ why: 'serving the canvas runtime only makes sense if something here renders a canvas',
61
+ },
62
+ {
63
+ prefix: '/_ws/acp',
64
+ why: 'the ACP bridge spawns the user’s own `claude` and can drive file edits (DDR-123 is desktop-only)',
65
+ },
66
+ ]);
67
+
68
+ /**
69
+ * Modules a cell must not be able to load. Presence of the *dependency* is the
70
+ * signal — a cell image that ships Playwright is one `import()` away from
71
+ * rendering tenant content, so the invariant is cheaper to hold at the image
72
+ * boundary than at the call site.
73
+ */
74
+ export const FORBIDDEN_MODULES: ReadonlyArray<string> = Object.freeze([
75
+ 'playwright',
76
+ 'playwright-core',
77
+ 'puppeteer',
78
+ 'puppeteer-core',
79
+ ]);
80
+
81
+ /** True when `route` is (or starts with) a forbidden prefix. */
82
+ export function isForbiddenRoute(route: string): boolean {
83
+ return FORBIDDEN_ROUTE_PREFIXES.some((f) => route === f.prefix || route.startsWith(f.prefix));
84
+ }
85
+
86
+ /**
87
+ * Remove every forbidden surface from a route table.
88
+ *
89
+ * This is what lets a cell boot at all: the studio serves one route table, and
90
+ * most of it (sync, comments, annotations, assets, git) is exactly what a cell
91
+ * needs. Pruning — rather than maintaining a separate cell server — means a cell
92
+ * runs the SAME code path a self-hoster runs, which is the property DDR-192 §1
93
+ * is built on. A second implementation would drift.
94
+ *
95
+ * Order matters: prune first, then `assertContainment` over the PRUNED keys.
96
+ * That makes the boot-assert a post-condition on this function rather than an
97
+ * independent opinion, so a prefix added to the vocabulary automatically both
98
+ * prunes and is verified.
99
+ */
100
+ export function pruneForWorkspace<T extends Record<string, unknown>>(
101
+ routes: T
102
+ ): { routes: Partial<T>; removed: string[] } {
103
+ const kept: Record<string, unknown> = {};
104
+ const removed: string[] = [];
105
+ for (const [route, handler] of Object.entries(routes)) {
106
+ if (isForbiddenRoute(route)) removed.push(route);
107
+ else kept[route] = handler;
108
+ }
109
+ return { routes: kept as Partial<T>, removed: removed.sort() };
110
+ }
111
+
112
+ export interface ContainmentReport {
113
+ ok: boolean;
114
+ /** Route prefixes that are reachable and must not be. */
115
+ routes: Array<{ route: string; prefix: string; why: string }>;
116
+ /** Forbidden modules that resolve in this process. */
117
+ modules: string[];
118
+ }
119
+
120
+ /**
121
+ * Check a route table against the invariant. Pure — takes the route names so it
122
+ * can be unit-tested without booting a server, and so the caller decides what
123
+ * "reachable" means for its own dispatch shape.
124
+ */
125
+ export function checkContainment(
126
+ routeNames: Iterable<string>,
127
+ { resolveModule }: { resolveModule?: (specifier: string) => boolean } = {}
128
+ ): ContainmentReport {
129
+ const routes: ContainmentReport['routes'] = [];
130
+ for (const route of routeNames) {
131
+ for (const { prefix, why } of FORBIDDEN_ROUTE_PREFIXES) {
132
+ if (route === prefix || route.startsWith(prefix)) {
133
+ routes.push({ route, prefix, why });
134
+ break;
135
+ }
136
+ }
137
+ }
138
+
139
+ const modules: string[] = [];
140
+ if (resolveModule) {
141
+ for (const specifier of FORBIDDEN_MODULES) {
142
+ if (resolveModule(specifier)) modules.push(specifier);
143
+ }
144
+ }
145
+
146
+ return { ok: routes.length === 0 && modules.length === 0, routes, modules };
147
+ }
148
+
149
+ /** Render a report as the message the operator sees when a cell refuses to boot. */
150
+ export function formatContainmentFailure(report: ContainmentReport): string {
151
+ const lines = [
152
+ 'REFUSING TO START: this process is running as a workspace cell',
153
+ '(MAUDE_WORKSPACE_MODE=1) but exposes surfaces that break the containment',
154
+ 'invariant — "no tenant-authored TSX is ever evaluated by vendor-operated',
155
+ 'compute" (DDR-193 §2).',
156
+ '',
157
+ ];
158
+ if (report.routes.length > 0) {
159
+ lines.push('Reachable routes that must not be:');
160
+ for (const { route, prefix, why } of report.routes) {
161
+ lines.push(` ${route}`);
162
+ lines.push(` matches ${prefix} — ${why}`);
163
+ }
164
+ lines.push('');
165
+ }
166
+ if (report.modules.length > 0) {
167
+ lines.push('Modules that resolve in this process and must not:');
168
+ for (const m of report.modules) lines.push(` ${m}`);
169
+ lines.push('');
170
+ }
171
+ lines.push(
172
+ 'A cell runs sync + git + asset storage. Rendering happens on a member’s own',
173
+ 'machine, in Maude Desktop, where DDR-063/DDR-054 already contain it.',
174
+ '',
175
+ 'If a feature genuinely needs one of these, Direction B (a structured,',
176
+ 'non-executable synced unit) is its HARD PREREQUISITE — not a flag to add here.'
177
+ );
178
+ if (report.modules.length > 0) {
179
+ lines.push(
180
+ '',
181
+ 'Testing workspace mode inside a DEV CHECKOUT? Playwright is a legitimate',
182
+ 'devDependency here (the E2E harness), so it resolves and this fires. Set',
183
+ 'MAUDE_WORKSPACE_ALLOW_DEV_MODULES=1 to skip the module check locally. It has',
184
+ 'no effect on a built cell image, where the runtime-dependency gate in',
185
+ 'scripts/check-containment.sh is the enforcement — a shipped cell must not',
186
+ 'contain a browser at all.'
187
+ );
188
+ }
189
+ return lines.join('\n');
190
+ }
191
+
192
+ /**
193
+ * Boot gate. No-op unless workspace mode is on; otherwise throws with the full
194
+ * report when anything forbidden is reachable.
195
+ *
196
+ * Deliberately throws rather than returning a boolean: a caller that forgets to
197
+ * check a return value is exactly the class of mistake this exists to survive.
198
+ */
199
+ export function assertContainment(
200
+ routeNames: Iterable<string>,
201
+ {
202
+ env = process.env,
203
+ resolveModule,
204
+ }: { env?: NodeJS.ProcessEnv; resolveModule?: (specifier: string) => boolean } = {}
205
+ ): void {
206
+ if (!isWorkspaceMode(env)) return;
207
+ const report = checkContainment(routeNames, { resolveModule });
208
+ if (report.ok) return;
209
+ throw new Error(formatContainmentFailure(report));
210
+ }
package/apps/studio/ws.ts CHANGED
@@ -27,6 +27,10 @@ export type WsData =
27
27
  remote: string;
28
28
  kind: 'collab';
29
29
  slug: string;
30
+ /** Which origin upgraded this socket — 'canvas' marks the untrusted
31
+ * canvas iframe origin, whose sync frames go through the DDR-122
32
+ * origin gate in collab/room.ts. Set at upgrade time in server.ts. */
33
+ realm: 'main' | 'canvas';
30
34
  }
31
35
  | {
32
36
  // T2 (9.1-A) — HMR-only socket for the segregated canvas origin. Receives
@@ -244,9 +248,14 @@ export function createWs(
244
248
  // right room to disconnect from. Multiplexed via ws.data.id.
245
249
  const collabConns = new Map<string, { roomSlug: string; conn: RoomConn }>();
246
250
 
247
- function bindCollab(ws: ServerWebSocket<WsData>, slug: string): RoomConn {
251
+ function bindCollab(
252
+ ws: ServerWebSocket<WsData>,
253
+ slug: string,
254
+ realm: 'main' | 'canvas'
255
+ ): RoomConn {
248
256
  const conn: RoomConn = {
249
257
  id: ws.data.id,
258
+ realm,
250
259
  send(payload: Uint8Array) {
251
260
  try {
252
261
  // Bun's ws.send accepts Uint8Array directly as binary.
@@ -264,7 +273,7 @@ export function createWs(
264
273
  async open(ws) {
265
274
  if (ws.data.kind === 'collab') {
266
275
  const room = collab.registry.get(ws.data.slug);
267
- const conn = bindCollab(ws, ws.data.slug);
276
+ const conn = bindCollab(ws, ws.data.slug, ws.data.realm ?? 'main');
268
277
  await room.connect(conn);
269
278
  return;
270
279
  }