@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.
- package/apps/studio/annotations-layer.tsx +11 -1
- package/apps/studio/api.ts +13 -7
- package/apps/studio/bin/screenshot.sh +43 -3
- package/apps/studio/build.ts +37 -3
- package/apps/studio/canvas-build-sandbox.ts +101 -9
- package/apps/studio/canvas-build-worker.ts +7 -1
- package/apps/studio/canvas-build.ts +9 -1
- package/apps/studio/client/app.jsx +226 -25
- package/apps/studio/client/export-center.jsx +10 -2
- package/apps/studio/client/github.js +7 -0
- package/apps/studio/client/panels/CloudBar.jsx +461 -40
- package/apps/studio/client/panels/SettingsPanel.jsx +6 -1
- package/apps/studio/client/styles/3-shell-maude.css +30 -0
- package/apps/studio/client/styles/4-components.css +40 -0
- package/apps/studio/cloud/endpoints.ts +48 -6
- package/apps/studio/cloud-build.ts +208 -0
- package/apps/studio/collab/room.ts +43 -7
- package/apps/studio/context.ts +12 -5
- package/apps/studio/dist/client.bundle.js +983 -983
- package/apps/studio/dist/comment-mount.js +2 -2
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/git/repo-lock.ts +305 -0
- package/apps/studio/git/service.ts +171 -21
- package/apps/studio/http.ts +159 -13
- package/apps/studio/inspect.ts +66 -6
- package/apps/studio/server.ts +114 -17
- package/apps/studio/session-scope.ts +101 -0
- package/apps/studio/sync/autocommit.ts +84 -46
- package/apps/studio/sync/index.ts +20 -0
- package/apps/studio/test/canvas-origin-gate.test.ts +61 -0
- package/apps/studio/test/canvas-shell-base.test.ts +107 -0
- package/apps/studio/test/cloud-build.test.ts +95 -0
- package/apps/studio/test/cloud-endpoints.test.ts +231 -4
- package/apps/studio/test/cloud-shell-surfaces.test.ts +31 -2
- package/apps/studio/test/collab-readonly-gate.test.ts +194 -0
- package/apps/studio/test/git-system-engine.test.ts +135 -0
- package/apps/studio/test/read-only-gate.test.ts +8 -0
- package/apps/studio/test/repo-concurrency.test.ts +146 -0
- package/apps/studio/test/repo-lock.test.ts +350 -0
- package/apps/studio/test/server-lifecycle.test.ts +11 -1
- package/apps/studio/test/session-runtime-state.test.ts +226 -0
- package/apps/studio/test/session-scope.test.ts +100 -0
- package/apps/studio/test/shell-importmap.test.ts +8 -1
- package/apps/studio/test/sync-autocommit.test.ts +76 -0
- package/apps/studio/test/sync-hardening.test.ts +75 -0
- package/apps/studio/use-collab.tsx +21 -1
- package/apps/studio/whats-new.json +54 -0
- package/apps/studio/ws.ts +44 -8
- package/cli/commands/kg.mjs +49 -19
- package/cli/lib/gitignore-block.mjs +3 -0
- package/package.json +11 -9
- package/plugins/design/dependencies.json +1 -1
- package/plugins/design/templates/_shell.html +66 -32
- package/plugins/flow/.claude-plugin/config.schema.json +1 -1
- package/plugins/flow/dependencies.json +1 -1
|
@@ -9,7 +9,12 @@ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
|
9
9
|
import { tmpdir } from 'node:os';
|
|
10
10
|
import { join } from 'node:path';
|
|
11
11
|
|
|
12
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
isDisplayableUrl,
|
|
14
|
+
localIdentityHint,
|
|
15
|
+
parseDeepLink,
|
|
16
|
+
shareViewUrl,
|
|
17
|
+
} from '../client/panels/CloudBar.jsx';
|
|
13
18
|
|
|
14
19
|
let stub: ReturnType<typeof Bun.serve> | null = null;
|
|
15
20
|
let scratch = '';
|
|
@@ -78,20 +83,242 @@ describe('parseDeepLink — untrusted input, strict shape', () => {
|
|
|
78
83
|
'maude://open/x?code=nothex', // bad code
|
|
79
84
|
'https://evil.example/?code=mhc_' + 'a'.repeat(64), // not maude://
|
|
80
85
|
'maude://join/x/y', // join is not the open shape
|
|
86
|
+
// Over the platform's 40-char project-id cap — a drive-by link never
|
|
87
|
+
// passes through the server-side bound, so arbitrary attacker text would
|
|
88
|
+
// otherwise render in the dialog heading, prose and aria-label.
|
|
89
|
+
`maude://open/${'a'.repeat(41)}?code=mhc_${'a'.repeat(64)}`,
|
|
81
90
|
]) {
|
|
82
91
|
expect(parseDeepLink(bad)).toBeNull();
|
|
83
92
|
}
|
|
93
|
+
// …and exactly 40 is still a real project id.
|
|
94
|
+
expect(
|
|
95
|
+
parseDeepLink(`maude://open/${'a'.repeat(40)}?code=mhc_${'a'.repeat(64)}`)
|
|
96
|
+
).not.toBeNull();
|
|
84
97
|
});
|
|
85
98
|
});
|
|
86
99
|
|
|
87
100
|
describe('shareViewUrl — the viewer’s browser home', () => {
|
|
88
|
-
test('
|
|
101
|
+
test('is the project’s OWN address — the view- gallery is gone (Phase 25 C5)', () => {
|
|
102
|
+
// `view-*` is hard-refused by tenantFromHostname, so the old derivation
|
|
103
|
+
// produced a guaranteed 404 — invisible only because window.open was a no-op
|
|
104
|
+
// in the shell. Making the opener work would have made it a dead menu item.
|
|
89
105
|
expect(shareViewUrl('https://alligators.cloud.maude.sh', 'alligators')).toBe(
|
|
90
|
-
'https://
|
|
106
|
+
'https://alligators.cloud.maude.sh'
|
|
91
107
|
);
|
|
108
|
+
expect(shareViewUrl('https://alligators.cloud.maude.sh', 'alligators')).not.toContain('view-');
|
|
92
109
|
});
|
|
93
110
|
test('falls back to the platform zone on garbage', () => {
|
|
94
|
-
expect(shareViewUrl('nonsense', 'p1')).toBe('https://
|
|
111
|
+
expect(shareViewUrl('nonsense', 'p1')).toBe('https://p1.cloud.maude.sh');
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe('isDisplayableUrl — what may be rendered as a clickable address', () => {
|
|
116
|
+
const CLOUD = 'https://cloud.maude.sh';
|
|
117
|
+
|
|
118
|
+
test('the configured origin and the cloud zone’s front doors pass', () => {
|
|
119
|
+
expect(isDisplayableUrl('https://cloud.maude.sh/activate?code=ABCD', CLOUD)).toBe(true);
|
|
120
|
+
expect(isDisplayableUrl('https://alligators.cloud.maude.sh/', CLOUD)).toBe(true);
|
|
121
|
+
// A self-host origin is legitimate for its OWN addresses.
|
|
122
|
+
expect(isDisplayableUrl('http://127.0.0.1:8788/activate', 'http://127.0.0.1:8788')).toBe(true);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('an off-zone address is never rendered — a scheme check was not enough', () => {
|
|
126
|
+
// The bug this closes: `https://evil.example/activate?code=…` from a hostile
|
|
127
|
+
// or MITM'd control plane passed a protocol-only check and still rendered as
|
|
128
|
+
// a live href with a Copy button, and middle-click never reaches the click
|
|
129
|
+
// handler, let alone the Rust zone lock.
|
|
130
|
+
for (const bad of [
|
|
131
|
+
'https://evil.example/activate?code=x',
|
|
132
|
+
'https://cloud.maude.sh.attacker.example/',
|
|
133
|
+
'javascript:alert(1)',
|
|
134
|
+
'file:///etc/passwd',
|
|
135
|
+
'not a url',
|
|
136
|
+
]) {
|
|
137
|
+
expect(isDisplayableUrl(bad, CLOUD)).toBe(false);
|
|
138
|
+
}
|
|
139
|
+
// Deep paths on a TENANT host are refused here too — same rule as the opener.
|
|
140
|
+
expect(isDisplayableUrl('https://canvas-evil.cloud.maude.sh/stored.svg', CLOUD)).toBe(false);
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
describe('status — names the LOCAL half so a deep link can be read', () => {
|
|
145
|
+
test('reports the open folder and its workspace, and never a credential', async () => {
|
|
146
|
+
const { createCloudEndpoints } = await import('../cloud/endpoints.ts');
|
|
147
|
+
const { mkdirSync } = await import('node:fs');
|
|
148
|
+
const repoRoot = join(scratch, 'alligators-web');
|
|
149
|
+
const designRoot = join(repoRoot, '.design');
|
|
150
|
+
mkdirSync(designRoot, { recursive: true });
|
|
151
|
+
writeFileSync(
|
|
152
|
+
join(designRoot, 'config.json'),
|
|
153
|
+
JSON.stringify({ linkedHub: { url: 'https://alligators.cloud.maude.sh', linkedAt: 1 } })
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
const r = createCloudEndpoints({ paths: { repoRoot, designRoot } }).status();
|
|
157
|
+
expect(r.status).toBe(200);
|
|
158
|
+
const json = r.json as Record<string, unknown>;
|
|
159
|
+
expect(json.project).toBe('alligators-web');
|
|
160
|
+
// No hub credential was stored in this scratch dir, so the link is reported
|
|
161
|
+
// but explicitly UNCORROBORATED — the dialog may not vouch for it.
|
|
162
|
+
expect(json.linkedHub).toEqual({
|
|
163
|
+
url: 'https://alligators.cloud.maude.sh',
|
|
164
|
+
credentialed: false,
|
|
165
|
+
});
|
|
166
|
+
// Signed out here — and the payload must stay free of token material
|
|
167
|
+
// whatever the state (this response reaches the browser surface too).
|
|
168
|
+
expect(json.connected).toBe(false);
|
|
169
|
+
expect(JSON.stringify(json)).not.toContain('token');
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test('an unlinked (or malformed-config) folder reports linkedHub: null, not an error', async () => {
|
|
173
|
+
const { createCloudEndpoints } = await import('../cloud/endpoints.ts');
|
|
174
|
+
const { mkdirSync } = await import('node:fs');
|
|
175
|
+
const repoRoot = join(scratch, 'fresh-thing');
|
|
176
|
+
const designRoot = join(repoRoot, '.design');
|
|
177
|
+
mkdirSync(designRoot, { recursive: true });
|
|
178
|
+
writeFileSync(join(designRoot, 'config.json'), '{ not json');
|
|
179
|
+
|
|
180
|
+
const json = createCloudEndpoints({ paths: { repoRoot, designRoot } }).status().json as Record<
|
|
181
|
+
string,
|
|
182
|
+
unknown
|
|
183
|
+
>;
|
|
184
|
+
expect(json.linkedHub).toBeNull();
|
|
185
|
+
expect(json.project).toBe('fresh-thing');
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
describe('localIdentityHint — only an exact, corroborated agreement is silent', () => {
|
|
190
|
+
const CLOUD = 'https://cloud.maude.sh';
|
|
191
|
+
|
|
192
|
+
test('the reported case warns: one project open, the link naming another', () => {
|
|
193
|
+
// StudyFi open, "Open in Maude" pressed on alligators — one click from
|
|
194
|
+
// syncing StudyFi's designs into somebody else's workspace.
|
|
195
|
+
expect(
|
|
196
|
+
localIdentityHint({ localProject: 'AI-StudyMate', cloudUrl: CLOUD, claimed: 'alligators' })
|
|
197
|
+
).toBe('mismatch');
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test('a name an ATTACKER chose to look like yours does not buy silence', () => {
|
|
201
|
+
// The whole point (attacker pass B1). The cloud project id is registerable by
|
|
202
|
+
// anyone, so treating "contains your folder name" as agreement handed the
|
|
203
|
+
// attacker the off-switch for the warning. And the server's 409 cannot help:
|
|
204
|
+
// the claim and the actual project agree — it IS their project.
|
|
205
|
+
for (const claimed of [
|
|
206
|
+
'alligators-design-sync',
|
|
207
|
+
'alligators-backup',
|
|
208
|
+
'shared-alligators',
|
|
209
|
+
'web-design-studio-app',
|
|
210
|
+
]) {
|
|
211
|
+
expect(localIdentityHint({ localProject: 'alligators', cloudUrl: CLOUD, claimed })).not.toBe(
|
|
212
|
+
'match'
|
|
213
|
+
);
|
|
214
|
+
expect(
|
|
215
|
+
['similar', 'mismatch'].includes(
|
|
216
|
+
localIdentityHint({ localProject: 'alligators', cloudUrl: CLOUD, claimed })
|
|
217
|
+
)
|
|
218
|
+
).toBe(true);
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test('an exact name is the only silent name-based verdict', () => {
|
|
223
|
+
expect(
|
|
224
|
+
localIdentityHint({ localProject: 'alligators', cloudUrl: CLOUD, claimed: 'alligators' })
|
|
225
|
+
).toBe('match');
|
|
226
|
+
// Normalization still applies — casing and separators are not a difference.
|
|
227
|
+
expect(
|
|
228
|
+
localIdentityHint({ localProject: 'Alligators', cloudUrl: CLOUD, claimed: 'alligators' })
|
|
229
|
+
).toBe('match');
|
|
230
|
+
// A near-match is its own state: alike, and still NOT the same workspace.
|
|
231
|
+
expect(
|
|
232
|
+
localIdentityHint({ localProject: 'alligators-web', cloudUrl: CLOUD, claimed: 'alligators' })
|
|
233
|
+
).toBe('similar');
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test('a CREDENTIALED link to that project reads as linked', () => {
|
|
237
|
+
expect(
|
|
238
|
+
localIdentityHint({
|
|
239
|
+
localProject: 'anything',
|
|
240
|
+
linkedHubUrl: 'https://alligators.cloud.maude.sh',
|
|
241
|
+
linkedHubCredentialed: true,
|
|
242
|
+
cloudUrl: CLOUD,
|
|
243
|
+
claimed: 'alligators',
|
|
244
|
+
})
|
|
245
|
+
).toBe('linked');
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test('an UNCREDENTIALED linkedHub cannot vouch for anyone', () => {
|
|
249
|
+
// config.json is committed and travels with the repo, so a hostile template
|
|
250
|
+
// could otherwise make the dialog say "already linked to <attacker>" — the
|
|
251
|
+
// strongest reassurance it can give (attacker pass B2). Without a stored
|
|
252
|
+
// credential it is ignored and the folder name decides.
|
|
253
|
+
expect(
|
|
254
|
+
localIdentityHint({
|
|
255
|
+
localProject: 'my-thing',
|
|
256
|
+
linkedHubUrl: 'https://attacker.cloud.maude.sh',
|
|
257
|
+
linkedHubCredentialed: false,
|
|
258
|
+
cloudUrl: CLOUD,
|
|
259
|
+
claimed: 'attacker',
|
|
260
|
+
})
|
|
261
|
+
).toBe('mismatch');
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test('a folder credentialed to a DIFFERENT workspace warns', () => {
|
|
265
|
+
expect(
|
|
266
|
+
localIdentityHint({
|
|
267
|
+
localProject: 'alligators',
|
|
268
|
+
linkedHubUrl: 'https://someone-else.cloud.maude.sh',
|
|
269
|
+
linkedHubCredentialed: true,
|
|
270
|
+
cloudUrl: CLOUD,
|
|
271
|
+
claimed: 'alligators',
|
|
272
|
+
})
|
|
273
|
+
).toBe('mismatch');
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
test('a short generic folder name does not buy agreement by containment', () => {
|
|
277
|
+
for (const localProject of ['web', 'app', 'src']) {
|
|
278
|
+
expect(
|
|
279
|
+
localIdentityHint({ localProject, cloudUrl: CLOUD, claimed: 'webshop-frontend' })
|
|
280
|
+
).toBe('mismatch');
|
|
281
|
+
}
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
test('an accented or over-long folder name still matches its own workspace', () => {
|
|
285
|
+
// The fold must mirror the platform's deriveProjectId (NFKD + strip + 40-cap)
|
|
286
|
+
// or the warning fires on every LEGITIMATE connect for a whole class of
|
|
287
|
+
// names — which is how a consent warning dies (attacker re-review).
|
|
288
|
+
expect(
|
|
289
|
+
localIdentityHint({ localProject: 'Zkušební tým', cloudUrl: CLOUD, claimed: 'zkusebni-tym' })
|
|
290
|
+
).toBe('match');
|
|
291
|
+
expect(
|
|
292
|
+
localIdentityHint({ localProject: 'Přátelé', cloudUrl: CLOUD, claimed: 'pratele' })
|
|
293
|
+
).toBe('match');
|
|
294
|
+
// >40 chars: the platform truncates the id, so the fold must truncate too.
|
|
295
|
+
const long = 'a-very-long-project-name-that-the-platform-truncates-at-forty';
|
|
296
|
+
expect(
|
|
297
|
+
localIdentityHint({ localProject: long, cloudUrl: CLOUD, claimed: long.slice(0, 40) })
|
|
298
|
+
).toBe('match');
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
test('no signal is "unknown", which warns — it must never read as agreement', () => {
|
|
302
|
+
// Every stub and self-hoster is http://127.0.0.1:<port>, carrying no project
|
|
303
|
+
// name. Before, that produced a reassuring neutral with a primary Connect;
|
|
304
|
+
// on the launch path an unresolved status did the same (attacker pass B3).
|
|
305
|
+
expect(
|
|
306
|
+
localIdentityHint({
|
|
307
|
+
localProject: '',
|
|
308
|
+
linkedHubUrl: 'http://127.0.0.1:4599',
|
|
309
|
+
cloudUrl: 'http://127.0.0.1:4599',
|
|
310
|
+
claimed: 'stub-project',
|
|
311
|
+
})
|
|
312
|
+
).toBe('unknown');
|
|
313
|
+
// Status not yet resolved → unknown, whatever else is on hand.
|
|
314
|
+
expect(
|
|
315
|
+
localIdentityHint({
|
|
316
|
+
localProject: 'alligators',
|
|
317
|
+
cloudUrl: CLOUD,
|
|
318
|
+
claimed: 'alligators',
|
|
319
|
+
resolved: false,
|
|
320
|
+
})
|
|
321
|
+
).toBe('unknown');
|
|
95
322
|
});
|
|
96
323
|
});
|
|
97
324
|
|
|
@@ -61,7 +61,15 @@ describe('the cloud shell offers nothing it cannot honour', () => {
|
|
|
61
61
|
});
|
|
62
62
|
|
|
63
63
|
test('the cloud sign-in is not offered inside the cloud', () => {
|
|
64
|
-
|
|
64
|
+
// `=== null`, not falsiness. `cloud` is undefined until `/_config` answers,
|
|
65
|
+
// and treating unknown as "not cloud" mounted this bar for one frame in
|
|
66
|
+
// every cloud tab — long enough to fire the one request the cell refuses.
|
|
67
|
+
expect(APP).toContain('{cloud === null ? <CloudBar /> : null}');
|
|
68
|
+
expect(APP).not.toContain('{cloud ? null : <CloudBar />}');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('the local export queue does not hydrate before the shell is known', () => {
|
|
72
|
+
expect(APP).toContain('useExportCenter({ enabled: cfg.cloud === null })');
|
|
65
73
|
});
|
|
66
74
|
|
|
67
75
|
test('every component that gates on the shell is actually GIVEN the flag', () => {
|
|
@@ -71,7 +79,9 @@ describe('the cloud shell offers nothing it cannot honour', () => {
|
|
|
71
79
|
const at = APP.indexOf(call);
|
|
72
80
|
expect(at).toBeGreaterThan(0);
|
|
73
81
|
const props = APP.slice(at, at + 900);
|
|
74
|
-
|
|
82
|
+
// Passed RAW — a `?? null` here would erase the "not known yet" state
|
|
83
|
+
// before any component could see it.
|
|
84
|
+
expect(props).toContain('cloud={cfg.cloud}');
|
|
75
85
|
}
|
|
76
86
|
});
|
|
77
87
|
|
|
@@ -88,3 +98,22 @@ describe('the cloud shell offers nothing it cannot honour', () => {
|
|
|
88
98
|
}
|
|
89
99
|
});
|
|
90
100
|
});
|
|
101
|
+
|
|
102
|
+
describe('"not known yet" survives every layer between /_config and the DOM', () => {
|
|
103
|
+
// The three-line bug: the call sites were fixed to pass `cfg.cloud` raw, and
|
|
104
|
+
// the components still declared `cloud = null` as a default parameter. A
|
|
105
|
+
// default fires on `undefined` — so "we have not asked yet" turned back into
|
|
106
|
+
// "this is not the cloud" one layer further in, the sign-in bar mounted for a
|
|
107
|
+
// frame, and the boot 404 came back looking exactly like the one just fixed.
|
|
108
|
+
const APP_SRC = readFileSync(join(STUDIO, 'client', 'app.jsx'), 'utf8');
|
|
109
|
+
const SETTINGS_SRC = readFileSync(join(STUDIO, 'client', 'panels', 'SettingsPanel.jsx'), 'utf8');
|
|
110
|
+
|
|
111
|
+
test('no component defaults the shell flag', () => {
|
|
112
|
+
for (const [label, src] of [
|
|
113
|
+
['app.jsx', APP_SRC],
|
|
114
|
+
['SettingsPanel.jsx', SETTINGS_SRC],
|
|
115
|
+
] as const) {
|
|
116
|
+
expect(src, label).not.toMatch(/^\s*cloud = (null|false|\{\}),$/m);
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
});
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
// Unit: the read-only role gate on collab sync writes — the cloud collab lane
|
|
2
|
+
// (RCA issue-cloud-live-collaboration-dead).
|
|
3
|
+
//
|
|
4
|
+
// A cell's proxy opens the canvas iframe's collab socket at the member's real
|
|
5
|
+
// role; a viewer's socket arrives with readOnly=true. The room mirrors the role
|
|
6
|
+
// matrix at the WS door the same way the inspector channel does for
|
|
7
|
+
// comments-patch/delete: a read-only conn receives everything and publishes
|
|
8
|
+
// awareness (presence for viewers is the point of the channel), but its sync
|
|
9
|
+
// writes are gated to the COMMENT lane — the one write a viewer holds
|
|
10
|
+
// (`viewer.comment === true`). Composes with, never replaces, the DDR-122
|
|
11
|
+
// canvas-realm body-lane gate (test/collab-origin-gate.test.ts).
|
|
12
|
+
|
|
13
|
+
import { describe, expect, test } from 'bun:test';
|
|
14
|
+
|
|
15
|
+
import * as encoding from 'lib0/encoding';
|
|
16
|
+
import { writeUpdate } from 'y-protocols/sync';
|
|
17
|
+
import * as Y from 'yjs';
|
|
18
|
+
|
|
19
|
+
import { MESSAGE_SYNC } from '../collab/protocol.ts';
|
|
20
|
+
import { createRoom, type RoomCallbacks, type RoomConn } from '../collab/room.ts';
|
|
21
|
+
|
|
22
|
+
function makeConn(
|
|
23
|
+
id: string,
|
|
24
|
+
opts: { realm?: 'main' | 'canvas'; readOnly?: boolean } = {}
|
|
25
|
+
): RoomConn & { recv: Uint8Array[] } {
|
|
26
|
+
const recv: Uint8Array[] = [];
|
|
27
|
+
return {
|
|
28
|
+
id,
|
|
29
|
+
...(opts.realm ? { realm: opts.realm } : {}),
|
|
30
|
+
...(opts.readOnly !== undefined ? { readOnly: opts.readOnly } : {}),
|
|
31
|
+
send(payload: Uint8Array) {
|
|
32
|
+
recv.push(payload);
|
|
33
|
+
},
|
|
34
|
+
recv,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function makeCallbacks(): RoomCallbacks {
|
|
39
|
+
return {
|
|
40
|
+
seed() {},
|
|
41
|
+
persistJson() {},
|
|
42
|
+
persistBinary() {},
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function syncUpdateFrame(update: Uint8Array): Uint8Array {
|
|
47
|
+
const encoder = encoding.createEncoder();
|
|
48
|
+
encoding.writeVarUint(encoder, MESSAGE_SYNC);
|
|
49
|
+
writeUpdate(encoder, update);
|
|
50
|
+
return encoding.toUint8Array(encoder);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function peerDelta(room: { doc: Y.Doc }, mutate: (doc: Y.Doc) => void): Uint8Array {
|
|
54
|
+
const peer = new Y.Doc();
|
|
55
|
+
Y.applyUpdate(peer, Y.encodeStateAsUpdate(room.doc));
|
|
56
|
+
const before = Y.encodeStateVector(peer);
|
|
57
|
+
mutate(peer);
|
|
58
|
+
return Y.encodeStateAsUpdate(peer, before);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
describe('room read-only gate — cloud viewer sockets', () => {
|
|
62
|
+
test('a read-only canvas conn may write COMMENTS (the viewer capability)', async () => {
|
|
63
|
+
const room = createRoom('viewer-comments', makeCallbacks());
|
|
64
|
+
const viewer = makeConn('viewer', { realm: 'canvas', readOnly: true });
|
|
65
|
+
await room.connect(viewer);
|
|
66
|
+
|
|
67
|
+
room.receive(
|
|
68
|
+
viewer,
|
|
69
|
+
syncUpdateFrame(
|
|
70
|
+
peerDelta(room, (d) => d.getArray('comments').push([{ id: 'c1', text: 'a viewer note' }]))
|
|
71
|
+
)
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
expect(room.doc.getArray('comments').toArray()).toEqual([{ id: 'c1', text: 'a viewer note' }]);
|
|
75
|
+
expect(room.gateRefusals()).toBe(0);
|
|
76
|
+
await room.destroy();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('a read-only conn may NOT write annotations — refused, doc untouched, not broadcast', async () => {
|
|
80
|
+
const room = createRoom('viewer-annotations', makeCallbacks());
|
|
81
|
+
const editor = makeConn('editor', { realm: 'canvas', readOnly: false });
|
|
82
|
+
const viewer = makeConn('viewer', { realm: 'canvas', readOnly: true });
|
|
83
|
+
await room.connect(editor);
|
|
84
|
+
await room.connect(viewer);
|
|
85
|
+
editor.recv.length = 0;
|
|
86
|
+
viewer.recv.length = 0;
|
|
87
|
+
|
|
88
|
+
room.receive(
|
|
89
|
+
viewer,
|
|
90
|
+
syncUpdateFrame(peerDelta(room, (d) => d.getMap('annotations').set('svg', '<svg/>')))
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
expect(room.doc.getMap('annotations').get('svg')).toBeUndefined();
|
|
94
|
+
expect(room.gateRefusals()).toBe(1);
|
|
95
|
+
expect(editor.recv.length).toBe(0);
|
|
96
|
+
// The refused peer got a corrective full-state frame instead.
|
|
97
|
+
expect(viewer.recv.length).toBe(1);
|
|
98
|
+
await room.destroy();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('a read-only conn may not write body lanes either — even on the main realm', async () => {
|
|
102
|
+
const room = createRoom('viewer-body', makeCallbacks());
|
|
103
|
+
const viewer = makeConn('viewer', { realm: 'main', readOnly: true });
|
|
104
|
+
await room.connect(viewer);
|
|
105
|
+
|
|
106
|
+
room.receive(
|
|
107
|
+
viewer,
|
|
108
|
+
syncUpdateFrame(peerDelta(room, (d) => d.getText('html').insert(0, 'PWNED')))
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
expect(room.doc.getText('html').toString()).toBe('');
|
|
112
|
+
expect(room.gateRefusals()).toBe(1);
|
|
113
|
+
await room.destroy();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test('a WRITABLE canvas conn (an owner through the cloud lane) still annotates', async () => {
|
|
117
|
+
// The reason the role claim exists at all: annotations are exactly what the
|
|
118
|
+
// cloud complaint was about, and they need an editor-or-better socket.
|
|
119
|
+
const room = createRoom('owner-annotates', makeCallbacks());
|
|
120
|
+
const owner = makeConn('owner', { realm: 'canvas', readOnly: false });
|
|
121
|
+
await room.connect(owner);
|
|
122
|
+
|
|
123
|
+
room.receive(
|
|
124
|
+
owner,
|
|
125
|
+
syncUpdateFrame(
|
|
126
|
+
peerDelta(room, (d) => d.getMap('annotations').set('svg', '<svg><rect/></svg>'))
|
|
127
|
+
)
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
expect(room.doc.getMap('annotations').get('svg')).toBe('<svg><rect/></svg>');
|
|
131
|
+
expect(room.gateRefusals()).toBe(0);
|
|
132
|
+
await room.destroy();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test('awareness frames from a read-only conn pass — presence for viewers is the point', async () => {
|
|
136
|
+
const room = createRoom('viewer-presence', makeCallbacks());
|
|
137
|
+
const viewer = makeConn('viewer', { realm: 'canvas', readOnly: true });
|
|
138
|
+
await room.connect(viewer);
|
|
139
|
+
|
|
140
|
+
const peerDoc = new Y.Doc();
|
|
141
|
+
const { Awareness, encodeAwarenessUpdate } = await import('y-protocols/awareness');
|
|
142
|
+
const peerAwareness = new Awareness(peerDoc);
|
|
143
|
+
peerAwareness.setLocalState({ name: 'viewing member' });
|
|
144
|
+
const encoder = encoding.createEncoder();
|
|
145
|
+
encoding.writeVarUint(encoder, 1 /* MESSAGE_AWARENESS */);
|
|
146
|
+
encoding.writeVarUint8Array(
|
|
147
|
+
encoder,
|
|
148
|
+
encodeAwarenessUpdate(peerAwareness, [peerAwareness.clientID])
|
|
149
|
+
);
|
|
150
|
+
room.receive(viewer, encoding.toUint8Array(encoder));
|
|
151
|
+
|
|
152
|
+
expect(room.awareness.getStates().get(peerAwareness.clientID)).toEqual({
|
|
153
|
+
name: 'viewing member',
|
|
154
|
+
});
|
|
155
|
+
expect(room.gateRefusals()).toBe(0);
|
|
156
|
+
await room.destroy();
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test('a mixed frame (comment + annotation) from a read-only conn is refused wholesale', async () => {
|
|
160
|
+
const room = createRoom('viewer-mixed', makeCallbacks());
|
|
161
|
+
const viewer = makeConn('viewer', { realm: 'canvas', readOnly: true });
|
|
162
|
+
await room.connect(viewer);
|
|
163
|
+
|
|
164
|
+
room.receive(
|
|
165
|
+
viewer,
|
|
166
|
+
syncUpdateFrame(
|
|
167
|
+
peerDelta(room, (d) => {
|
|
168
|
+
d.getArray('comments').push([{ id: 'decoy' }]);
|
|
169
|
+
d.getMap('annotations').set('svg', '<svg/>');
|
|
170
|
+
})
|
|
171
|
+
)
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
expect(room.gateRefusals()).toBe(1);
|
|
175
|
+
expect(room.doc.getArray('comments').length).toBe(0);
|
|
176
|
+
expect(room.doc.getMap('annotations').get('svg')).toBeUndefined();
|
|
177
|
+
await room.destroy();
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test('absent readOnly defaults to writable — pre-existing loopback callers unchanged', async () => {
|
|
181
|
+
const room = createRoom('legacy-writable', makeCallbacks());
|
|
182
|
+
const legacy = makeConn('legacy', { realm: 'canvas' });
|
|
183
|
+
await room.connect(legacy);
|
|
184
|
+
|
|
185
|
+
room.receive(
|
|
186
|
+
legacy,
|
|
187
|
+
syncUpdateFrame(peerDelta(room, (d) => d.getMap('annotations').set('svg', '<svg/>')))
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
expect(room.doc.getMap('annotations').get('svg')).toBe('<svg/>');
|
|
191
|
+
expect(room.gateRefusals()).toBe(0);
|
|
192
|
+
await room.destroy();
|
|
193
|
+
});
|
|
194
|
+
});
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// The engine a CELL actually runs — Cloud Phase 27 D2.
|
|
2
|
+
//
|
|
3
|
+
// WHY THIS FILE EXISTS. `MAUDE_USE_SYSTEM_GIT=1` used to be an escape hatch
|
|
4
|
+
// nobody set, so `commitSystem` and the system-git halves of checkout / branch /
|
|
5
|
+
// discard / fold had no coverage at all while their isomorphic-git twins had
|
|
6
|
+
// plenty. D2 makes every cloud tenant's saves go through exactly those halves
|
|
7
|
+
// (studio-child.mjs pins it, because two git engines over one index is not a
|
|
8
|
+
// race a careful caller can avoid — it is two programs writing one file).
|
|
9
|
+
//
|
|
10
|
+
// Shipping that flip while its code path was the untested one would be trading a
|
|
11
|
+
// known bug for an unmeasured one. These are the same assertions the iso engine
|
|
12
|
+
// already gets, run against the engine the cloud uses.
|
|
13
|
+
|
|
14
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
15
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
16
|
+
import { tmpdir } from 'node:os';
|
|
17
|
+
import { join } from 'node:path';
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
gitCheckout,
|
|
21
|
+
gitCommit,
|
|
22
|
+
gitCreateBranch,
|
|
23
|
+
gitDiscard,
|
|
24
|
+
gitListBranches,
|
|
25
|
+
gitStatus,
|
|
26
|
+
} from '../git/service.ts';
|
|
27
|
+
|
|
28
|
+
let dir: string;
|
|
29
|
+
let saved: string | undefined;
|
|
30
|
+
|
|
31
|
+
function run(args: string[], cwd: string) {
|
|
32
|
+
const r = Bun.spawnSync(['git', ...args], { cwd });
|
|
33
|
+
return { code: r.exitCode, stdout: r.stdout.toString(), stderr: r.stderr.toString() };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
beforeEach(() => {
|
|
37
|
+
saved = process.env.MAUDE_USE_SYSTEM_GIT;
|
|
38
|
+
// The whole point: the flag is read LIVE, so it can be scoped to this file
|
|
39
|
+
// instead of leaking into every other suite through a module-load const.
|
|
40
|
+
process.env.MAUDE_USE_SYSTEM_GIT = '1';
|
|
41
|
+
|
|
42
|
+
dir = mkdtempSync(join(tmpdir(), 'maude-system-git-'));
|
|
43
|
+
run(['init', '-b', 'main'], dir);
|
|
44
|
+
run(['config', 'user.name', 'Test'], dir);
|
|
45
|
+
run(['config', 'user.email', 'test@example.com'], dir);
|
|
46
|
+
mkdirSync(join(dir, '.design', 'ui'), { recursive: true });
|
|
47
|
+
writeFileSync(join(dir, '.design', 'ui', 'Home.tsx'), 'export const v = 1;\n');
|
|
48
|
+
run(['add', '-A'], dir);
|
|
49
|
+
run(['commit', '-m', 'seed'], dir);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
afterEach(() => {
|
|
53
|
+
if (saved === undefined) {
|
|
54
|
+
delete process.env.MAUDE_USE_SYSTEM_GIT;
|
|
55
|
+
} else process.env.MAUDE_USE_SYSTEM_GIT = saved;
|
|
56
|
+
rmSync(dir, { recursive: true, force: true });
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe('the system-git engine a cell runs on', () => {
|
|
60
|
+
test('a save commits exactly the selected file and nothing else', async () => {
|
|
61
|
+
writeFileSync(join(dir, '.design', 'ui', 'Home.tsx'), 'export const v = 2;\n');
|
|
62
|
+
writeFileSync(join(dir, '.design', 'ui', 'Other.tsx'), 'export const other = 1;\n');
|
|
63
|
+
|
|
64
|
+
const res = await gitCommit(dir, 'update home', ['.design/ui/Home.tsx'], {
|
|
65
|
+
designPrefix: '.design',
|
|
66
|
+
});
|
|
67
|
+
expect(res.ok).toBe(true);
|
|
68
|
+
|
|
69
|
+
// The other file is still uncommitted — "Save selected" must mean selected.
|
|
70
|
+
const after = await gitStatus(dir, { designPrefix: '.design' });
|
|
71
|
+
expect(after.files.map((f) => f.path)).toEqual(['.design/ui/Other.tsx']);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('save-all commits every change in scope', async () => {
|
|
75
|
+
writeFileSync(join(dir, '.design', 'ui', 'Home.tsx'), 'export const v = 3;\n');
|
|
76
|
+
writeFileSync(join(dir, '.design', 'ui', 'New.tsx'), 'export const n = 1;\n');
|
|
77
|
+
|
|
78
|
+
const res = await gitCommit(dir, 'save all', undefined, { designPrefix: '.design' });
|
|
79
|
+
expect(res.ok).toBe(true);
|
|
80
|
+
expect((await gitStatus(dir, { designPrefix: '.design' })).clean).toBe(true);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('nothing to save is a refusal, not an empty commit', async () => {
|
|
84
|
+
const before = run(['rev-list', '--count', 'HEAD'], dir).stdout.trim();
|
|
85
|
+
const res = await gitCommit(dir, 'nothing', undefined, { designPrefix: '.design' });
|
|
86
|
+
expect(res.ok).toBe(false);
|
|
87
|
+
expect(run(['rev-list', '--count', 'HEAD'], dir).stdout.trim()).toBe(before);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('discard restores a tracked file and deletes an untracked one', async () => {
|
|
91
|
+
writeFileSync(join(dir, '.design', 'ui', 'Home.tsx'), 'wrecked\n');
|
|
92
|
+
writeFileSync(join(dir, '.design', 'ui', 'Scratch.tsx'), 'temporary\n');
|
|
93
|
+
|
|
94
|
+
const res = await gitDiscard(dir, ['.design/ui/Home.tsx', '.design/ui/Scratch.tsx'], {
|
|
95
|
+
designPrefix: '.design',
|
|
96
|
+
});
|
|
97
|
+
expect(res.ok).toBe(true);
|
|
98
|
+
expect(readFileSync(join(dir, '.design', 'ui', 'Home.tsx'), 'utf8')).toBe(
|
|
99
|
+
'export const v = 1;\n'
|
|
100
|
+
);
|
|
101
|
+
expect(existsSync(join(dir, '.design', 'ui', 'Scratch.tsx'))).toBe(false);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('a draft is created, switched to, and switched back', async () => {
|
|
105
|
+
const made = await gitCreateBranch(dir, 'nav-redesign');
|
|
106
|
+
expect(made.ok).toBe(true);
|
|
107
|
+
expect(run(['rev-parse', '--abbrev-ref', 'HEAD'], dir).stdout.trim()).toBe('nav-redesign');
|
|
108
|
+
|
|
109
|
+
const back = await gitCheckout(dir, 'main');
|
|
110
|
+
expect(back.ok).toBe(true);
|
|
111
|
+
expect(run(['rev-parse', '--abbrev-ref', 'HEAD'], dir).stdout.trim()).toBe('main');
|
|
112
|
+
|
|
113
|
+
expect((await gitListBranches(dir)).map((b) => b.name).sort()).toEqual([
|
|
114
|
+
'main',
|
|
115
|
+
'nav-redesign',
|
|
116
|
+
]);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('switching with unsaved changes says so instead of losing them', async () => {
|
|
120
|
+
await gitCreateBranch(dir, 'draft');
|
|
121
|
+
writeFileSync(join(dir, '.design', 'ui', 'Home.tsx'), 'export const v = 99;\n');
|
|
122
|
+
await gitCommit(dir, 'draft edit', undefined, { designPrefix: '.design' });
|
|
123
|
+
await gitCheckout(dir, 'main');
|
|
124
|
+
|
|
125
|
+
// Now dirty on main in a way the switch would clobber.
|
|
126
|
+
writeFileSync(join(dir, '.design', 'ui', 'Home.tsx'), 'export const v = 1000;\n');
|
|
127
|
+
const res = await gitCheckout(dir, 'draft');
|
|
128
|
+
expect(res.ok).toBe(false);
|
|
129
|
+
expect(res.error).toContain('Save your changes');
|
|
130
|
+
// The unsaved bytes are still there — the refusal is the point.
|
|
131
|
+
expect(readFileSync(join(dir, '.design', 'ui', 'Home.tsx'), 'utf8')).toBe(
|
|
132
|
+
'export const v = 1000;\n'
|
|
133
|
+
);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
@@ -76,6 +76,14 @@ describe('read-only gate — viewer role refuses project writes locally', () =>
|
|
|
76
76
|
['/_api/fs-mkdir', 'POST'],
|
|
77
77
|
['/_api/annotations', 'PUT'],
|
|
78
78
|
['/_api/acp/chat', 'POST'],
|
|
79
|
+
// Cloud Phase 27 D2 — both USED to be on READ_ONLY_ALLOWED_WRITES, on the
|
|
80
|
+
// reasoning that "viewing another branch is not changing one". Both
|
|
81
|
+
// rewrite the working TREE, and the tree is shared: a viewer switching
|
|
82
|
+
// branches replaces the files under whoever is mid-edit. The cell's proxy
|
|
83
|
+
// refuses them too, but this is the gate the hub-linked desktop and the
|
|
84
|
+
// self-host path consult, where no manifest runs at all.
|
|
85
|
+
['/_api/git/checkout', 'POST'],
|
|
86
|
+
['/_api/git/pull', 'POST'],
|
|
79
87
|
] as const) {
|
|
80
88
|
const r = await fetch(base + path, { method, body: '{}' });
|
|
81
89
|
expect(`${path} ${r.status}`).toBe(`${path} 403`);
|