@1agh/maude 0.47.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 (58) hide show
  1. package/README.md +7 -6
  2. package/apps/studio/acp/bridge.ts +8 -1
  3. package/apps/studio/acp/plugin-bootstrap.ts +15 -1
  4. package/apps/studio/api.ts +18 -2
  5. package/apps/studio/assets-s3.ts +291 -0
  6. package/apps/studio/build.ts +1 -1
  7. package/apps/studio/client/export-center.jsx +7 -6
  8. package/apps/studio/client/github.js +11 -4
  9. package/apps/studio/collab/origins.ts +150 -0
  10. package/apps/studio/collab/protocol.ts +36 -9
  11. package/apps/studio/collab/room.ts +124 -0
  12. package/apps/studio/context.ts +9 -0
  13. package/apps/studio/dist/client.bundle.js +282 -282
  14. package/apps/studio/dist/comment-mount.js +2 -2
  15. package/apps/studio/dist/runtime/REMOTION-LICENSE.md +1 -1
  16. package/apps/studio/examples/perf-100-artboards.tsx +1 -1
  17. package/apps/studio/exporters/pdf.ts +35 -1
  18. package/apps/studio/git/service.ts +4 -1
  19. package/apps/studio/http.ts +45 -0
  20. package/apps/studio/paths.ts +37 -1
  21. package/apps/studio/server.ts +75 -2
  22. package/apps/studio/sync/autocommit.ts +299 -0
  23. package/apps/studio/sync/doc-name.ts +228 -0
  24. package/apps/studio/sync/index.ts +95 -1
  25. package/apps/studio/sync/workspace-signin.ts +301 -0
  26. package/apps/studio/test/acp-plugin-bootstrap.test.ts +34 -0
  27. package/apps/studio/test/acp-session-plugins.test.ts +6 -0
  28. package/apps/studio/test/assets-s3.test.ts +249 -0
  29. package/apps/studio/test/canvas-origin-gate.test.ts +7 -0
  30. package/apps/studio/test/collab-origin-gate.test.ts +323 -0
  31. package/apps/studio/test/exporters/pdf.test.ts +33 -1
  32. package/apps/studio/test/sync-autocommit.test.ts +334 -0
  33. package/apps/studio/test/sync-doc-name.test.ts +281 -0
  34. package/apps/studio/test/workspace-containment.test.ts +258 -0
  35. package/apps/studio/test/workspace-signin.test.ts +270 -0
  36. package/apps/studio/use-collab.tsx +28 -1
  37. package/apps/studio/workspace-mode.ts +210 -0
  38. package/apps/studio/ws.ts +11 -2
  39. package/cli/bin/maude.mjs +1 -0
  40. package/cli/commands/hub-workspace.mjs +341 -0
  41. package/cli/commands/hub.mjs +325 -3
  42. package/cli/commands/hub.test.mjs +14 -1
  43. package/cli/commands/init.mjs +80 -3
  44. package/cli/commands/kg.mjs +368 -0
  45. package/cli/commands/kg.test.mjs +118 -0
  46. package/cli/lib/cell-plan.mjs +302 -0
  47. package/cli/lib/cell-plan.test.mjs +225 -0
  48. package/cli/lib/ddr-to-kgai.mjs +648 -0
  49. package/cli/lib/ddr-to-kgai.test.mjs +99 -0
  50. package/cli/lib/flow-design-integration.test.mjs +2 -2
  51. package/cli/lib/gitignore-block.mjs +16 -1
  52. package/cli/lib/plugin-name-namespace.test.mjs +71 -0
  53. package/cli/lib/workspace-plan.mjs +422 -0
  54. package/cli/lib/workspace-plan.test.mjs +223 -0
  55. package/package.json +8 -8
  56. package/plugins/design/dependencies.json +17 -0
  57. package/plugins/flow/.claude-plugin/config.schema.json +66 -0
  58. package/plugins/flow/dependencies.json +17 -0
