@1agh/maude 0.53.1 → 0.54.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 +13 -7
- package/apps/studio/bin/screenshot.sh +173 -17
- package/apps/studio/build.ts +37 -3
- package/apps/studio/canvas-build-sandbox.ts +385 -0
- package/apps/studio/canvas-build-worker.ts +85 -0
- package/apps/studio/client/app.jsx +308 -21
- package/apps/studio/client/canvas-url.js +7 -0
- package/apps/studio/client/export-center.jsx +10 -2
- package/apps/studio/client/panels/SettingsPanel.jsx +37 -12
- package/apps/studio/client/report-bug.jsx +125 -30
- package/apps/studio/client/styles/4-components-maude.css +5 -0
- package/apps/studio/client/styles/4-components.css +75 -0
- 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 +910 -910
- 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 +383 -31
- package/apps/studio/inspect.ts +66 -6
- package/apps/studio/paths.ts +8 -0
- package/apps/studio/server.ts +216 -53
- 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-build-sandbox.test.ts +69 -0
- package/apps/studio/test/canvas-origin-gate.test.ts +66 -0
- package/apps/studio/test/canvas-shell-base.test.ts +107 -0
- package/apps/studio/test/canvas-url.test.ts +26 -0
- package/apps/studio/test/cloud-build.test.ts +95 -0
- package/apps/studio/test/cloud-session-role.test.ts +131 -0
- package/apps/studio/test/cloud-shell-surfaces.test.ts +119 -0
- package/apps/studio/test/collab-readonly-gate.test.ts +194 -0
- package/apps/studio/test/config-projection.test.ts +104 -0
- package/apps/studio/test/git-system-engine.test.ts +135 -0
- package/apps/studio/test/read-only-gate.test.ts +58 -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/test/workspace-containment.test.ts +90 -11
- package/apps/studio/use-collab.tsx +21 -1
- package/apps/studio/whats-new.json +47 -2
- package/apps/studio/workspace-mode.ts +161 -28
- package/apps/studio/ws.ts +74 -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
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// Cloud Phase 27 D1 — the stub generator, and the reason it is generated.
|
|
2
|
+
//
|
|
3
|
+
// The compiled-artifact assertion lives in `scripts/check-cloud-binary.sh`
|
|
4
|
+
// (two compiles, ~1 min — release-job work). These are the unit-level halves:
|
|
5
|
+
// that a stub covers every runtime export of the module it replaces, and that
|
|
6
|
+
// the value it substitutes cannot crash a boot on a shape nobody remembered.
|
|
7
|
+
|
|
8
|
+
import { describe, expect, test } from 'bun:test';
|
|
9
|
+
import { readFileSync } from 'node:fs';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
CLOUD_ELIMINATED,
|
|
14
|
+
CLOUD_FORBIDDEN_STRINGS,
|
|
15
|
+
exportedNames,
|
|
16
|
+
stubSource,
|
|
17
|
+
} from '../cloud-build.ts';
|
|
18
|
+
|
|
19
|
+
const STUDIO = join(import.meta.dir, '..');
|
|
20
|
+
|
|
21
|
+
describe('cloud elimination', () => {
|
|
22
|
+
test('every listed module exists — a typo eliminates nothing, silently', () => {
|
|
23
|
+
for (const rel of CLOUD_ELIMINATED) {
|
|
24
|
+
expect(() => readFileSync(join(STUDIO, rel), 'utf8')).not.toThrow();
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('the stub covers every runtime export of the real module', () => {
|
|
29
|
+
// A missing export is a boot-time crash in the cloud binary and nowhere
|
|
30
|
+
// else — the worst place for it to surface. Generating from the real file
|
|
31
|
+
// is what keeps this true when a module grows a function.
|
|
32
|
+
for (const rel of CLOUD_ELIMINATED) {
|
|
33
|
+
const source = readFileSync(join(STUDIO, rel), 'utf8');
|
|
34
|
+
const stub = stubSource(source);
|
|
35
|
+
for (const name of exportedNames(source)) {
|
|
36
|
+
expect(stub).toContain(`export const ${name} = inert;`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('it reads the forms this codebase actually uses', () => {
|
|
42
|
+
const names = exportedNames(`
|
|
43
|
+
export function alpha() {}
|
|
44
|
+
export async function beta() {}
|
|
45
|
+
export const gamma = 1;
|
|
46
|
+
export let delta = 2;
|
|
47
|
+
export class Epsilon {}
|
|
48
|
+
export { zeta, eta as theta };
|
|
49
|
+
export interface NotThis {}
|
|
50
|
+
export type NorThis = string;
|
|
51
|
+
const notExported = 3;
|
|
52
|
+
`);
|
|
53
|
+
expect(names.sort()).toEqual(['Epsilon', 'alpha', 'beta', 'delta', 'gamma', 'theta', 'zeta']);
|
|
54
|
+
// Types are erased before any of this matters; listing them would emit a
|
|
55
|
+
// `const` where a type was and break the build for no reason.
|
|
56
|
+
expect(names).not.toContain('NotThis');
|
|
57
|
+
expect(names).not.toContain('NorThis');
|
|
58
|
+
expect(names).not.toContain('notExported');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('the inert value survives every shape a caller might use', async () => {
|
|
62
|
+
// The callers are not alike: one calls `createExportJobQueue(...)` and
|
|
63
|
+
// hands the result to a route table, one calls `installLogRing()` for a
|
|
64
|
+
// side effect, one reads a property off what `createAcp` returned. Anything
|
|
65
|
+
// that answers all of them cannot crash a boot.
|
|
66
|
+
const mod = await import(
|
|
67
|
+
`data:text/javascript;base64,${btoa(stubSource('export function makeThing() {}'))}`
|
|
68
|
+
);
|
|
69
|
+
const { makeThing } = mod;
|
|
70
|
+
expect(() => makeThing()).not.toThrow();
|
|
71
|
+
expect(() => makeThing().someMethod()).not.toThrow();
|
|
72
|
+
expect(() => new makeThing()).not.toThrow();
|
|
73
|
+
expect(() => makeThing().a.b.c()).not.toThrow();
|
|
74
|
+
// NOT thenable — an `await` on it must resolve, not hang forever waiting
|
|
75
|
+
// for a `then` that never calls back.
|
|
76
|
+
const awaited = await makeThing();
|
|
77
|
+
expect(awaited).toBeDefined();
|
|
78
|
+
// Destructuring and iteration are both survivable.
|
|
79
|
+
const { anything } = makeThing();
|
|
80
|
+
expect(anything).toBeDefined();
|
|
81
|
+
expect([...makeThing()]).toEqual([]);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('a sentinel is a string only our own code can produce', () => {
|
|
85
|
+
// `api.github.com` was the first candidate and is useless: Bun's own
|
|
86
|
+
// runtime carries GITHUB_API_DOMAIN, so it appears in every compiled binary
|
|
87
|
+
// whatever we do, and a gate that cannot go red is one people learn to
|
|
88
|
+
// ignore. Each sentinel must therefore be specific enough to be OURS.
|
|
89
|
+
expect(CLOUD_FORBIDDEN_STRINGS.length).toBeGreaterThan(0);
|
|
90
|
+
for (const s of CLOUD_FORBIDDEN_STRINGS) {
|
|
91
|
+
expect(s).not.toBe('api.github.com');
|
|
92
|
+
expect(s.length).toBeGreaterThan(3);
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
});
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// Cloud Phase 27 A3/A4 + C2/C4 (DDR-209) — the studio in a cell.
|
|
2
|
+
//
|
|
3
|
+
// The behaviour under test is the one that makes a cell multi-tenant-safe: in
|
|
4
|
+
// workspace mode this process serves an owner and a viewer at the SAME TIME, so
|
|
5
|
+
// "what may this request do" cannot come from a file on disk. It comes from the
|
|
6
|
+
// proxy in front, per request, and in the absence of proof it is `viewer`.
|
|
7
|
+
//
|
|
8
|
+
// The local gate this replaces fails OPEN by design — `isHubReadOnly()` returns
|
|
9
|
+
// false from its `catch`, and `projectReadOnly()` returns false when `linkedHub`
|
|
10
|
+
// is unset. That is correct for a tool on your own laptop and is the whole
|
|
11
|
+
// ballgame on the internet, which is why the no-header case below is the most
|
|
12
|
+
// important assertion in the file.
|
|
13
|
+
|
|
14
|
+
import { afterAll, describe, expect, test } from 'bun:test';
|
|
15
|
+
import type { Subprocess } from 'bun';
|
|
16
|
+
|
|
17
|
+
import { bootServer, killProc, makeSandbox, nextPort } from './_helpers.ts';
|
|
18
|
+
|
|
19
|
+
const procs: Subprocess[] = [];
|
|
20
|
+
afterAll(async () => {
|
|
21
|
+
for (const p of procs) await killProc(p);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
async function bootCell() {
|
|
25
|
+
const { root } = makeSandbox();
|
|
26
|
+
const port = nextPort();
|
|
27
|
+
const proc = await bootServer(root, port, {
|
|
28
|
+
MAUDE_WORKSPACE_MODE: '1',
|
|
29
|
+
// A dev checkout resolves Playwright (the E2E harness), which is a
|
|
30
|
+
// legitimate devDependency here and would otherwise fail the module half of
|
|
31
|
+
// the containment assert before workspace mode could be tested at all.
|
|
32
|
+
MAUDE_WORKSPACE_ALLOW_DEV_MODULES: '1',
|
|
33
|
+
HUB_DASHBOARD_URL: 'https://cloud.maude.sh',
|
|
34
|
+
MAUDE_PROJECT_NAME: 'Alligators',
|
|
35
|
+
});
|
|
36
|
+
procs.push(proc);
|
|
37
|
+
return { base: `http://localhost:${port}` };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe('a cell resolves the role per request, and defaults to read-only', () => {
|
|
41
|
+
test('no injected role at all ⇒ read-only, and writes are refused', async () => {
|
|
42
|
+
const { base } = await bootCell();
|
|
43
|
+
|
|
44
|
+
const cfg = (await (await fetch(`${base}/_config`)).json()) as { readOnly?: boolean };
|
|
45
|
+
expect(cfg.readOnly).toBe(true);
|
|
46
|
+
|
|
47
|
+
const res = await fetch(`${base}/_api/edit-text`, {
|
|
48
|
+
method: 'POST',
|
|
49
|
+
headers: { 'content-type': 'application/json' },
|
|
50
|
+
body: JSON.stringify({ file: 'ui/Sample.tsx', id: 'x', text: 'y' }),
|
|
51
|
+
});
|
|
52
|
+
expect(res.status).toBe(403);
|
|
53
|
+
expect(((await res.json()) as { reason?: string }).reason).toBe('read-only');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('a vouched editing role opens the same write', async () => {
|
|
57
|
+
const { base } = await bootCell();
|
|
58
|
+
|
|
59
|
+
const cfg = (await (
|
|
60
|
+
await fetch(`${base}/_config`, { headers: { 'x-maude-readonly': '0' } })
|
|
61
|
+
).json()) as { readOnly?: boolean };
|
|
62
|
+
expect(cfg.readOnly).toBe(false);
|
|
63
|
+
|
|
64
|
+
const res = await fetch(`${base}/_api/edit-text`, {
|
|
65
|
+
method: 'POST',
|
|
66
|
+
headers: { 'content-type': 'application/json', 'x-maude-readonly': '0' },
|
|
67
|
+
body: JSON.stringify({ file: 'ui/Sample.tsx', id: 'x', text: 'y' }),
|
|
68
|
+
});
|
|
69
|
+
// Whatever the handler makes of the payload, the GATE let it through — the
|
|
70
|
+
// read-only refusal is the specific thing that must not happen.
|
|
71
|
+
expect(res.status).not.toBe(403);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('anything other than a positive `0` is read-only', async () => {
|
|
75
|
+
// The failure mode this rules out: a truthy-string check, where
|
|
76
|
+
// `x-maude-readonly: false` (a plausible thing for a future caller to send)
|
|
77
|
+
// reads as "not read-only".
|
|
78
|
+
const { base } = await bootCell();
|
|
79
|
+
for (const value of ['1', 'false', 'no', '', 'true', 'owner']) {
|
|
80
|
+
const cfg = (await (
|
|
81
|
+
await fetch(`${base}/_config`, { headers: { 'x-maude-readonly': value } })
|
|
82
|
+
).json()) as { readOnly?: boolean };
|
|
83
|
+
expect(cfg.readOnly).toBe(true);
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('two requests to ONE process get two different answers', async () => {
|
|
88
|
+
// The actual multi-tenancy property, stated as a test: this is what a
|
|
89
|
+
// per-PROCESS config file cannot do, and it is why A3 exists.
|
|
90
|
+
const { base } = await bootCell();
|
|
91
|
+
const [viewer, member] = await Promise.all([
|
|
92
|
+
fetch(`${base}/_config`, { headers: { 'x-maude-readonly': '1' } }).then((r) => r.json()),
|
|
93
|
+
fetch(`${base}/_config`, { headers: { 'x-maude-readonly': '0' } }).then((r) => r.json()),
|
|
94
|
+
]);
|
|
95
|
+
expect((viewer as { readOnly: boolean }).readOnly).toBe(true);
|
|
96
|
+
expect((member as { readOnly: boolean }).readOnly).toBe(false);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe('the cloud shell states what it is', () => {
|
|
101
|
+
test('/_config carries the cloud block C2 and C4 render from', async () => {
|
|
102
|
+
const { base } = await bootCell();
|
|
103
|
+
const cfg = (await (await fetch(`${base}/_config`)).json()) as {
|
|
104
|
+
cloud?: { dashboardUrl?: string; projectName?: string };
|
|
105
|
+
};
|
|
106
|
+
expect(cfg.cloud).toBeTruthy();
|
|
107
|
+
expect(cfg.cloud?.dashboardUrl).toBe('https://cloud.maude.sh');
|
|
108
|
+
expect(cfg.cloud?.projectName).toBe('Alligators');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('the canvas capability is echoed only when the proxy minted one', async () => {
|
|
112
|
+
const { base } = await bootCell();
|
|
113
|
+
const without = (await (await fetch(`${base}/_config`)).json()) as { canvasToken?: string };
|
|
114
|
+
expect(without.canvasToken).toBeUndefined();
|
|
115
|
+
|
|
116
|
+
const with_ = (await (
|
|
117
|
+
await fetch(`${base}/_config`, { headers: { 'x-maude-canvas-token': 'cap-123' } })
|
|
118
|
+
).json()) as { canvasToken?: string };
|
|
119
|
+
expect(with_.canvasToken).toBe('cap-123');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('a cell SERVES the canvas surfaces it used to 404 (DDR-209 A′1)', async () => {
|
|
123
|
+
const { base } = await bootCell();
|
|
124
|
+
// The shell is a static harness; the browser is what evaluates it. Before
|
|
125
|
+
// DDR-209 this was a hard 404 in workspace mode, which is precisely why
|
|
126
|
+
// Phase 25 had to hand-roll a second studio.
|
|
127
|
+
const shell = await fetch(`${base}/_canvas-shell.html`);
|
|
128
|
+
expect(shell.status).toBe(200);
|
|
129
|
+
expect(shell.headers.get('content-type')).toContain('text/html');
|
|
130
|
+
});
|
|
131
|
+
});
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// Surfaces a cloud tab must not offer — Cloud Phase 27, found by using it.
|
|
2
|
+
//
|
|
3
|
+
// The phase's rule is C1's: role shapes what is OFFERED, never what is VISIBLE.
|
|
4
|
+
// That rule is about ROLES. A different question turned up the moment the real
|
|
5
|
+
// studio actually ran in a browser: what about a control whose feature cannot
|
|
6
|
+
// exist in THIS SHELL at all?
|
|
7
|
+
//
|
|
8
|
+
// Three answers, and the difference between them is the whole point:
|
|
9
|
+
//
|
|
10
|
+
// the agent chat ABSENT, WITH A NOTICE. It runs on your own Claude
|
|
11
|
+
// subscription on your own machine (DDR-123). The
|
|
12
|
+
// feature exists and you can have it — the menu row
|
|
13
|
+
// stays, disabled, saying where. (C2)
|
|
14
|
+
//
|
|
15
|
+
// "Sign in to Maude ABSENT, SILENTLY. It offers to do the thing you have
|
|
16
|
+
// Cloud" already done: you reached this tab THROUGH a Maude
|
|
17
|
+
// account. There is nothing to explain.
|
|
18
|
+
//
|
|
19
|
+
// AI generation / ABSENT, SILENTLY. These configure ffmpeg, mlx-vlm, a
|
|
20
|
+
// Subtitles / Video HuggingFace cache and your own provider keys — none
|
|
21
|
+
// of which a browser tab has, and whose routes a cell
|
|
22
|
+
// refuses by design (DDR-209 D1). Left in, they render
|
|
23
|
+
// as a wall of `HTTP 404`, which is what the owner saw.
|
|
24
|
+
//
|
|
25
|
+
// Source-level assertions, because the menubar's dropdowns do not open under a
|
|
26
|
+
// synthetic click and a test that cannot fail is worse than no test. What these
|
|
27
|
+
// pin is exactly what would regress: someone re-introducing `TABS.map` or
|
|
28
|
+
// dropping the gate on `<CloudBar />`.
|
|
29
|
+
|
|
30
|
+
import { describe, expect, test } from 'bun:test';
|
|
31
|
+
import { readFileSync } from 'node:fs';
|
|
32
|
+
import { join } from 'node:path';
|
|
33
|
+
|
|
34
|
+
const STUDIO = join(import.meta.dir, '..');
|
|
35
|
+
const APP = readFileSync(join(STUDIO, 'client', 'app.jsx'), 'utf8');
|
|
36
|
+
const SETTINGS = readFileSync(join(STUDIO, 'client', 'panels', 'SettingsPanel.jsx'), 'utf8');
|
|
37
|
+
|
|
38
|
+
describe('the cloud shell offers nothing it cannot honour', () => {
|
|
39
|
+
test('the local-machine settings tabs are flagged, and only those three', () => {
|
|
40
|
+
const flagged = [
|
|
41
|
+
...SETTINGS.matchAll(/\{ id: '([a-z-]+)', label: '[^']+', local: true \}/g),
|
|
42
|
+
].map((m) => m[1]);
|
|
43
|
+
expect(flagged.sort()).toEqual(['ai-generation', 'subtitles', 'video']);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('the tab rail renders the FILTERED list, not the raw table', () => {
|
|
47
|
+
// `TABS.map` is what shipped the wall of 404s.
|
|
48
|
+
expect(SETTINGS).toContain('{tabs.map((t) => (');
|
|
49
|
+
expect(SETTINGS).not.toContain('{TABS.map(');
|
|
50
|
+
// …and every keyboard path must walk the same list, or End/Home select a
|
|
51
|
+
// tab that is not on screen.
|
|
52
|
+
expect(SETTINGS).not.toMatch(/setTab\(TABS\[/);
|
|
53
|
+
expect(SETTINGS).toContain('const tabs = useMemo(');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('a remembered tab this shell does not have cannot select nothing', () => {
|
|
57
|
+
// Open Video on the desktop, then open Settings in the cloud.
|
|
58
|
+
const init =
|
|
59
|
+
/const \[tab, setTab\] = useState\(\(\) => \{[\s\S]*?\}\);/.exec(SETTINGS)?.[0] ?? '';
|
|
60
|
+
expect(init).toContain('!(cloud && t.local)');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('the cloud sign-in is not offered inside the cloud', () => {
|
|
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 })');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('every component that gates on the shell is actually GIVEN the flag', () => {
|
|
76
|
+
// The bug this exists for: three `cfg?.cloud` reads inside a component that
|
|
77
|
+
// never receives `cfg` — a ReferenceError at render, and a blank app.
|
|
78
|
+
for (const call of ['<Menubar', '<Sidebar', '<SettingsPanel']) {
|
|
79
|
+
const at = APP.indexOf(call);
|
|
80
|
+
expect(at).toBeGreaterThan(0);
|
|
81
|
+
const props = APP.slice(at, at + 900);
|
|
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}');
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('no component reaches for `cfg` where only the flag was passed', () => {
|
|
89
|
+
// `cfg` is a real variable at the top level, so a stray reference in a
|
|
90
|
+
// child component is a ReferenceError the bundler happily emits.
|
|
91
|
+
for (const fn of ['function Menubar({', 'function Sidebar({']) {
|
|
92
|
+
const start = APP.indexOf(fn);
|
|
93
|
+
expect(start).toBeGreaterThan(0);
|
|
94
|
+
// Bounded window: the component's own body, not the whole file.
|
|
95
|
+
const body = APP.slice(start, APP.indexOf('\nfunction ', start + 10));
|
|
96
|
+
expect(body).not.toMatch(/\bcfg\?\./);
|
|
97
|
+
expect(body).not.toMatch(/\bcfg\./);
|
|
98
|
+
}
|
|
99
|
+
});
|
|
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,104 @@
|
|
|
1
|
+
// The `/_config` projection — Cloud Phase 27.
|
|
2
|
+
//
|
|
3
|
+
// WHAT WENT WRONG, TWICE IN ONE CHANGE.
|
|
4
|
+
//
|
|
5
|
+
// The server's `/_config` handler adds fields to the config it returns. The
|
|
6
|
+
// client does NOT spread that response — it builds a new object naming each
|
|
7
|
+
// field it wants (a deliberate choice: `/_config` and `/_index-data` race, and
|
|
8
|
+
// a full replace would clobber `canvasDesignSystems`). So a field the server
|
|
9
|
+
// starts returning arrives nowhere until somebody names it on the client too,
|
|
10
|
+
// and NOTHING says so. Phase 27 added two and named neither:
|
|
11
|
+
//
|
|
12
|
+
// `cloud` → the cloud studio rendered without its "which project am I
|
|
13
|
+
// in / how do I leave" chrome, silently.
|
|
14
|
+
// `canvasToken` → every canvas iframe loaded the cookieless canvas origin
|
|
15
|
+
// with no capability, so every canvas would have 401'd.
|
|
16
|
+
//
|
|
17
|
+
// Both shipped to production. The first was found by looking at the page; the
|
|
18
|
+
// second had not been found at all.
|
|
19
|
+
//
|
|
20
|
+
// This test reads BOTH sides out of source and demands they agree. It is a
|
|
21
|
+
// grep-gate, the same trade `scripts/check-containment.sh` makes, and it is
|
|
22
|
+
// sound for the same reason: the shapes it matches are the ones both files have
|
|
23
|
+
// had since they were written, and the "did the regex find anything" assertions
|
|
24
|
+
// below are what stop it passing vacuously.
|
|
25
|
+
|
|
26
|
+
import { describe, expect, test } from 'bun:test';
|
|
27
|
+
import { readFileSync } from 'node:fs';
|
|
28
|
+
import { join } from 'node:path';
|
|
29
|
+
|
|
30
|
+
const STUDIO = join(import.meta.dir, '..');
|
|
31
|
+
const HTTP_TS = readFileSync(join(STUDIO, 'http.ts'), 'utf8');
|
|
32
|
+
const APP_JSX = readFileSync(join(STUDIO, 'client', 'app.jsx'), 'utf8');
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Fields the `/_config` handler adds ON TOP of the `...ctx.cfg` spread.
|
|
36
|
+
*
|
|
37
|
+
* The spread itself is not enumerable from source and does not need to be: it
|
|
38
|
+
* is the project's own config file, which the client already carries wholesale.
|
|
39
|
+
* The risk is in the COMPUTED fields — the ones the server decides per request.
|
|
40
|
+
*/
|
|
41
|
+
function serverComputedConfigFields(): string[] {
|
|
42
|
+
const start = HTTP_TS.indexOf("'/_config':");
|
|
43
|
+
expect(start).toBeGreaterThan(0);
|
|
44
|
+
// The handler is short; take to the end of its `Response.json({...})`.
|
|
45
|
+
const body = HTTP_TS.slice(start, start + 2000);
|
|
46
|
+
const end = body.indexOf('\n // ');
|
|
47
|
+
const src = end > 0 ? body.slice(0, end) : body;
|
|
48
|
+
|
|
49
|
+
const fields = new Set<string>();
|
|
50
|
+
// `canvasOrigin: ctx.canvasOrigin,` / `readOnly: projectReadOnly(req),`
|
|
51
|
+
for (const m of src.matchAll(/^\s{8}([a-zA-Z][a-zA-Z0-9]*):\s/gm)) fields.add(m[1]);
|
|
52
|
+
// Conditionally-spread fields: `? { canvasToken: … }` / `? { cloud: { … } }`
|
|
53
|
+
for (const m of src.matchAll(/\?\s*\{\s*([a-zA-Z][a-zA-Z0-9]*):/g)) fields.add(m[1]);
|
|
54
|
+
return [...fields].sort();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Fields the client's `/_config` handler copies into `cfg`. */
|
|
58
|
+
function clientProjectedFields(): string[] {
|
|
59
|
+
const start = APP_JSX.indexOf("fetch('/_config')");
|
|
60
|
+
expect(start).toBeGreaterThan(0);
|
|
61
|
+
const end = APP_JSX.indexOf('.catch(() => {});', start);
|
|
62
|
+
expect(end).toBeGreaterThan(start);
|
|
63
|
+
const src = APP_JSX.slice(start, end);
|
|
64
|
+
const fields = new Set<string>();
|
|
65
|
+
for (const m of src.matchAll(/^\s{10}([a-zA-Z][a-zA-Z0-9]*):\s/gm)) fields.add(m[1]);
|
|
66
|
+
return [...fields].sort();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
describe('/_config projection', () => {
|
|
70
|
+
test('the scrape finds both sides — a regex that matches nothing approves everything', () => {
|
|
71
|
+
const server = serverComputedConfigFields();
|
|
72
|
+
const client = clientProjectedFields();
|
|
73
|
+
expect(server.length).toBeGreaterThanOrEqual(3);
|
|
74
|
+
expect(client.length).toBeGreaterThanOrEqual(5);
|
|
75
|
+
expect(server).toContain('readOnly');
|
|
76
|
+
expect(client).toContain('readOnly');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('every field the server COMPUTES is named by the client', () => {
|
|
80
|
+
const client = new Set(clientProjectedFields());
|
|
81
|
+
const missing = serverComputedConfigFields().filter((f) => !client.has(f));
|
|
82
|
+
expect(missing).toEqual([]);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('the two fields this gate was written for are both present', () => {
|
|
86
|
+
// Named explicitly as well as covered by the rule above, because these are
|
|
87
|
+
// the ones that actually shipped broken and a future refactor of the regex
|
|
88
|
+
// should not be able to quietly stop covering them.
|
|
89
|
+
const client = new Set(clientProjectedFields());
|
|
90
|
+
expect(client.has('canvasToken')).toBe(true);
|
|
91
|
+
expect(client.has('cloud')).toBe(true);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('the client still MERGES rather than replaces', () => {
|
|
95
|
+
// The reason the projection is explicit in the first place: `/_config` and
|
|
96
|
+
// `/_index-data` race, and a full replace clobbers `canvasDesignSystems`
|
|
97
|
+
// (DDR-093). Anyone "simplifying" this into a spread of `data` must not
|
|
98
|
+
// also drop the functional merge.
|
|
99
|
+
const start = APP_JSX.indexOf("fetch('/_config')");
|
|
100
|
+
const src = APP_JSX.slice(start, APP_JSX.indexOf('.catch(() => {});', start));
|
|
101
|
+
expect(src).toContain('setCfg((prev) => ({');
|
|
102
|
+
expect(src).toContain('...prev,');
|
|
103
|
+
});
|
|
104
|
+
});
|