@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.
Files changed (30) hide show
  1. package/apps/studio/bin/_import-asset.mjs +1 -6
  2. package/apps/studio/bin/_import-brand.mjs +1 -1
  3. package/apps/studio/bin/_video-playwright.mjs +5 -1
  4. package/apps/studio/bin/screenshot.sh +131 -15
  5. package/apps/studio/canvas-build-sandbox.ts +385 -0
  6. package/apps/studio/canvas-build-worker.ts +85 -0
  7. package/apps/studio/client/app.jsx +112 -8
  8. package/apps/studio/client/canvas-url.js +7 -0
  9. package/apps/studio/client/panels/SettingsPanel.jsx +32 -12
  10. package/apps/studio/client/report-bug.jsx +125 -30
  11. package/apps/studio/client/styles/4-components-maude.css +5 -0
  12. package/apps/studio/client/styles/4-components.css +35 -0
  13. package/apps/studio/dist/client.bundle.js +831 -831
  14. package/apps/studio/dist/styles.css +1 -1
  15. package/apps/studio/http.ts +252 -23
  16. package/apps/studio/paths.ts +8 -0
  17. package/apps/studio/server.ts +102 -36
  18. package/apps/studio/test/canvas-build-sandbox.test.ts +69 -0
  19. package/apps/studio/test/canvas-origin-gate.test.ts +5 -0
  20. package/apps/studio/test/canvas-url.test.ts +26 -0
  21. package/apps/studio/test/cloud-session-role.test.ts +131 -0
  22. package/apps/studio/test/cloud-shell-surfaces.test.ts +90 -0
  23. package/apps/studio/test/config-projection.test.ts +104 -0
  24. package/apps/studio/test/read-only-gate.test.ts +50 -0
  25. package/apps/studio/test/report-proxy.test.ts +125 -0
  26. package/apps/studio/test/workspace-containment.test.ts +90 -11
  27. package/apps/studio/whats-new.json +11 -2
  28. package/apps/studio/workspace-mode.ts +161 -28
  29. package/apps/studio/ws.ts +30 -0
  30. 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
+ });