@1agh/maude 0.30.0 → 0.32.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/README.md +5 -5
- package/apps/studio/acp/bridge.ts +285 -0
- package/apps/studio/acp/env.ts +48 -0
- package/apps/studio/acp/index.ts +132 -0
- package/apps/studio/acp/probe.ts +112 -0
- package/apps/studio/acp/transcript.ts +149 -0
- package/apps/studio/annotations-layer.tsx +6 -1
- package/apps/studio/api.ts +225 -66
- package/apps/studio/bin/chat-open.sh +44 -0
- package/apps/studio/canvas-lib.tsx +112 -19
- package/apps/studio/canvas-list-watch.ts +177 -0
- package/apps/studio/canvas-shell.tsx +22 -2
- package/apps/studio/client/app.jsx +788 -26
- package/apps/studio/client/canvas-url.js +5 -0
- package/apps/studio/client/github.js +99 -0
- package/apps/studio/client/panels/ChatPanel.jsx +796 -0
- package/apps/studio/client/panels/CollabModelInfographic.jsx +71 -0
- package/apps/studio/client/panels/CreateProject.jsx +334 -0
- package/apps/studio/client/panels/DiffView.jsx +590 -0
- package/apps/studio/client/panels/GitPanel.jsx +767 -0
- package/apps/studio/client/panels/IdentityBar.jsx +294 -0
- package/apps/studio/client/panels/OnboardingWizard.jsx +598 -0
- package/apps/studio/client/panels/ReadinessList.jsx +189 -0
- package/apps/studio/client/panels/RepoBranchSwitcher.jsx +349 -0
- package/apps/studio/client/panels/acp-runtime.js +286 -0
- package/apps/studio/client/panels/chat-markdown.jsx +138 -0
- package/apps/studio/client/panels/git-grouping.js +86 -0
- package/apps/studio/client/styles/0-reset.css +4 -0
- package/apps/studio/client/styles/3-shell-maude.css +625 -3
- package/apps/studio/client/styles/5-maude-overrides.css +25 -0
- package/apps/studio/client/styles/6-acp-chat.css +821 -0
- package/apps/studio/client/styles/_index.css +2 -0
- package/apps/studio/client/tour/collab-tour.js +61 -0
- package/apps/studio/client/tour/overlay.jsx +11 -1
- package/apps/studio/client/whats-new.jsx +25 -10
- package/apps/studio/collab/registry.ts +13 -0
- package/apps/studio/collab/room.ts +36 -0
- package/apps/studio/cursors-overlay.tsx +17 -1
- package/apps/studio/dist/client.bundle.js +29097 -1534
- package/apps/studio/dist/comment-mount.js +4 -2
- package/apps/studio/dist/styles.css +5816 -1614
- package/apps/studio/git/endpoints.ts +338 -0
- package/apps/studio/git/service.ts +1334 -0
- package/apps/studio/git/watch.ts +97 -0
- package/apps/studio/github/endpoints.ts +358 -0
- package/apps/studio/github/service.ts +231 -0
- package/apps/studio/github/token.ts +53 -0
- package/apps/studio/hmr-broadcast.ts +9 -2
- package/apps/studio/http.ts +399 -1
- package/apps/studio/participants-chrome.tsx +69 -9
- package/apps/studio/paths.ts +12 -0
- package/apps/studio/readiness.ts +220 -0
- package/apps/studio/scaffold-design.ts +57 -0
- package/apps/studio/server.ts +65 -2
- package/apps/studio/sync/agent.ts +81 -1
- package/apps/studio/sync/codec.ts +24 -0
- package/apps/studio/sync/cold-start.ts +40 -0
- package/apps/studio/sync/hub-link.ts +137 -0
- package/apps/studio/test/acp-bridge.test.ts +127 -0
- package/apps/studio/test/acp-env.test.ts +65 -0
- package/apps/studio/test/acp-origin-gate.test.ts +95 -0
- package/apps/studio/test/acp-transcript.test.ts +112 -0
- package/apps/studio/test/canvas-create-api.test.ts +72 -0
- package/apps/studio/test/canvas-list-watch.test.ts +322 -0
- package/apps/studio/test/canvas-meta-api.test.ts +161 -27
- package/apps/studio/test/canvas-origin-gate.test.ts +35 -0
- package/apps/studio/test/chat-markdown.test.tsx +58 -0
- package/apps/studio/test/collab-session-survival.test.tsx +176 -0
- package/apps/studio/test/csrf-write-guard.test.ts +26 -0
- package/apps/studio/test/editing-presence.test.ts +103 -0
- package/apps/studio/test/fixtures/mock-acp-agent.mjs +45 -0
- package/apps/studio/test/git-api.test.ts +0 -0
- package/apps/studio/test/git-branches.test.ts +106 -0
- package/apps/studio/test/git-grouping.test.ts +106 -0
- package/apps/studio/test/git-watch.test.ts +97 -0
- package/apps/studio/test/github-api.test.ts +465 -0
- package/apps/studio/test/hub-link.test.ts +69 -0
- package/apps/studio/test/participants-chrome.test.ts +36 -1
- package/apps/studio/test/readiness.test.ts +127 -0
- package/apps/studio/test/sync-cold-seed-dedup.test.ts +187 -0
- package/apps/studio/test/sync-cold-start.test.ts +61 -1
- package/apps/studio/test/tour-overlay.test.tsx +18 -0
- package/apps/studio/tool-palette.tsx +18 -9
- package/apps/studio/use-chrome-visibility.tsx +66 -0
- package/apps/studio/use-collab.tsx +414 -187
- package/apps/studio/whats-new.json +82 -0
- package/apps/studio/ws.ts +44 -1
- package/cli/commands/design.mjs +1 -0
- package/cli/lib/gitignore-block.mjs +16 -3
- package/cli/lib/gitignore-block.test.mjs +13 -1
- package/package.json +11 -9
- package/plugins/design/dependencies.json +17 -0
- package/plugins/design/templates/_shell.html +30 -8
|
@@ -1,15 +1,21 @@
|
|
|
1
1
|
// Phase 4 T5 — `/_api/canvas-meta` GET/PATCH endpoint round-trip.
|
|
2
|
+
// DDR-115 — per-user camera split: `viewport` lives in a gitignored per-machine
|
|
3
|
+
// view file (`_canvas-state/<slug>.view.json`), NEVER inline in the versioned
|
|
4
|
+
// `.meta.json`. PATCH splits the lanes; GET merges them back.
|
|
2
5
|
//
|
|
3
6
|
// Verifies:
|
|
4
|
-
// - PATCH
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
// - PATCH rejects non-finite
|
|
8
|
-
// - GET
|
|
9
|
-
// -
|
|
7
|
+
// - PATCH `viewport` leaves `.meta.json` BYTE-UNCHANGED + writes the view file
|
|
8
|
+
// (the mouse-move churn killer) — and a viewport-only patch on a meta-less
|
|
9
|
+
// canvas still succeeds
|
|
10
|
+
// - PATCH clamps zoom to [0.1, 4.0] / rejects non-finite coords (view file)
|
|
11
|
+
// - GET merges the view file's viewport into the returned meta
|
|
12
|
+
// - GET strips a stale inline viewport from a legacy meta
|
|
13
|
+
// - PATCH `layout` writes meta AND bumps `last_modified` (a real shared change)
|
|
14
|
+
// - PATCH layout persists position-only entries (DDR-027)
|
|
15
|
+
// - Paths that escape repoRoot are 404
|
|
10
16
|
|
|
11
17
|
import { describe, expect, test } from 'bun:test';
|
|
12
|
-
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
18
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
13
19
|
import { join } from 'node:path';
|
|
14
20
|
|
|
15
21
|
import { bootServer, killProc, makeSandbox, nextPort } from './_helpers.ts';
|
|
@@ -29,8 +35,13 @@ function repoRel(designRoot: string, abs: string): string {
|
|
|
29
35
|
return abs.startsWith(`${repoRoot}/`) ? abs.slice(repoRoot.length + 1) : abs;
|
|
30
36
|
}
|
|
31
37
|
|
|
32
|
-
|
|
33
|
-
|
|
38
|
+
/** `_canvas-state/<slug>.view.json` for a `<designRoot>/ui/<Name>.tsx` canvas. */
|
|
39
|
+
function viewPath(designRoot: string, slug: string): string {
|
|
40
|
+
return join(designRoot, '_canvas-state', `${slug}.view.json`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe('/_api/canvas-meta — GET/PATCH (DDR-115 camera split)', () => {
|
|
44
|
+
test('PATCH viewport leaves meta byte-unchanged + writes the view file (churn killer)', async () => {
|
|
34
45
|
const { root, designRoot } = makeSandbox();
|
|
35
46
|
const port = nextPort();
|
|
36
47
|
const proc = await bootServer(root, port);
|
|
@@ -47,6 +58,7 @@ describe('/_api/canvas-meta — GET/PATCH', () => {
|
|
|
47
58
|
ai_context: { pinned_decisions: ['keep dc-* classes'] },
|
|
48
59
|
})
|
|
49
60
|
);
|
|
61
|
+
const before = readFileSync(metaAbs, 'utf8');
|
|
50
62
|
const file = repoRel(designRoot, tsxAbs);
|
|
51
63
|
|
|
52
64
|
const r = await fetch(`http://localhost:${port}/_api/canvas-meta`, {
|
|
@@ -56,16 +68,48 @@ describe('/_api/canvas-meta — GET/PATCH', () => {
|
|
|
56
68
|
});
|
|
57
69
|
expect(r.status).toBe(200);
|
|
58
70
|
const merged = (await r.json()) as MetaShape;
|
|
71
|
+
// The returned object is GET-shaped: shared keys + the camera merged in.
|
|
59
72
|
expect(merged.title).toBe('Phase 4');
|
|
60
73
|
expect(merged.sections).toBeDefined();
|
|
61
74
|
expect(merged.ai_context).toBeDefined();
|
|
62
75
|
expect(merged.viewport).toEqual({ x: 12, y: 34, zoom: 1.5 });
|
|
63
|
-
expect(typeof merged.last_modified).toBe('string');
|
|
64
76
|
|
|
65
|
-
//
|
|
66
|
-
|
|
67
|
-
expect(
|
|
68
|
-
|
|
77
|
+
// KEYSTONE — the versioned meta is byte-identical; no viewport, no
|
|
78
|
+
// last_modified bump on a viewport-only patch.
|
|
79
|
+
expect(readFileSync(metaAbs, 'utf8')).toBe(before);
|
|
80
|
+
|
|
81
|
+
// The camera landed in the gitignored per-machine view file.
|
|
82
|
+
const vAbs = viewPath(designRoot, 'ui-phase4');
|
|
83
|
+
expect(existsSync(vAbs)).toBe(true);
|
|
84
|
+
const view = JSON.parse(readFileSync(vAbs, 'utf8'));
|
|
85
|
+
expect(view.viewport).toEqual({ x: 12, y: 34, zoom: 1.5 });
|
|
86
|
+
} finally {
|
|
87
|
+
await killProc(proc);
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('PATCH viewport succeeds on a canvas with no meta yet (writes only the view file)', async () => {
|
|
92
|
+
const { root, designRoot } = makeSandbox();
|
|
93
|
+
const port = nextPort();
|
|
94
|
+
const proc = await bootServer(root, port);
|
|
95
|
+
try {
|
|
96
|
+
mkdirSync(join(designRoot, 'ui'), { recursive: true });
|
|
97
|
+
const tsxAbs = join(designRoot, 'ui', 'NoMeta.tsx');
|
|
98
|
+
writeFileSync(tsxAbs, 'export default function N(){return <main/>}\n');
|
|
99
|
+
const metaAbs = tsxAbs.replace(/\.tsx$/, '.meta.json');
|
|
100
|
+
const file = repoRel(designRoot, tsxAbs);
|
|
101
|
+
|
|
102
|
+
const r = await fetch(`http://localhost:${port}/_api/canvas-meta`, {
|
|
103
|
+
method: 'PATCH',
|
|
104
|
+
headers: { 'content-type': 'application/json' },
|
|
105
|
+
body: JSON.stringify({ file, patch: { viewport: { x: 1, y: 2, zoom: 2 } } }),
|
|
106
|
+
});
|
|
107
|
+
expect(r.status).toBe(200);
|
|
108
|
+
const merged = (await r.json()) as MetaShape;
|
|
109
|
+
expect(merged.viewport).toEqual({ x: 1, y: 2, zoom: 2 });
|
|
110
|
+
// No meta was conjured into existence.
|
|
111
|
+
expect(existsSync(metaAbs)).toBe(false);
|
|
112
|
+
expect(existsSync(viewPath(designRoot, 'ui-nometa'))).toBe(true);
|
|
69
113
|
} finally {
|
|
70
114
|
await killProc(proc);
|
|
71
115
|
}
|
|
@@ -102,7 +146,7 @@ describe('/_api/canvas-meta — GET/PATCH', () => {
|
|
|
102
146
|
}
|
|
103
147
|
});
|
|
104
148
|
|
|
105
|
-
test('PATCH ignores non-finite viewport (NaN/Infinity)', async () => {
|
|
149
|
+
test('PATCH ignores non-finite viewport (NaN/Infinity) — keeps the prior camera', async () => {
|
|
106
150
|
const { root, designRoot } = makeSandbox();
|
|
107
151
|
const port = nextPort();
|
|
108
152
|
const proc = await bootServer(root, port);
|
|
@@ -110,36 +154,44 @@ describe('/_api/canvas-meta — GET/PATCH', () => {
|
|
|
110
154
|
mkdirSync(join(designRoot, 'ui'), { recursive: true });
|
|
111
155
|
const tsxAbs = join(designRoot, 'ui', 'Bad.tsx');
|
|
112
156
|
writeFileSync(tsxAbs, 'export default function B(){return <main/>}\n');
|
|
113
|
-
writeFileSync(
|
|
114
|
-
tsxAbs.replace(/\.tsx$/, '.meta.json'),
|
|
115
|
-
'{"title":"Bad","viewport":{"x":1,"y":2,"zoom":1}}'
|
|
116
|
-
);
|
|
157
|
+
writeFileSync(tsxAbs.replace(/\.tsx$/, '.meta.json'), '{"title":"Bad"}');
|
|
117
158
|
const file = repoRel(designRoot, tsxAbs);
|
|
118
159
|
|
|
160
|
+
// Seed a valid camera (writes the view file).
|
|
161
|
+
await fetch(`http://localhost:${port}/_api/canvas-meta`, {
|
|
162
|
+
method: 'PATCH',
|
|
163
|
+
headers: { 'content-type': 'application/json' },
|
|
164
|
+
body: JSON.stringify({ file, patch: { viewport: { x: 1, y: 2, zoom: 1 } } }),
|
|
165
|
+
});
|
|
166
|
+
|
|
119
167
|
const r = await fetch(`http://localhost:${port}/_api/canvas-meta`, {
|
|
120
168
|
method: 'PATCH',
|
|
121
169
|
headers: { 'content-type': 'application/json' },
|
|
122
170
|
body: JSON.stringify({ file, patch: { viewport: { x: 'nope', y: null, zoom: 1 } } }),
|
|
123
171
|
});
|
|
124
172
|
const m = (await r.json()) as MetaShape;
|
|
125
|
-
// Prior
|
|
173
|
+
// Prior valid camera preserved (the bad patch was a no-op for the view file).
|
|
126
174
|
expect(m.viewport).toEqual({ x: 1, y: 2, zoom: 1 });
|
|
175
|
+
const view = JSON.parse(readFileSync(viewPath(designRoot, 'ui-bad'), 'utf8'));
|
|
176
|
+
expect(view.viewport).toEqual({ x: 1, y: 2, zoom: 1 });
|
|
127
177
|
} finally {
|
|
128
178
|
await killProc(proc);
|
|
129
179
|
}
|
|
130
180
|
});
|
|
131
181
|
|
|
132
|
-
test('GET
|
|
182
|
+
test('GET merges the view file viewport into the returned meta', async () => {
|
|
133
183
|
const { root, designRoot } = makeSandbox();
|
|
134
184
|
const port = nextPort();
|
|
135
185
|
const proc = await bootServer(root, port);
|
|
136
186
|
try {
|
|
137
187
|
mkdirSync(join(designRoot, 'ui'), { recursive: true });
|
|
138
|
-
const tsxAbs = join(designRoot, 'ui', '
|
|
139
|
-
writeFileSync(tsxAbs, 'export default function
|
|
188
|
+
const tsxAbs = join(designRoot, 'ui', 'Merge.tsx');
|
|
189
|
+
writeFileSync(tsxAbs, 'export default function M(){return <main/>}\n');
|
|
190
|
+
writeFileSync(tsxAbs.replace(/\.tsx$/, '.meta.json'), JSON.stringify({ title: 'Merge' }));
|
|
191
|
+
mkdirSync(join(designRoot, '_canvas-state'), { recursive: true });
|
|
140
192
|
writeFileSync(
|
|
141
|
-
|
|
142
|
-
JSON.stringify({
|
|
193
|
+
viewPath(designRoot, 'ui-merge'),
|
|
194
|
+
JSON.stringify({ viewport: { x: 7, y: 8, zoom: 0.5 } })
|
|
143
195
|
);
|
|
144
196
|
const file = repoRel(designRoot, tsxAbs);
|
|
145
197
|
|
|
@@ -148,8 +200,69 @@ describe('/_api/canvas-meta — GET/PATCH', () => {
|
|
|
148
200
|
);
|
|
149
201
|
expect(r.status).toBe(200);
|
|
150
202
|
const m = (await r.json()) as MetaShape;
|
|
151
|
-
expect(m.title).toBe('
|
|
152
|
-
expect(m.viewport
|
|
203
|
+
expect(m.title).toBe('Merge');
|
|
204
|
+
expect(m.viewport).toEqual({ x: 7, y: 8, zoom: 0.5 });
|
|
205
|
+
} finally {
|
|
206
|
+
await killProc(proc);
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test('GET strips a stale inline viewport from a legacy meta (no view file)', async () => {
|
|
211
|
+
const { root, designRoot } = makeSandbox();
|
|
212
|
+
const port = nextPort();
|
|
213
|
+
const proc = await bootServer(root, port);
|
|
214
|
+
try {
|
|
215
|
+
mkdirSync(join(designRoot, 'ui'), { recursive: true });
|
|
216
|
+
const tsxAbs = join(designRoot, 'ui', 'Legacy.tsx');
|
|
217
|
+
writeFileSync(tsxAbs, 'export default function L(){return <main/>}\n');
|
|
218
|
+
// A pre-DDR-115 meta with the camera baked inline + a stale last_modified.
|
|
219
|
+
writeFileSync(
|
|
220
|
+
tsxAbs.replace(/\.tsx$/, '.meta.json'),
|
|
221
|
+
JSON.stringify({
|
|
222
|
+
title: 'Legacy',
|
|
223
|
+
viewport: { x: 5, y: 6, zoom: 0.5 },
|
|
224
|
+
last_modified: '2020-01-01T00:00:00.000Z',
|
|
225
|
+
})
|
|
226
|
+
);
|
|
227
|
+
const file = repoRel(designRoot, tsxAbs);
|
|
228
|
+
|
|
229
|
+
const r = await fetch(
|
|
230
|
+
`http://localhost:${port}/_api/canvas-meta?file=${encodeURIComponent(file)}`
|
|
231
|
+
);
|
|
232
|
+
const m = (await r.json()) as MetaShape;
|
|
233
|
+
expect(m.title).toBe('Legacy');
|
|
234
|
+
// The stale inline camera + timestamp are NOT surfaced (no view file → none).
|
|
235
|
+
expect(m.viewport).toBeUndefined();
|
|
236
|
+
expect(m.last_modified).toBeUndefined();
|
|
237
|
+
} finally {
|
|
238
|
+
await killProc(proc);
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test('PATCH layout writes meta AND bumps last_modified (a real shared change)', async () => {
|
|
243
|
+
const { root, designRoot } = makeSandbox();
|
|
244
|
+
const port = nextPort();
|
|
245
|
+
const proc = await bootServer(root, port);
|
|
246
|
+
try {
|
|
247
|
+
mkdirSync(join(designRoot, 'ui'), { recursive: true });
|
|
248
|
+
const tsxAbs = join(designRoot, 'ui', 'Layout.tsx');
|
|
249
|
+
writeFileSync(tsxAbs, 'export default function L(){return <main/>}\n');
|
|
250
|
+
const metaAbs = tsxAbs.replace(/\.tsx$/, '.meta.json');
|
|
251
|
+
writeFileSync(metaAbs, JSON.stringify({ title: 'L', sections: [] }));
|
|
252
|
+
const file = repoRel(designRoot, tsxAbs);
|
|
253
|
+
|
|
254
|
+
const r = await fetch(`http://localhost:${port}/_api/canvas-meta`, {
|
|
255
|
+
method: 'PATCH',
|
|
256
|
+
headers: { 'content-type': 'application/json' },
|
|
257
|
+
body: JSON.stringify({ file, patch: { layout: { artboards: [{ id: 'a', x: 0, y: 0 }] } } }),
|
|
258
|
+
});
|
|
259
|
+
expect(r.status).toBe(200);
|
|
260
|
+
const onDisk = JSON.parse(readFileSync(metaAbs, 'utf8')) as MetaShape;
|
|
261
|
+
expect(onDisk.layout?.artboards).toBeDefined();
|
|
262
|
+
expect(typeof onDisk.last_modified).toBe('string');
|
|
263
|
+
expect(onDisk.title).toBe('L');
|
|
264
|
+
// The camera never leaks into the versioned meta.
|
|
265
|
+
expect(onDisk.viewport).toBeUndefined();
|
|
153
266
|
} finally {
|
|
154
267
|
await killProc(proc);
|
|
155
268
|
}
|
|
@@ -204,6 +317,27 @@ describe('/_api/canvas-meta — GET/PATCH', () => {
|
|
|
204
317
|
}
|
|
205
318
|
});
|
|
206
319
|
|
|
320
|
+
test('PATCH viewport on a non-existent canvas is refused (404) and mints no file (DDR-115 F-A2)', async () => {
|
|
321
|
+
const { root, designRoot } = makeSandbox();
|
|
322
|
+
const port = nextPort();
|
|
323
|
+
const proc = await bootServer(root, port);
|
|
324
|
+
try {
|
|
325
|
+
mkdirSync(join(designRoot, 'ui'), { recursive: true });
|
|
326
|
+
// No .tsx written — the canvas does not exist.
|
|
327
|
+
const file = '.design/ui/Ghost.tsx';
|
|
328
|
+
const r = await fetch(`http://localhost:${port}/_api/canvas-meta`, {
|
|
329
|
+
method: 'PATCH',
|
|
330
|
+
headers: { 'content-type': 'application/json' },
|
|
331
|
+
body: JSON.stringify({ file, patch: { viewport: { x: 1, y: 2, zoom: 1 } } }),
|
|
332
|
+
});
|
|
333
|
+
expect(r.status).toBe(404);
|
|
334
|
+
// The untrusted-origin write was refused — no per-machine file minted.
|
|
335
|
+
expect(existsSync(viewPath(designRoot, 'ui-ghost'))).toBe(false);
|
|
336
|
+
} finally {
|
|
337
|
+
await killProc(proc);
|
|
338
|
+
}
|
|
339
|
+
});
|
|
340
|
+
|
|
207
341
|
test('PATCH rejects paths that escape repoRoot', async () => {
|
|
208
342
|
const { root } = makeSandbox();
|
|
209
343
|
const port = nextPort();
|
|
@@ -67,6 +67,41 @@ describe('canvas-origin gate — A1/A2 traversal + privilege containment', () =>
|
|
|
67
67
|
'/_api/edit-css',
|
|
68
68
|
'/_api/edit-text',
|
|
69
69
|
'/_api/edit-attr',
|
|
70
|
+
// Phase 27 (E2) — every /_api/git/* route is MAIN-ORIGIN ONLY: absent from
|
|
71
|
+
// CANVAS_SAFE_API + startCanvasServer's `routes` map. A GET from the
|
|
72
|
+
// canvas origin must 403 at the gate (not 405 from a reached handler),
|
|
73
|
+
// proving the route is unreachable on this origin. Guards the dual-
|
|
74
|
+
// allowlist invariant for the token-bearing publish/get-latest endpoints.
|
|
75
|
+
'/_api/git/status',
|
|
76
|
+
'/_api/git/log',
|
|
77
|
+
'/_api/git/diff',
|
|
78
|
+
'/_api/git/commit',
|
|
79
|
+
'/_api/git/discard',
|
|
80
|
+
'/_api/git/push',
|
|
81
|
+
'/_api/git/pull',
|
|
82
|
+
'/_api/git/resolve',
|
|
83
|
+
// Phase 29 (E4) — drafts: list + create + switch + fold are MAIN-ORIGIN ONLY.
|
|
84
|
+
'/_api/git/branches',
|
|
85
|
+
'/_api/git/branch',
|
|
86
|
+
'/_api/git/checkout',
|
|
87
|
+
'/_api/git/fold',
|
|
88
|
+
// Phase 28 (E3) — every /_api/github/* route is MAIN-ORIGIN ONLY (absent
|
|
89
|
+
// from CANVAS_SAFE_API + startCanvasServer's `routes` map) and token-bearing.
|
|
90
|
+
// The untrusted canvas iframe origin must never reach identity/create-repo/
|
|
91
|
+
// invite/repos — a request from this origin 403s at the gate. This is also
|
|
92
|
+
// the guard that the GitHub token (server-held, keychain) can never be
|
|
93
|
+
// exfiltrated to the canvas realm.
|
|
94
|
+
'/_api/github/identity',
|
|
95
|
+
'/_api/github/repos',
|
|
96
|
+
'/_api/github/create-repo',
|
|
97
|
+
'/_api/github/invite',
|
|
98
|
+
'/_api/github/clone',
|
|
99
|
+
'/_api/github/create-project',
|
|
100
|
+
'/_api/design/init',
|
|
101
|
+
// Phase 29 (E4) Door C — the hub-link credential write is MAIN-ORIGIN ONLY;
|
|
102
|
+
// the untrusted canvas origin must never reach it (it writes the global
|
|
103
|
+
// ~/.config/maude/hubs.json token store).
|
|
104
|
+
'/_api/hub/link',
|
|
70
105
|
'/package.json',
|
|
71
106
|
]) {
|
|
72
107
|
expect(await code(p)).toBe(403);
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// chat-markdown — Phase 31. The ACP chat feed's compact, XSS-safe markdown
|
|
2
|
+
// renderer. Builds React nodes (never innerHTML), so we assert the rendered
|
|
3
|
+
// markup + that unsafe link schemes are dropped.
|
|
4
|
+
|
|
5
|
+
import { describe, expect, test } from 'bun:test';
|
|
6
|
+
import { renderToStaticMarkup } from 'react-dom/server';
|
|
7
|
+
|
|
8
|
+
import { Markdown } from '../client/panels/chat-markdown.jsx';
|
|
9
|
+
|
|
10
|
+
const html = (text: string) => renderToStaticMarkup(<Markdown text={text} />);
|
|
11
|
+
|
|
12
|
+
describe('chat-markdown', () => {
|
|
13
|
+
test('inline bold / italic / code', () => {
|
|
14
|
+
const out = html('Use **bold**, *italic* and `code` here.');
|
|
15
|
+
expect(out).toContain('<strong>bold</strong>');
|
|
16
|
+
expect(out).toContain('<em>italic</em>');
|
|
17
|
+
expect(out).toContain('<code>code</code>');
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test('fenced code block', () => {
|
|
21
|
+
const out = html('text\n```\nconst x = 1;\n```\nmore');
|
|
22
|
+
expect(out).toContain('class="chat-code"');
|
|
23
|
+
expect(out).toContain('const x = 1;');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('bullet + numbered lists', () => {
|
|
27
|
+
const bullet = html('- one\n- two');
|
|
28
|
+
expect(bullet).toContain('<ul class="chat-md-list">');
|
|
29
|
+
expect(bullet).toContain('<li>one</li>');
|
|
30
|
+
const ordered = html('1. first\n2. second');
|
|
31
|
+
expect(ordered).toContain('<ol class="chat-md-list">');
|
|
32
|
+
expect(ordered).toContain('<li>first</li>');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('headings render as styled paragraphs (no giant h1)', () => {
|
|
36
|
+
const out = html('## Section');
|
|
37
|
+
expect(out).toContain('class="chat-md-h"');
|
|
38
|
+
expect(out).toContain('Section');
|
|
39
|
+
expect(out).not.toContain('<h1');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('http(s) links are kept, unsafe schemes are dropped to text', () => {
|
|
43
|
+
const safe = html('see [Anthropic](https://anthropic.com)');
|
|
44
|
+
expect(safe).toContain('href="https://anthropic.com"');
|
|
45
|
+
expect(safe).toContain('rel="noreferrer noopener"');
|
|
46
|
+
|
|
47
|
+
const unsafe = html('[click](javascript:alert(1))');
|
|
48
|
+
expect(unsafe).not.toContain('javascript:');
|
|
49
|
+
expect(unsafe).not.toContain('<a ');
|
|
50
|
+
expect(unsafe).toContain('click'); // falls back to plain text
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('plain text passes through as a paragraph', () => {
|
|
54
|
+
const out = html('just a line');
|
|
55
|
+
expect(out).toContain('class="chat-md-p"');
|
|
56
|
+
expect(out).toContain('just a line');
|
|
57
|
+
});
|
|
58
|
+
});
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// Collab session survives a canvas hot-swap remount — F4 regression.
|
|
2
|
+
//
|
|
3
|
+
// RCA: `.ai/logs/rca/issue-collab-observer-tsx-reload-flicker.md`. A cross-peer
|
|
4
|
+
// synced edit hot-swaps the canvas module in place, which REMOUNTS the whole
|
|
5
|
+
// canvas subtree — including <CollabProvider>. If the Y.Doc + Awareness + socket
|
|
6
|
+
// were per-component, that remount would close + re-handshake the awareness
|
|
7
|
+
// socket and every peer's cursor/avatar would blink. The fix makes the session
|
|
8
|
+
// a module-level, refcounted singleton keyed by slug, so a same-slug remount
|
|
9
|
+
// reuses the SAME live socket + Awareness (stable clientID → the server never
|
|
10
|
+
// sees the peer leave + rejoin → no blink).
|
|
11
|
+
//
|
|
12
|
+
// We register happy-dom (error boundaries / real createRoot need a live DOM,
|
|
13
|
+
// like canvas-hmr-runtime.test.tsx) and stub WebSocket + fetch.
|
|
14
|
+
|
|
15
|
+
import { afterAll, afterEach, beforeAll, describe, expect, test } from 'bun:test';
|
|
16
|
+
import { GlobalRegistrator } from '@happy-dom/global-registrator';
|
|
17
|
+
|
|
18
|
+
beforeAll(() => {
|
|
19
|
+
GlobalRegistrator.register();
|
|
20
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
21
|
+
});
|
|
22
|
+
afterAll(() => {
|
|
23
|
+
GlobalRegistrator.unregister();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
import { act, createElement, useEffect } from 'react';
|
|
27
|
+
import { createRoot, type Root } from 'react-dom/client';
|
|
28
|
+
import type * as Y from 'yjs';
|
|
29
|
+
|
|
30
|
+
import { CollabProvider, useCollab } from '../use-collab.tsx';
|
|
31
|
+
|
|
32
|
+
// ── Fake WebSocket: count constructions + closes; never touches the network ──
|
|
33
|
+
let wsConstructed = 0;
|
|
34
|
+
let wsClosed = 0;
|
|
35
|
+
class FakeWS {
|
|
36
|
+
static OPEN = 1;
|
|
37
|
+
static CLOSING = 2;
|
|
38
|
+
static CLOSED = 3;
|
|
39
|
+
readyState = 1; // OPEN immediately — the provider only broadcasts when OPEN
|
|
40
|
+
url: string;
|
|
41
|
+
private handlers: Record<string, Array<(e: unknown) => void>> = {};
|
|
42
|
+
constructor(url: string) {
|
|
43
|
+
this.url = url;
|
|
44
|
+
wsConstructed++;
|
|
45
|
+
// Fire `open` on the next microtask so the provider's open handler runs.
|
|
46
|
+
queueMicrotask(() => this.dispatch('open', {}));
|
|
47
|
+
}
|
|
48
|
+
addEventListener(type: string, fn: (e: unknown) => void) {
|
|
49
|
+
const bucket = this.handlers[type] ?? [];
|
|
50
|
+
bucket.push(fn);
|
|
51
|
+
this.handlers[type] = bucket;
|
|
52
|
+
}
|
|
53
|
+
removeEventListener() {}
|
|
54
|
+
send() {}
|
|
55
|
+
close() {
|
|
56
|
+
if (this.readyState === FakeWS.CLOSED) return;
|
|
57
|
+
this.readyState = FakeWS.CLOSED;
|
|
58
|
+
wsClosed++;
|
|
59
|
+
this.dispatch('close', {});
|
|
60
|
+
}
|
|
61
|
+
private dispatch(type: string, e: unknown) {
|
|
62
|
+
for (const fn of this.handlers[type] ?? []) fn(e);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const realWebSocket = globalThis.WebSocket;
|
|
67
|
+
const realFetch = globalThis.fetch;
|
|
68
|
+
|
|
69
|
+
beforeAll(() => {
|
|
70
|
+
(globalThis as unknown as { WebSocket: unknown }).WebSocket = FakeWS;
|
|
71
|
+
(globalThis as unknown as { fetch: unknown }).fetch = () =>
|
|
72
|
+
Promise.resolve({ json: () => Promise.resolve({ name: 'Tester' }) });
|
|
73
|
+
});
|
|
74
|
+
afterAll(() => {
|
|
75
|
+
(globalThis as unknown as { WebSocket: unknown }).WebSocket = realWebSocket;
|
|
76
|
+
(globalThis as unknown as { fetch: unknown }).fetch = realFetch;
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
afterEach(() => {
|
|
80
|
+
wsConstructed = 0;
|
|
81
|
+
wsClosed = 0;
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// Captures the live doc + awareness clientID for the mounted provider.
|
|
85
|
+
let captured: { doc: Y.Doc; clientID: number } | null = null;
|
|
86
|
+
function Probe() {
|
|
87
|
+
const collab = useCollab();
|
|
88
|
+
useEffect(() => {
|
|
89
|
+
if (collab) captured = { doc: collab.doc, clientID: collab.awareness.clientID };
|
|
90
|
+
});
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function mount(el: HTMLElement): Root {
|
|
95
|
+
let root!: Root;
|
|
96
|
+
act(() => {
|
|
97
|
+
root = createRoot(el);
|
|
98
|
+
});
|
|
99
|
+
return root;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function renderProvider(root: Root, slug: string) {
|
|
103
|
+
act(() => {
|
|
104
|
+
root.render(createElement(CollabProvider, { slug }, createElement(Probe)));
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
describe('collab session survival across a hot-swap remount', () => {
|
|
109
|
+
test('a same-slug remount reuses the live socket + Awareness (no reconnect)', () => {
|
|
110
|
+
captured = null;
|
|
111
|
+
const host = document.createElement('div');
|
|
112
|
+
const root = mount(host);
|
|
113
|
+
|
|
114
|
+
renderProvider(root, 'survival-slug');
|
|
115
|
+
expect(wsConstructed).toBe(1);
|
|
116
|
+
const first = captured;
|
|
117
|
+
expect(first).not.toBeNull();
|
|
118
|
+
|
|
119
|
+
// Hot-swap = unmount the canvas subtree, then immediately remount the SAME
|
|
120
|
+
// slug (what CanvasHmrRuntime's key=attempt does on a synced module swap).
|
|
121
|
+
act(() => {
|
|
122
|
+
root.render(createElement('div', null));
|
|
123
|
+
});
|
|
124
|
+
renderProvider(root, 'survival-slug');
|
|
125
|
+
|
|
126
|
+
// The session was reused within the grace window — NO second socket, and the
|
|
127
|
+
// SAME doc + Awareness clientID (the server never saw a leave/rejoin).
|
|
128
|
+
expect(wsConstructed).toBe(1);
|
|
129
|
+
expect(wsClosed).toBe(0);
|
|
130
|
+
expect(captured?.doc).toBe(first?.doc);
|
|
131
|
+
expect(captured?.clientID).toBe(first?.clientID);
|
|
132
|
+
|
|
133
|
+
act(() => root.unmount());
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test('the session registry is anchored on window under a non-enumerable Symbol', () => {
|
|
137
|
+
// A hot-swap re-imports the canvas bundle with a `?v=` cache-buster, and
|
|
138
|
+
// use-collab is INLINED into that bundle — so a module-scoped `const
|
|
139
|
+
// SESSIONS = new Map()` would be FRESH (empty) on every hot-swap and spin a
|
|
140
|
+
// new clientID each time (phantom self-avatars pile up). Anchoring the map
|
|
141
|
+
// on `window` (under a global Symbol so the re-imported module resolves the
|
|
142
|
+
// same key) is what makes the session survive the swap. This pins that
|
|
143
|
+
// invariant — the registry holds the slug — AND the security posture: the
|
|
144
|
+
// key is a NON-ENUMERABLE Symbol so untrusted same-realm canvas script can't
|
|
145
|
+
// harvest the live network handles via a for-in / Object.keys sweep.
|
|
146
|
+
captured = null;
|
|
147
|
+
const host = document.createElement('div');
|
|
148
|
+
const root = mount(host);
|
|
149
|
+
renderProvider(root, 'window-anchored-slug');
|
|
150
|
+
const w = window as unknown as Record<symbol, Map<string, unknown> | undefined>;
|
|
151
|
+
const reg = w[Symbol.for('maude.collab.sessions.v1')];
|
|
152
|
+
expect(reg).toBeInstanceOf(Map);
|
|
153
|
+
expect(reg?.has('window-anchored-slug')).toBe(true);
|
|
154
|
+
// Not reachable via string-key enumeration (defense in depth).
|
|
155
|
+
expect(Object.keys(window)).not.toContain('__maudeCollabSessions');
|
|
156
|
+
act(() => root.unmount());
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test('a different slug opens its own session (no cross-slug sharing)', () => {
|
|
160
|
+
captured = null;
|
|
161
|
+
const host = document.createElement('div');
|
|
162
|
+
const root = mount(host);
|
|
163
|
+
|
|
164
|
+
renderProvider(root, 'slug-a');
|
|
165
|
+
const a = captured;
|
|
166
|
+
renderProvider(root, 'slug-b');
|
|
167
|
+
const b = captured;
|
|
168
|
+
|
|
169
|
+
// slug-b reuses slug-a's <CollabProvider> instance but a NEW session →
|
|
170
|
+
// a second socket + a distinct doc.
|
|
171
|
+
expect(wsConstructed).toBe(2);
|
|
172
|
+
expect(b?.doc).not.toBe(a?.doc);
|
|
173
|
+
|
|
174
|
+
act(() => root.unmount());
|
|
175
|
+
});
|
|
176
|
+
});
|
|
@@ -15,6 +15,8 @@
|
|
|
15
15
|
// race unrelated to this guard. See the Phase 12.2 validate notes.)
|
|
16
16
|
|
|
17
17
|
import { describe, expect, test } from 'bun:test';
|
|
18
|
+
import { readFileSync } from 'node:fs';
|
|
19
|
+
import { fileURLToPath } from 'node:url';
|
|
18
20
|
|
|
19
21
|
import { sameOriginWrite } from '../http.ts';
|
|
20
22
|
|
|
@@ -50,3 +52,27 @@ describe('CSRF Origin guard — sameOriginWrite (DDR-105)', () => {
|
|
|
50
52
|
expect(sameOriginWrite(post('not a url'))).toBe(false);
|
|
51
53
|
});
|
|
52
54
|
});
|
|
55
|
+
|
|
56
|
+
// phase-30 / DDR-120: the ai-activity bridge now projects `/_api/ai/*` POSTs
|
|
57
|
+
// onto room awareness, which crosses the hub to every connected peer. So a
|
|
58
|
+
// forged cross-origin POST to /start /heartbeat /end is no longer a harmless
|
|
59
|
+
// loopback banner — it injects a fake "<x> is editing <slug>" presence to all
|
|
60
|
+
// peers (the channel that drives the social save/publish decision). These three
|
|
61
|
+
// routes MUST carry the same `sameOriginWrite` guard as the other write routes.
|
|
62
|
+
// Source-level assertion (same "don't boot a server" rationale as above): pin
|
|
63
|
+
// the guard into each route block so an accidental removal fails CI.
|
|
64
|
+
describe('CSRF Origin guard — /_api/ai/* presence-bridge routes (phase-30)', () => {
|
|
65
|
+
const src = readFileSync(fileURLToPath(new URL('../http.ts', import.meta.url)), 'utf8');
|
|
66
|
+
|
|
67
|
+
for (const route of ['/_api/ai/start', '/_api/ai/heartbeat', '/_api/ai/end']) {
|
|
68
|
+
test(`${route} is wired with the sameOriginWrite CSRF guard`, () => {
|
|
69
|
+
// Slice from this route's key to the next route key, then assert the guard
|
|
70
|
+
// appears inside that block.
|
|
71
|
+
const start = src.indexOf(`'${route}':`);
|
|
72
|
+
expect(start).toBeGreaterThan(-1);
|
|
73
|
+
const after = src.indexOf("'/_api/", start + 1);
|
|
74
|
+
const block = src.slice(start, after === -1 ? undefined : after);
|
|
75
|
+
expect(block).toContain('sameOriginWrite(req)');
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
});
|