@@ -0,0 +1,258 @@
1
+ // Cloud Phase 3 — the containment invariant's boot-assert (DDR-193 §2).
2
+ //
3
+ // > No tenant-authored TSX is ever evaluated by vendor-operated compute.
4
+ //
5
+ // The thing being tested is a REFUSAL. So the tests that matter are the ones
6
+ // that prove the gate fires: a cell exposing an export route, a canvas shell, a
7
+ // runtime bundle, or shipping Playwright must not be able to start.
8
+ //
9
+ // The negative case matters just as much: none of this may fire outside
10
+ // workspace mode, or every local dev-server stops booting.
11
+
12
+ import { describe, expect, test } from 'bun:test';
13
+
14
+ import {
15
+ assertContainment,
16
+ checkContainment,
17
+ FORBIDDEN_MODULES,
18
+ FORBIDDEN_ROUTE_PREFIXES,
19
+ formatContainmentFailure,
20
+ isForbiddenRoute,
21
+ isWorkspaceMode,
22
+ pruneForWorkspace,
23
+ } from '../workspace-mode.ts';
24
+
25
+ const CELL = { MAUDE_WORKSPACE_MODE: '1' } as NodeJS.ProcessEnv;
26
+ const LAPTOP = {} as NodeJS.ProcessEnv;
27
+
28
+ /** The routes a cell legitimately serves: sync + git + assets, nothing else. */
29
+ const ALLOWED_ROUTES = [
30
+ '/_health',
31
+ '/_api/asset',
32
+ '/_api/comments',
33
+ '/_api/annotations',
34
+ '/_api/git-committers',
35
+ '/_config',
36
+ '/_ws',
37
+ ];
38
+
39
+ describe('workspace mode detection', () => {
40
+ test('only MAUDE_WORKSPACE_MODE=1 turns it on', () => {
41
+ expect(isWorkspaceMode(CELL)).toBe(true);
42
+ expect(isWorkspaceMode(LAPTOP)).toBe(false);
43
+ expect(isWorkspaceMode({ MAUDE_WORKSPACE_MODE: 'true' })).toBe(false);
44
+ expect(isWorkspaceMode({ MAUDE_WORKSPACE_MODE: '0' })).toBe(false);
45
+ expect(isWorkspaceMode({ MAUDE_WORKSPACE_MODE: '' })).toBe(false);
46
+ });
47
+ });
48
+
49
+ describe('checkContainment — what a cell may serve', () => {
50
+ test('sync + git + asset routes pass', () => {
51
+ const report = checkContainment(ALLOWED_ROUTES);
52
+ expect(report.ok).toBe(true);
53
+ expect(report.routes).toEqual([]);
54
+ expect(report.modules).toEqual([]);
55
+ });
56
+
57
+ test('every forbidden prefix is caught, and the reason travels with it', () => {
58
+ for (const { prefix, why } of FORBIDDEN_ROUTE_PREFIXES) {
59
+ const report = checkContainment([...ALLOWED_ROUTES, prefix]);
60
+ expect(report.ok).toBe(false);
61
+ expect(report.routes).toHaveLength(1);
62
+ expect(report.routes[0]?.prefix).toBe(prefix);
63
+ // The message has to teach, not just refuse — this is read by someone who
64
+ // added a route in 2027 and has never heard of this invariant.
65
+ expect(report.routes[0]?.why).toBe(why);
66
+ expect(why.length).toBeGreaterThan(20);
67
+ }
68
+ });
69
+
70
+ test('a VARIANT of a forbidden route is still forbidden', () => {
71
+ // The realistic way this gets reintroduced: not `/_api/export` verbatim, but
72
+ // a sibling that does the same thing under a new name.
73
+ for (const route of [
74
+ '/_api/export-jobs',
75
+ '/_api/export/png',
76
+ '/_canvas-shell.html',
77
+ '/_canvas-runtime/react.js',
78
+ '/_ws/acp',
79
+ ]) {
80
+ const report = checkContainment([...ALLOWED_ROUTES, route]);
81
+ expect(report.ok).toBe(false);
82
+ expect(report.routes[0]?.route).toBe(route);
83
+ }
84
+ });
85
+
86
+ test('a route that merely CONTAINS a forbidden word is not a false positive', () => {
87
+ // Prefix matching, not substring — `/_api/asset` must not trip on nothing,
88
+ // and a route about exports in its docs must not be blocked by name alone.
89
+ const report = checkContainment([
90
+ '/_api/asset',
91
+ '/_api/export-history-docs-link',
92
+ '/_api/no-export',
93
+ '/_api/git-export-status',
94
+ ]);
95
+ expect(report.routes.map((r) => r.route)).toEqual(['/_api/export-history-docs-link']);
96
+ // ...that one DOES start with /_api/export, and being caught is correct:
97
+ // a surface named "export-*" is exactly what needs a human to look at it.
98
+ });
99
+
100
+ test('a resolvable browser automation module fails the check', () => {
101
+ const report = checkContainment(ALLOWED_ROUTES, {
102
+ resolveModule: (s) => s === 'playwright',
103
+ });
104
+ expect(report.ok).toBe(false);
105
+ expect(report.modules).toEqual(['playwright']);
106
+ // All four are checked, not just the popular one.
107
+ const all = checkContainment(ALLOWED_ROUTES, { resolveModule: () => true });
108
+ expect(all.modules).toEqual([...FORBIDDEN_MODULES]);
109
+ });
110
+
111
+ test('no resolver supplied ⇒ modules are simply not checked (not silently passed)', () => {
112
+ const report = checkContainment(ALLOWED_ROUTES);
113
+ expect(report.modules).toEqual([]);
114
+ });
115
+ });
116
+
117
+ describe('assertContainment — the boot gate', () => {
118
+ test('OUTSIDE workspace mode it never fires, whatever is exposed', () => {
119
+ // A developer's laptop serves every one of these. If the gate fired here,
120
+ // the entire local product would stop booting.
121
+ expect(() =>
122
+ assertContainment([...ALLOWED_ROUTES, ...FORBIDDEN_ROUTE_PREFIXES.map((f) => f.prefix)], {
123
+ env: LAPTOP,
124
+ resolveModule: () => true,
125
+ })
126
+ ).not.toThrow();
127
+ });
128
+
129
+ test('in a cell, a clean route table starts', () => {
130
+ expect(() => assertContainment(ALLOWED_ROUTES, { env: CELL })).not.toThrow();
131
+ });
132
+
133
+ test('in a cell, an export route REFUSES TO START', () => {
134
+ expect(() => assertContainment([...ALLOWED_ROUTES, '/_api/export'], { env: CELL })).toThrow(
135
+ /REFUSING TO START/
136
+ );
137
+ });
138
+
139
+ test('in a cell, shipping Playwright REFUSES TO START even with clean routes', () => {
140
+ expect(() =>
141
+ assertContainment(ALLOWED_ROUTES, { env: CELL, resolveModule: (s) => s === 'playwright' })
142
+ ).toThrow(/playwright/);
143
+ });
144
+
145
+ test('the failure message names the surface, the reason, and the way out', () => {
146
+ let message = '';
147
+ try {
148
+ assertContainment([...ALLOWED_ROUTES, '/_canvas-shell.html'], { env: CELL });
149
+ } catch (err) {
150
+ message = (err as Error).message;
151
+ }
152
+ expect(message).toContain('/_canvas-shell.html');
153
+ expect(message).toContain('DDR-193');
154
+ expect(message).toContain('executes a canvas module');
155
+ // The escape hatch is named so the next engineer doesn't invent a flag.
156
+ expect(message).toContain('Direction B');
157
+ expect(message).toContain('HARD PREREQUISITE');
158
+ });
159
+
160
+ test('formatContainmentFailure reports routes and modules together', () => {
161
+ const report = checkContainment([...ALLOWED_ROUTES, '/_api/export'], {
162
+ resolveModule: (s) => s === 'puppeteer',
163
+ });
164
+ const message = formatContainmentFailure(report);
165
+ expect(message).toContain('/_api/export');
166
+ expect(message).toContain('puppeteer');
167
+ expect(message).toContain('Reachable routes');
168
+ expect(message).toContain('Modules that resolve');
169
+ });
170
+ });
171
+
172
+ describe('the vocabulary itself', () => {
173
+ test('the forbidden set covers every way a cell could evaluate tenant code', () => {
174
+ // Pinned deliberately: the realistic loss of this invariant is a refactor
175
+ // quietly dropping an entry while every other test stays green. Changing
176
+ // this list should require changing this test, on purpose.
177
+ expect(FORBIDDEN_ROUTE_PREFIXES.map((f) => f.prefix).sort()).toEqual([
178
+ '/_api/export',
179
+ '/_api/generate',
180
+ '/_api/photo-edit',
181
+ '/_canvas-runtime',
182
+ '/_canvas-shell',
183
+ '/_ws/acp',
184
+ ]);
185
+ expect([...FORBIDDEN_MODULES].sort()).toEqual([
186
+ 'playwright',
187
+ 'playwright-core',
188
+ 'puppeteer',
189
+ 'puppeteer-core',
190
+ ]);
191
+ });
192
+ });
193
+
194
+ describe('pruneForWorkspace — how a cell can boot at all', () => {
195
+ test('keeps the sync/git/asset surface and removes exactly the forbidden one', () => {
196
+ const table = {
197
+ '/_health': 'h',
198
+ '/_api/asset': 'a',
199
+ '/_api/comments': 'c',
200
+ '/_api/git-committers': 'g',
201
+ '/_api/export': 'x',
202
+ '/_api/export-jobs': 'x2',
203
+ '/_api/photo-edit': 'p',
204
+ '/_api/generate/keys': 'k',
205
+ };
206
+ const { routes, removed } = pruneForWorkspace(table);
207
+
208
+ expect(Object.keys(routes).sort()).toEqual([
209
+ '/_api/asset',
210
+ '/_api/comments',
211
+ '/_api/git-committers',
212
+ '/_health',
213
+ ]);
214
+ expect(removed).toEqual([
215
+ '/_api/export',
216
+ '/_api/export-jobs',
217
+ '/_api/generate/keys',
218
+ '/_api/photo-edit',
219
+ ]);
220
+ // Handlers are carried through by reference — pruning must not rebuild them.
221
+ expect(routes['/_api/asset']).toBe('a');
222
+ });
223
+
224
+ test('the pruned table then PASSES the boot assert — prune and assert agree', () => {
225
+ // This is the whole design: the assert is a post-condition on the pruning,
226
+ // so a prefix added to the vocabulary both prunes and is verified, and the
227
+ // two can never drift into disagreeing.
228
+ const table = Object.fromEntries(
229
+ [...FORBIDDEN_ROUTE_PREFIXES.map((f) => f.prefix), '/_health', '/_api/asset'].map((r) => [
230
+ r,
231
+ () => {},
232
+ ])
233
+ );
234
+ const { routes } = pruneForWorkspace(table);
235
+ expect(() => assertContainment(Object.keys(routes), { env: CELL })).not.toThrow();
236
+ // ...and the unpruned table would have refused.
237
+ expect(() => assertContainment(Object.keys(table), { env: CELL })).toThrow(/REFUSING TO START/);
238
+ });
239
+
240
+ test('pruning an already-clean table is a no-op', () => {
241
+ const table = Object.fromEntries(ALLOWED_ROUTES.map((r) => [r, () => {}]));
242
+ const { routes, removed } = pruneForWorkspace(table);
243
+ expect(removed).toEqual([]);
244
+ expect(Object.keys(routes).sort()).toEqual([...ALLOWED_ROUTES].sort());
245
+ });
246
+
247
+ test('isForbiddenRoute is the single predicate both the table and fetch use', () => {
248
+ // The `fetch` fall-through owns paths that are not in the route table
249
+ // (/_ws/acp, /_canvas-shell.html, /_canvas-runtime/*), so it gates on this
250
+ // same function. One predicate, so the two gates cannot disagree.
251
+ expect(isForbiddenRoute('/_canvas-shell.html')).toBe(true);
252
+ expect(isForbiddenRoute('/_canvas-runtime/react.js')).toBe(true);
253
+ expect(isForbiddenRoute('/_ws/acp')).toBe(true);
254
+ expect(isForbiddenRoute('/_ws')).toBe(false);
255
+ expect(isForbiddenRoute('/_health')).toBe(false);
256
+ expect(isForbiddenRoute('/_api/asset')).toBe(false);
257
+ });
258
+ });
@@ -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);