@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.
- package/apps/studio/api.ts +18 -2
- package/apps/studio/assets-s3.ts +291 -0
- package/apps/studio/collab/origins.ts +150 -0
- package/apps/studio/collab/protocol.ts +36 -9
- package/apps/studio/collab/room.ts +124 -0
- package/apps/studio/context.ts +9 -0
- package/apps/studio/dist/client.bundle.js +1 -1
- package/apps/studio/dist/comment-mount.js +2 -2
- package/apps/studio/http.ts +45 -0
- package/apps/studio/server.ts +75 -2
- package/apps/studio/sync/autocommit.ts +299 -0
- package/apps/studio/sync/doc-name.ts +228 -0
- package/apps/studio/sync/index.ts +95 -1
- package/apps/studio/sync/workspace-signin.ts +301 -0
- package/apps/studio/test/assets-s3.test.ts +249 -0
- package/apps/studio/test/canvas-origin-gate.test.ts +7 -0
- package/apps/studio/test/collab-origin-gate.test.ts +323 -0
- package/apps/studio/test/sync-autocommit.test.ts +334 -0
- package/apps/studio/test/sync-doc-name.test.ts +281 -0
- package/apps/studio/test/workspace-containment.test.ts +258 -0
- package/apps/studio/test/workspace-signin.test.ts +270 -0
- package/apps/studio/use-collab.tsx +28 -1
- package/apps/studio/workspace-mode.ts +210 -0
- package/apps/studio/ws.ts +11 -2
- package/cli/commands/hub-workspace.mjs +341 -0
- package/cli/commands/hub.mjs +325 -3
- package/cli/commands/hub.test.mjs +14 -1
- package/cli/lib/cell-plan.mjs +302 -0
- package/cli/lib/cell-plan.test.mjs +225 -0
- package/cli/lib/gitignore-block.mjs +12 -1
- package/cli/lib/workspace-plan.mjs +422 -0
- package/cli/lib/workspace-plan.test.mjs +223 -0
- 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
|
-
|
|
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(
|
|
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
|
}
|