@1agh/maude 0.53.0 → 0.53.2
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/bin/_import-asset.mjs +1 -6
- package/apps/studio/bin/_import-brand.mjs +1 -1
- package/apps/studio/bin/_video-playwright.mjs +5 -1
- package/apps/studio/bin/screenshot.sh +131 -15
- package/apps/studio/canvas-build-sandbox.ts +385 -0
- package/apps/studio/canvas-build-worker.ts +85 -0
- package/apps/studio/client/app.jsx +112 -8
- package/apps/studio/client/canvas-url.js +7 -0
- package/apps/studio/client/panels/SettingsPanel.jsx +32 -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 +35 -0
- package/apps/studio/dist/client.bundle.js +831 -831
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/http.ts +252 -23
- package/apps/studio/paths.ts +8 -0
- package/apps/studio/server.ts +102 -36
- package/apps/studio/test/canvas-build-sandbox.test.ts +69 -0
- package/apps/studio/test/canvas-origin-gate.test.ts +5 -0
- package/apps/studio/test/canvas-url.test.ts +26 -0
- package/apps/studio/test/cloud-session-role.test.ts +131 -0
- package/apps/studio/test/cloud-shell-surfaces.test.ts +90 -0
- package/apps/studio/test/config-projection.test.ts +104 -0
- package/apps/studio/test/read-only-gate.test.ts +50 -0
- package/apps/studio/test/report-proxy.test.ts +125 -0
- package/apps/studio/test/workspace-containment.test.ts +90 -11
- package/apps/studio/whats-new.json +11 -2
- package/apps/studio/workspace-mode.ts +161 -28
- package/apps/studio/ws.ts +30 -0
- package/package.json +8 -8
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// Cloud Phase 27 — the sandbox child's environment.
|
|
2
|
+
//
|
|
3
|
+
// Diagnosed against a real cell: the canvas module 422'd with "Cannot find
|
|
4
|
+
// native binding" while running the identical worker command by hand with a
|
|
5
|
+
// clean environment worked. The difference was one inherited variable.
|
|
6
|
+
|
|
7
|
+
import { describe, expect, test } from 'bun:test';
|
|
8
|
+
|
|
9
|
+
import { workerEnv } from '../canvas-build-sandbox.ts';
|
|
10
|
+
|
|
11
|
+
describe('workerEnv', () => {
|
|
12
|
+
test('a compiled binary’s VIRTUAL binding path is not handed to a child', () => {
|
|
13
|
+
const env = workerEnv({
|
|
14
|
+
PATH: '/bin',
|
|
15
|
+
NAPI_RS_NATIVE_LIBRARY_PATH: '/$bunfs/root/parser.linux-arm64-gnu.node',
|
|
16
|
+
} as NodeJS.ProcessEnv);
|
|
17
|
+
expect(env.NAPI_RS_NATIVE_LIBRARY_PATH).toBeUndefined();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test('a REAL binding path still is', () => {
|
|
21
|
+
const env = workerEnv({
|
|
22
|
+
PATH: '/bin',
|
|
23
|
+
NAPI_RS_NATIVE_LIBRARY_PATH: '/maude/node_modules/@oxc-parser/binding-linux-arm64-gnu',
|
|
24
|
+
} as NodeJS.ProcessEnv);
|
|
25
|
+
expect(env.NAPI_RS_NATIVE_LIBRARY_PATH).toBe(
|
|
26
|
+
'/maude/node_modules/@oxc-parser/binding-linux-arm64-gnu'
|
|
27
|
+
);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('the child environment carries no secret — it parses tenant source', () => {
|
|
31
|
+
const env = workerEnv({
|
|
32
|
+
PATH: '/bin',
|
|
33
|
+
HUB_SECRET: 'nope',
|
|
34
|
+
AWS_SECRET_ACCESS_KEY: 'also-nope',
|
|
35
|
+
MAUDE_PROJECT_TOKEN_KEY: 'definitely-nope',
|
|
36
|
+
} as NodeJS.ProcessEnv);
|
|
37
|
+
expect(env.HUB_SECRET).toBeUndefined();
|
|
38
|
+
expect(env.AWS_SECRET_ACCESS_KEY).toBeUndefined();
|
|
39
|
+
expect(env.MAUDE_PROJECT_TOKEN_KEY).toBeUndefined();
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// Cloud Phase 27 B2 — content-addressed media is immutable; everything else
|
|
44
|
+
// revalidates. `no-store` is right on a laptop and is a re-download of every
|
|
45
|
+
// photograph on every pan over the internet.
|
|
46
|
+
import { cacheControlFor } from '../http.ts';
|
|
47
|
+
|
|
48
|
+
describe('cacheControlFor', () => {
|
|
49
|
+
test('a content-addressed name is immutable — the name IS the content', () => {
|
|
50
|
+
for (const p of ['/x/assets/deadbeef.png', '/a/b/0123456789abcdef.mp4']) {
|
|
51
|
+
expect(cacheControlFor(p).cacheControl).toBe('public, max-age=31536000, immutable');
|
|
52
|
+
expect(cacheControlFor(p).addEtag).toBe(false);
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('an AUTHORED name revalidates — a designer edits hero.png in place', () => {
|
|
57
|
+
for (const p of ['/x/assets/hero.png', '/x/system/ds/colors_and_type.css', '/x/logos.svg']) {
|
|
58
|
+
expect(cacheControlFor(p).cacheControl).toBe('no-cache');
|
|
59
|
+
expect(cacheControlFor(p).addEtag).toBe(true);
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('a name that merely LOOKS hex-ish is not treated as content-addressed', () => {
|
|
64
|
+
// `deadbee` is 7 chars; `deadbeefs` has a non-hex char. Getting either
|
|
65
|
+
// wrong caches a mutable file for a year.
|
|
66
|
+
expect(cacheControlFor('/x/deadbee.png').cacheControl).toBe('no-cache');
|
|
67
|
+
expect(cacheControlFor('/x/deadbeefs.png').cacheControl).toBe('no-cache');
|
|
68
|
+
});
|
|
69
|
+
});
|
|
@@ -156,6 +156,11 @@ describe('canvas-origin gate — A1/A2 traversal + privilege containment', () =>
|
|
|
156
156
|
'/_api/debug-bundle',
|
|
157
157
|
'/_api/report',
|
|
158
158
|
'/_api/report-fallback',
|
|
159
|
+
// …and the shell capture SPAWNS A PROCESS (screenshot.sh → a headless
|
|
160
|
+
// browser) and renders the whole project. Reachable from the canvas
|
|
161
|
+
// origin it would be a resource-exhaustion lever driven by untrusted
|
|
162
|
+
// canvas code, so it carries the same main-origin-only posture.
|
|
163
|
+
'/_api/shell-shot',
|
|
159
164
|
// Stage F1 — the AssetPicker's asset-listing GET is a shell (main-origin)
|
|
160
165
|
// concern; the untrusted canvas iframe must not enumerate project media.
|
|
161
166
|
'/_api/assets',
|
|
@@ -110,3 +110,29 @@ describe('canvasUrl — per-canvas design-system token resolution', () => {
|
|
|
110
110
|
).toBeNull();
|
|
111
111
|
});
|
|
112
112
|
});
|
|
113
|
+
|
|
114
|
+
// Cloud Phase 27 (DDR-209) — the canvas origin's capability.
|
|
115
|
+
//
|
|
116
|
+
// In the cloud the canvas origin is a cookieless, cross-site hostname: a cookie
|
|
117
|
+
// scoped widely enough to cover it would be readable by the untrusted canvas
|
|
118
|
+
// content itself, which is the one thing the DDR-054 split exists to prevent.
|
|
119
|
+
// So the capability rides in the URL. It reaches `cfg` through the `/_config`
|
|
120
|
+
// projection — which silently dropped it once, and would have 401'd every
|
|
121
|
+
// canvas iframe in production (see `config-projection.test.ts`).
|
|
122
|
+
|
|
123
|
+
test('a canvas URL carries the capability when the server minted one', () => {
|
|
124
|
+
const url = canvasUrl('.design/ui/Home.tsx', {
|
|
125
|
+
designRel: '.design',
|
|
126
|
+
canvasOrigin: 'https://canvas.cloud.maude.sh/alligators',
|
|
127
|
+
canvasToken: 'cap-abc123',
|
|
128
|
+
});
|
|
129
|
+
expect(url.startsWith('https://canvas.cloud.maude.sh/alligators/_canvas-shell.html?')).toBe(true);
|
|
130
|
+
expect(new URL(url).searchParams.get('t')).toBe('cap-abc123');
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('a desktop URL is byte-identical to before — no capability, no query', () => {
|
|
134
|
+
// The loopback canvas origin needs none, so this must not change for the
|
|
135
|
+
// shell that has always worked.
|
|
136
|
+
const url = canvasUrl('.design/ui/Home.tsx', { designRel: '.design' });
|
|
137
|
+
expect(new URL(url, 'http://x').searchParams.has('t')).toBe(false);
|
|
138
|
+
});
|
|
@@ -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,90 @@
|
|
|
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
|
+
expect(APP).toContain('{cloud ? null : <CloudBar />}');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('every component that gates on the shell is actually GIVEN the flag', () => {
|
|
68
|
+
// The bug this exists for: three `cfg?.cloud` reads inside a component that
|
|
69
|
+
// never receives `cfg` — a ReferenceError at render, and a blank app.
|
|
70
|
+
for (const call of ['<Menubar', '<Sidebar', '<SettingsPanel']) {
|
|
71
|
+
const at = APP.indexOf(call);
|
|
72
|
+
expect(at).toBeGreaterThan(0);
|
|
73
|
+
const props = APP.slice(at, at + 900);
|
|
74
|
+
expect(props).toContain('cloud={cfg?.cloud ?? null}');
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('no component reaches for `cfg` where only the flag was passed', () => {
|
|
79
|
+
// `cfg` is a real variable at the top level, so a stray reference in a
|
|
80
|
+
// child component is a ReferenceError the bundler happily emits.
|
|
81
|
+
for (const fn of ['function Menubar({', 'function Sidebar({']) {
|
|
82
|
+
const start = APP.indexOf(fn);
|
|
83
|
+
expect(start).toBeGreaterThan(0);
|
|
84
|
+
// Bounded window: the component's own body, not the whole file.
|
|
85
|
+
const body = APP.slice(start, APP.indexOf('\nfunction ', start + 10));
|
|
86
|
+
expect(body).not.toMatch(/\bcfg\?\./);
|
|
87
|
+
expect(body).not.toMatch(/\bcfg\./);
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
});
|
|
@@ -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
|
+
});
|
|
@@ -128,3 +128,53 @@ describe('read-only gate — viewer role refuses project writes locally', () =>
|
|
|
128
128
|
expect(r.status).not.toBe(403);
|
|
129
129
|
}, 30000);
|
|
130
130
|
});
|
|
131
|
+
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
// Cloud Phase 27 — the one write a viewer holds.
|
|
134
|
+
//
|
|
135
|
+
// The role matrix has said `viewer.comment === true` since Phase 25 C4 and the
|
|
136
|
+
// People page promises it in those words. This list did not have it, so a
|
|
137
|
+
// viewer's comment was allowed by the authority (the cell's proxy) and then
|
|
138
|
+
// refused by the defence-in-depth layer behind it. Found by commenting as a
|
|
139
|
+
// viewer against a real cell, not by reading either file.
|
|
140
|
+
|
|
141
|
+
describe('a viewer may comment — the matrix says so, and so must this gate', () => {
|
|
142
|
+
test('comment + reply pass; annotate still does not', async () => {
|
|
143
|
+
const { base } = await boot('viewer');
|
|
144
|
+
|
|
145
|
+
const comment = await fetch(`${base}/_comments`, {
|
|
146
|
+
method: 'POST',
|
|
147
|
+
headers: { 'content-type': 'application/json' },
|
|
148
|
+
body: JSON.stringify({ file: 'ui/Sample.tsx', text: 'looks off to me' }),
|
|
149
|
+
});
|
|
150
|
+
expect(comment.status).not.toBe(403);
|
|
151
|
+
|
|
152
|
+
const reply = await fetch(`${base}/_api/comments/abc123/reply`, {
|
|
153
|
+
method: 'POST',
|
|
154
|
+
headers: { 'content-type': 'application/json' },
|
|
155
|
+
body: JSON.stringify({ text: 'agreed' }),
|
|
156
|
+
});
|
|
157
|
+
expect(reply.status).not.toBe(403);
|
|
158
|
+
|
|
159
|
+
// An annotation is drawn ON the design and versioned with it — the matrix
|
|
160
|
+
// files it with `edit`, and that distinction has to survive this change.
|
|
161
|
+
const annotate = await fetch(`${base}/_api/annotations`, {
|
|
162
|
+
method: 'PUT',
|
|
163
|
+
headers: { 'content-type': 'application/json' },
|
|
164
|
+
body: JSON.stringify({ file: 'ui/Sample.tsx', svg: '<svg/>' }),
|
|
165
|
+
});
|
|
166
|
+
expect(annotate.status).toBe(403);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test('the reply pattern does not open the whole comment namespace', async () => {
|
|
170
|
+
const { base } = await boot('viewer');
|
|
171
|
+
for (const path of [
|
|
172
|
+
'/_api/comments/abc/delete',
|
|
173
|
+
'/_api/comments/abc/reply/../../edit-text',
|
|
174
|
+
'/_api/comments//reply',
|
|
175
|
+
]) {
|
|
176
|
+
const res = await fetch(`${base}${path}`, { method: 'POST' });
|
|
177
|
+
expect(res.status).not.toBe(200);
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
});
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// Regression test for issue-report-a-bug-http-500 (RCA:
|
|
2
|
+
// .ai/logs/rca/issue-report-a-bug-http-500.md). `/_api/report` proxies the
|
|
3
|
+
// dialog's multipart body to `MAUDE_REPORT_URL`. A bare `fetch(endpoint, …)`
|
|
4
|
+
// call inside http.ts silently resolved to this module's OWN same-named
|
|
5
|
+
// fall-through handler instead of the global `fetch` — the request never left
|
|
6
|
+
// the machine, and the route mirrored a bogus local 500 back to the client.
|
|
7
|
+
// These tests boot a real server pointed at a real stub upstream, so a
|
|
8
|
+
// reintroduced shadow (or any other break in the proxy hop) fails loudly
|
|
9
|
+
// instead of only being caught by a mocked-upstream unit test.
|
|
10
|
+
|
|
11
|
+
import { describe, expect, test } from 'bun:test';
|
|
12
|
+
import { readFileSync } from 'node:fs';
|
|
13
|
+
|
|
14
|
+
import { bootServer, killProc, makeSandbox, nextPort } from './_helpers.ts';
|
|
15
|
+
|
|
16
|
+
async function bootStubUpstream(
|
|
17
|
+
handler: (req: Request) => Response | Promise<Response>
|
|
18
|
+
): Promise<{ url: string; stop: () => void; received: () => number }> {
|
|
19
|
+
let received = 0;
|
|
20
|
+
const server = Bun.serve({
|
|
21
|
+
port: 0,
|
|
22
|
+
fetch: async (req) => {
|
|
23
|
+
received += 1;
|
|
24
|
+
return handler(req);
|
|
25
|
+
},
|
|
26
|
+
});
|
|
27
|
+
return {
|
|
28
|
+
url: `http://127.0.0.1:${server.port}`,
|
|
29
|
+
stop: () => server.stop(true),
|
|
30
|
+
received: () => received,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe('/_api/report proxy hop', () => {
|
|
35
|
+
test('forwards the multipart body verbatim and mirrors a successful upstream response', async () => {
|
|
36
|
+
const { root } = makeSandbox();
|
|
37
|
+
let capturedBytes = 0;
|
|
38
|
+
let capturedHasReportPart = false;
|
|
39
|
+
const upstream = await bootStubUpstream(async (req) => {
|
|
40
|
+
const form = await req.formData();
|
|
41
|
+
capturedHasReportPart = typeof form.get('report') === 'string';
|
|
42
|
+
// Multipart parsing consumed the body — approximate "did real bytes
|
|
43
|
+
// arrive" by re-serializing what FormData kept.
|
|
44
|
+
capturedBytes = [...form.values()].reduce(
|
|
45
|
+
(n, v) => n + (typeof v === 'string' ? v.length : (v as Blob).size),
|
|
46
|
+
0
|
|
47
|
+
);
|
|
48
|
+
return Response.json({ ok: true, issueNumber: 1, issueUrl: 'https://example.com/issues/1' });
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const port = nextPort();
|
|
52
|
+
const proc = await bootServer(root, port, { MAUDE_REPORT_URL: `${upstream.url}/report` });
|
|
53
|
+
try {
|
|
54
|
+
const form = new FormData();
|
|
55
|
+
form.set(
|
|
56
|
+
'report',
|
|
57
|
+
JSON.stringify({ schema: 'maude-report/v1', description: 'test', reportId: 'r-0123abcd' })
|
|
58
|
+
);
|
|
59
|
+
form.append('screenshot', new Blob([new Uint8Array([1, 2, 3, 4])]), 'shot.png');
|
|
60
|
+
|
|
61
|
+
const res = await fetch(`http://localhost:${port}/_api/report`, {
|
|
62
|
+
method: 'POST',
|
|
63
|
+
body: form,
|
|
64
|
+
});
|
|
65
|
+
expect(res.status).toBe(200);
|
|
66
|
+
const json = await res.json();
|
|
67
|
+
expect(json).toEqual({ ok: true, issueNumber: 1, issueUrl: 'https://example.com/issues/1' });
|
|
68
|
+
|
|
69
|
+
expect(upstream.received()).toBe(1);
|
|
70
|
+
expect(capturedHasReportPart).toBe(true);
|
|
71
|
+
expect(capturedBytes).toBeGreaterThan(0);
|
|
72
|
+
} finally {
|
|
73
|
+
await killProc(proc);
|
|
74
|
+
upstream.stop();
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('an unreachable upstream yields 502 with a JSON error, not a bare 500', async () => {
|
|
79
|
+
const { root } = makeSandbox();
|
|
80
|
+
const port = nextPort();
|
|
81
|
+
// Nothing listens on this port — the fetch itself must fail/timeout.
|
|
82
|
+
const proc = await bootServer(root, port, { MAUDE_REPORT_URL: 'http://127.0.0.1:1/report' });
|
|
83
|
+
try {
|
|
84
|
+
const form = new FormData();
|
|
85
|
+
form.set(
|
|
86
|
+
'report',
|
|
87
|
+
JSON.stringify({ schema: 'maude-report/v1', description: 'test', reportId: 'r-0123abcd' })
|
|
88
|
+
);
|
|
89
|
+
const res = await fetch(`http://localhost:${port}/_api/report`, {
|
|
90
|
+
method: 'POST',
|
|
91
|
+
body: form,
|
|
92
|
+
});
|
|
93
|
+
expect(res.status).toBe(502);
|
|
94
|
+
const json = await res.json();
|
|
95
|
+
expect(json).toEqual({ error: 'report service unreachable' });
|
|
96
|
+
} finally {
|
|
97
|
+
await killProc(proc);
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe('no bare `fetch(` call sites in http.ts', () => {
|
|
103
|
+
test('every fetch call is either the global fetch (qualified) or a local handler name', () => {
|
|
104
|
+
const src = readFileSync(new URL('../http.ts', import.meta.url), 'utf8');
|
|
105
|
+
// A bare `fetch(` immediately preceded by a word-boundary and NOT preceded
|
|
106
|
+
// by a `.` (member access) or `function ` (declaration) would silently
|
|
107
|
+
// resolve to this file's own same-named fall-through handler instead of
|
|
108
|
+
// the global — exactly the bug this test guards against. `handleFallthrough`
|
|
109
|
+
// is the current, unambiguous name; this assertion also fails if the
|
|
110
|
+
// handler is ever renamed back to something call-site-ambiguous.
|
|
111
|
+
const bareFetchCalls = [...src.matchAll(/(?<![.\w])fetch\(/g)];
|
|
112
|
+
const lines = src.split('\n');
|
|
113
|
+
// Skip: comment lines (mentioning `fetch(` in prose is fine) and the one
|
|
114
|
+
// legitimate bare occurrence — the type-signature parameter name
|
|
115
|
+
// `fetch(req: Request): Promise<Response>;` in the `Api`-shaped interface,
|
|
116
|
+
// which is a declaration, not a call.
|
|
117
|
+
const offendingLines = bareFetchCalls
|
|
118
|
+
.map((m) => src.slice(0, m.index).split('\n').length)
|
|
119
|
+
.map((line) => ({ line, text: lines[line - 1] ?? '' }))
|
|
120
|
+
.filter(({ text }) => !/^\s*(\/\/|\*)/.test(text))
|
|
121
|
+
.filter(({ text }) => !/^\s*fetch\(req: Request\): Promise<Response>;/.test(text));
|
|
122
|
+
|
|
123
|
+
expect(offendingLines).toEqual([]);
|
|
124
|
+
});
|
|
125
|
+
});
|