@1agh/maude 0.23.0 → 0.24.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.
Files changed (101) hide show
  1. package/README.md +34 -1
  2. package/cli/bin/maude.mjs +3 -0
  3. package/cli/commands/cache.mjs +181 -0
  4. package/cli/commands/cache.test.mjs +166 -0
  5. package/cli/commands/design-link.test.mjs +32 -2
  6. package/cli/commands/design.mjs +95 -4
  7. package/cli/commands/design.test.mjs +56 -0
  8. package/cli/commands/help.mjs +34 -0
  9. package/cli/commands/hub.mjs +255 -30
  10. package/cli/commands/hub.test.mjs +126 -2
  11. package/cli/commands/init.mjs +3 -0
  12. package/cli/commands/preflight.mjs +11 -0
  13. package/cli/commands/preflight.test.mjs +46 -0
  14. package/cli/commands/scenario-report.mjs +45 -0
  15. package/cli/lib/cache.mjs +407 -0
  16. package/cli/lib/cache.test.mjs +303 -0
  17. package/cli/lib/design-link.mjs +74 -10
  18. package/cli/lib/flow-design-integration.test.mjs +165 -0
  19. package/cli/lib/gitignore-block.mjs +90 -0
  20. package/cli/lib/gitignore-block.test.mjs +123 -0
  21. package/cli/lib/plugin-cli-reachability.test.mjs +56 -0
  22. package/cli/lib/preflight.mjs +41 -10
  23. package/package.json +8 -8
  24. package/plugins/design/dev-server/ai-banner.tsx +2 -2
  25. package/plugins/design/dev-server/annotations-context-toolbar.tsx +349 -112
  26. package/plugins/design/dev-server/annotations-layer.tsx +906 -137
  27. package/plugins/design/dev-server/api.ts +109 -4
  28. package/plugins/design/dev-server/bin/preflight.sh +21 -10
  29. package/plugins/design/dev-server/bin/prep.sh +211 -0
  30. package/plugins/design/dev-server/bin/scenario-report.mjs +209 -0
  31. package/plugins/design/dev-server/bin/screenshot.sh +18 -1
  32. package/plugins/design/dev-server/bin/smoke.sh +94 -11
  33. package/plugins/design/dev-server/canvas-cursors.ts +125 -0
  34. package/plugins/design/dev-server/canvas-icons.tsx +130 -0
  35. package/plugins/design/dev-server/canvas-lib.tsx +25 -21
  36. package/plugins/design/dev-server/canvas-meta.schema.json +20 -0
  37. package/plugins/design/dev-server/canvas-shell.tsx +333 -19
  38. package/plugins/design/dev-server/client/app.jsx +155 -7
  39. package/plugins/design/dev-server/client/styles/3-shell.css +10 -0
  40. package/plugins/design/dev-server/collab/index.ts +87 -9
  41. package/plugins/design/dev-server/collab/persistence.ts +34 -3
  42. package/plugins/design/dev-server/collab/registry.ts +80 -2
  43. package/plugins/design/dev-server/collab/room.ts +21 -8
  44. package/plugins/design/dev-server/context-menu.tsx +167 -23
  45. package/plugins/design/dev-server/context.ts +24 -0
  46. package/plugins/design/dev-server/contextual-toolbar.tsx +7 -7
  47. package/plugins/design/dev-server/dist/client.bundle.js +126 -13
  48. package/plugins/design/dev-server/dist/comment-mount.js +78 -11
  49. package/plugins/design/dev-server/dist/styles.css +16 -0
  50. package/plugins/design/dev-server/equal-spacing-handles.tsx +1 -1
  51. package/plugins/design/dev-server/export-dialog.tsx +19 -17
  52. package/plugins/design/dev-server/fs-watch.ts +1 -0
  53. package/plugins/design/dev-server/http.ts +260 -20
  54. package/plugins/design/dev-server/input-router.tsx +26 -3
  55. package/plugins/design/dev-server/participants-chrome.tsx +10 -10
  56. package/plugins/design/dev-server/server.ts +123 -1
  57. package/plugins/design/dev-server/sync/agent.ts +95 -0
  58. package/plugins/design/dev-server/sync/codec.ts +155 -0
  59. package/plugins/design/dev-server/sync/connection-state.ts +203 -0
  60. package/plugins/design/dev-server/sync/index.ts +479 -35
  61. package/plugins/design/dev-server/sync/materialize.ts +62 -0
  62. package/plugins/design/dev-server/sync/migrate-seed.ts +163 -0
  63. package/plugins/design/dev-server/sync/origins.ts +57 -0
  64. package/plugins/design/dev-server/sync/projection.ts +368 -0
  65. package/plugins/design/dev-server/sync/status.ts +115 -0
  66. package/plugins/design/dev-server/sync/untrusted.ts +153 -0
  67. package/plugins/design/dev-server/test/_helpers.ts +6 -2
  68. package/plugins/design/dev-server/test/annotations-layer.test.ts +231 -0
  69. package/plugins/design/dev-server/test/annotations-roundtrip.test.ts +276 -0
  70. package/plugins/design/dev-server/test/canvas-cursors.test.ts +73 -0
  71. package/plugins/design/dev-server/test/canvas-origin-gate.test.ts +76 -0
  72. package/plugins/design/dev-server/test/collab-reseed-guard.test.ts +78 -0
  73. package/plugins/design/dev-server/test/collab-room.test.ts +21 -10
  74. package/plugins/design/dev-server/test/csp-canvas-shell.test.ts +46 -0
  75. package/plugins/design/dev-server/test/fixtures/phase-20-annotations.svg +1 -0
  76. package/plugins/design/dev-server/test/input-router.test.ts +21 -0
  77. package/plugins/design/dev-server/test/sanitize-annotation-svg.test.ts +100 -0
  78. package/plugins/design/dev-server/test/shared-doc-convergence.test.ts +265 -0
  79. package/plugins/design/dev-server/test/shared-doc-foundation.test.ts +63 -0
  80. package/plugins/design/dev-server/test/shared-doc-migrate.test.ts +160 -0
  81. package/plugins/design/dev-server/test/shared-doc-projection.test.ts +238 -0
  82. package/plugins/design/dev-server/test/sync-connection-state.test.ts +146 -0
  83. package/plugins/design/dev-server/test/sync-meta-codec.test.ts +123 -0
  84. package/plugins/design/dev-server/test/sync-runtime.test.ts +531 -4
  85. package/plugins/design/dev-server/test/sync-status.test.ts +80 -0
  86. package/plugins/design/dev-server/test/sync-untrusted.test.ts +104 -0
  87. package/plugins/design/dev-server/test/use-tool-mode.test.tsx +38 -10
  88. package/plugins/design/dev-server/tool-palette.tsx +18 -16
  89. package/plugins/design/dev-server/undo-hud.tsx +4 -4
  90. package/plugins/design/dev-server/undo-stack.ts +20 -4
  91. package/plugins/design/dev-server/use-annotation-resize.tsx +16 -5
  92. package/plugins/design/dev-server/use-tool-mode.tsx +15 -12
  93. package/plugins/design/dev-server/ws.ts +40 -1
  94. package/plugins/design/templates/_shell.html +49 -1
  95. package/plugins/design/templates/canvas.tsx.template +13 -0
  96. package/plugins/design/templates/design-system-inspiration/SUB-AGENT-PROMPTS.md +14 -7
  97. package/plugins/flow/.claude-plugin/config.schema.json +5 -0
  98. package/plugins/flow/templates/ai-skeleton/README.md +1 -1
  99. package/plugins/flow/templates/ai-skeleton/gitignore +10 -0
  100. package/plugins/flow/templates/ai-skeleton/scenarios/README.md +3 -1
  101. package/plugins/flow/templates/ai-skeleton/workflows.config.json +2 -1
@@ -0,0 +1,276 @@
1
+ // annotations round-trip — Phase 21. The PARSE side of the schema (svgToStrokes)
2
+ // needs a DOMParser, which bun:test does not expose natively. We register
3
+ // happy-dom for THIS file only (beforeAll/afterAll) so the rest of the suite
4
+ // keeps running DOM-free; the existing annotations-layer.test.ts covers the
5
+ // write side (strokesToSvg) without a DOM.
6
+ //
7
+ // The load-bearing test here is the back-compat canary (Task 10): a hand-frozen
8
+ // pre-Phase-21 fixture must survive parse → re-serialize BYTE-IDENTICAL. Any
9
+ // "phantom default" (e.g. always emitting data-end-head="triangle") would bloat
10
+ // every legacy SVG on the first load→save cycle and this test would catch it.
11
+
12
+ import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
13
+ import { GlobalRegistrator } from '@happy-dom/global-registrator';
14
+
15
+ import {
16
+ type ArrowStroke,
17
+ type RectStroke,
18
+ type StickyStroke,
19
+ type Stroke,
20
+ type TextStroke,
21
+ strokesToSvg,
22
+ svgToStrokes,
23
+ } from '../annotations-layer.tsx';
24
+ import { sanitizeAnnotationSvg } from '../api.ts';
25
+
26
+ beforeAll(() => {
27
+ GlobalRegistrator.register();
28
+ });
29
+ afterAll(async () => {
30
+ await GlobalRegistrator.unregister();
31
+ });
32
+
33
+ /** Re-serialize a freshly-parsed SVG. Round-trip helper. */
34
+ function reparse(svg: string): string {
35
+ return strokesToSvg(svgToStrokes(svg));
36
+ }
37
+
38
+ describe('annotations round-trip / back-compat canary (Task 10)', () => {
39
+ test('a frozen pre-Phase-21 SVG round-trips BYTE-IDENTICAL', async () => {
40
+ const fixture = await Bun.file(
41
+ new URL('./fixtures/phase-20-annotations.svg', import.meta.url)
42
+ ).text();
43
+ // No trailing newline in the fixture; svgToStrokes trims its input but the
44
+ // serializer never emits trailing whitespace, so raw === raw must hold.
45
+ expect(fixture.endsWith('</svg>')).toBe(true);
46
+ const roundTripped = reparse(fixture);
47
+ expect(roundTripped).toBe(fixture);
48
+ });
49
+
50
+ test('parsing the fixture yields exactly the five legacy strokes', async () => {
51
+ const fixture = await Bun.file(
52
+ new URL('./fixtures/phase-20-annotations.svg', import.meta.url)
53
+ ).text();
54
+ const strokes = svgToStrokes(fixture);
55
+ expect(strokes.map((s) => s.tool)).toEqual(['pen', 'rect', 'ellipse', 'arrow', 'text']);
56
+ // The legacy rect must NOT pick up a phantom non-zero radius.
57
+ const rect = strokes.find((s) => s.tool === 'rect') as RectStroke;
58
+ expect(rect.cornerRadius ?? 0).toBe(0);
59
+ // The legacy arrow must NOT pick up phantom head/dash overrides.
60
+ const arrow = strokes.find((s) => s.tool === 'arrow') as ArrowStroke;
61
+ expect(arrow.startHead).toBeUndefined();
62
+ expect(arrow.endHead).toBeUndefined();
63
+ expect(arrow.dashed).toBeUndefined();
64
+ // The legacy text keeps its anchor.
65
+ const text = strokes.find((s) => s.tool === 'text') as TextStroke;
66
+ expect(text.anchorId).toBe('r1');
67
+ });
68
+ });
69
+
70
+ describe('annotations round-trip / sticky', () => {
71
+ const sticky: StickyStroke = {
72
+ id: 'st1',
73
+ tool: 'sticky',
74
+ color: '#ffe27a',
75
+ x: 40,
76
+ y: 50,
77
+ w: 200,
78
+ h: 160,
79
+ text: 'approve copy?',
80
+ fontSize: 14,
81
+ cornerRadius: 8,
82
+ };
83
+
84
+ test('sticky survives serialize → parse with all fields intact', () => {
85
+ const [parsed] = svgToStrokes(strokesToSvg([sticky])) as StickyStroke[];
86
+ expect(parsed).toEqual(sticky);
87
+ });
88
+
89
+ test('sticky with multi-line text round-trips the newline', () => {
90
+ const multi: StickyStroke = { ...sticky, text: 'line one\nline two' };
91
+ const [parsed] = svgToStrokes(strokesToSvg([multi])) as StickyStroke[];
92
+ expect(parsed?.text).toBe('line one\nline two');
93
+ });
94
+
95
+ test('sticky serialize→parse is idempotent', () => {
96
+ const once = strokesToSvg([sticky]);
97
+ expect(reparse(once)).toBe(once);
98
+ });
99
+
100
+ test('sticky never persists a foreignObject (sanitizer would strip it)', () => {
101
+ expect(strokesToSvg([sticky])).not.toContain('foreignObject');
102
+ });
103
+
104
+ test('sticky SVG survives sanitizeAnnotationSvg byte-intact (PUT-path)', () => {
105
+ // The persisted form passes through the DDR-060 F1 sanitizer on every PUT.
106
+ // It uses only allowlisted elements (g/rect/text) and no denied attrs, so
107
+ // it must come back unchanged — otherwise sticky text wouldn't persist.
108
+ const svg = strokesToSvg([sticky]);
109
+ expect(sanitizeAnnotationSvg(svg)).toBe(svg);
110
+ });
111
+
112
+ test('sticky body text still parses after a sanitize pass', () => {
113
+ const svg = strokesToSvg([{ ...sticky, text: 'survive me' }]);
114
+ const cleaned = sanitizeAnnotationSvg(svg);
115
+ const [parsed] = svgToStrokes(cleaned) as StickyStroke[];
116
+ expect(parsed?.text).toBe('survive me');
117
+ });
118
+ });
119
+
120
+ describe('annotations round-trip / rect corner radius', () => {
121
+ const base: RectStroke = {
122
+ id: 'r',
123
+ tool: 'rect',
124
+ color: '#1d6cf0',
125
+ width: 2,
126
+ x: 0,
127
+ y: 0,
128
+ w: 50,
129
+ h: 50,
130
+ fill: null,
131
+ };
132
+
133
+ for (const r of [0, 8, 999]) {
134
+ test(`cornerRadius ${r} round-trips`, () => {
135
+ const [parsed] = svgToStrokes(strokesToSvg([{ ...base, cornerRadius: r }])) as RectStroke[];
136
+ expect(parsed?.cornerRadius ?? 0).toBe(r);
137
+ });
138
+ }
139
+ });
140
+
141
+ describe('annotations round-trip / arrow heads + dash (4 dirs × 2 dash)', () => {
142
+ const base: ArrowStroke = {
143
+ id: 'a',
144
+ tool: 'arrow',
145
+ color: '#1a8f3e',
146
+ width: 2,
147
+ x1: 0,
148
+ y1: 0,
149
+ x2: 100,
150
+ y2: 0,
151
+ };
152
+
153
+ // The four FigJam direction presets, expressed as (startHead, endHead) pairs.
154
+ const dirs: Array<{
155
+ name: string;
156
+ startHead: 'none' | 'triangle';
157
+ endHead: 'none' | 'triangle';
158
+ }> = [
159
+ { name: 'none (line)', startHead: 'none', endHead: 'none' },
160
+ { name: 'start', startHead: 'triangle', endHead: 'none' },
161
+ { name: 'end (default)', startHead: 'none', endHead: 'triangle' },
162
+ { name: 'both', startHead: 'triangle', endHead: 'triangle' },
163
+ ];
164
+
165
+ for (const d of dirs) {
166
+ for (const dashed of [false, true]) {
167
+ test(`${d.name} + ${dashed ? 'dashed' : 'solid'} round-trips`, () => {
168
+ const arrow: ArrowStroke = {
169
+ ...base,
170
+ startHead: d.startHead,
171
+ endHead: d.endHead,
172
+ dashed,
173
+ };
174
+ const svg = strokesToSvg([arrow]);
175
+ const [parsed] = svgToStrokes(svg) as ArrowStroke[];
176
+ // Normalize defaults: the parser leaves default values undefined.
177
+ expect(parsed?.startHead ?? 'none').toBe(d.startHead);
178
+ expect(parsed?.endHead ?? 'triangle').toBe(d.endHead);
179
+ expect(parsed?.dashed ?? false).toBe(dashed);
180
+ // And it is idempotent at the byte level.
181
+ expect(reparse(svg)).toBe(svg);
182
+ });
183
+ }
184
+ }
185
+ });
186
+
187
+ describe('annotations round-trip / standalone vs anchored text', () => {
188
+ test('standalone text round-trips its world (x, y) and stays unanchored', () => {
189
+ const t: TextStroke = {
190
+ id: 't-std',
191
+ tool: 'text',
192
+ color: '#1a1a1a',
193
+ fontSize: 20,
194
+ text: 'side note',
195
+ x: 120,
196
+ y: 80,
197
+ };
198
+ const [parsed] = svgToStrokes(strokesToSvg([t])) as TextStroke[];
199
+ expect(parsed?.anchorId).toBeUndefined();
200
+ expect(parsed?.x).toBe(120);
201
+ expect(parsed?.y).toBe(80);
202
+ expect(parsed?.text).toBe('side note');
203
+ expect(parsed?.fontSize).toBe(20);
204
+ });
205
+
206
+ test('anchored text round-trips its host id (back-compat)', () => {
207
+ const t: TextStroke = {
208
+ id: 't-anc',
209
+ tool: 'text',
210
+ color: '#1a1a1a',
211
+ fontSize: 14,
212
+ text: 'label',
213
+ anchorId: 'r-host',
214
+ };
215
+ const [parsed] = svgToStrokes(strokesToSvg([t])) as TextStroke[];
216
+ expect(parsed?.anchorId).toBe('r-host');
217
+ expect(parsed?.x).toBeUndefined();
218
+ });
219
+ });
220
+
221
+ describe('annotations round-trip / mixed scene idempotency', () => {
222
+ test('a scene mixing every Phase 21 shape re-serializes identically', () => {
223
+ const scene: Stroke[] = [
224
+ {
225
+ id: 'p',
226
+ tool: 'pen',
227
+ color: '#000',
228
+ width: 3,
229
+ points: [
230
+ [0, 0],
231
+ [5, 5],
232
+ ],
233
+ },
234
+ {
235
+ id: 'r',
236
+ tool: 'rect',
237
+ color: '#222',
238
+ width: 2,
239
+ x: 0,
240
+ y: 0,
241
+ w: 40,
242
+ h: 40,
243
+ fill: null,
244
+ cornerRadius: 999,
245
+ },
246
+ {
247
+ id: 'a',
248
+ tool: 'arrow',
249
+ color: '#111',
250
+ width: 2,
251
+ x1: 0,
252
+ y1: 0,
253
+ x2: 50,
254
+ y2: 50,
255
+ startHead: 'triangle',
256
+ endHead: 'triangle',
257
+ dashed: true,
258
+ },
259
+ {
260
+ id: 'st',
261
+ tool: 'sticky',
262
+ color: '#ffd6e7',
263
+ x: 10,
264
+ y: 10,
265
+ w: 200,
266
+ h: 160,
267
+ text: 'hi',
268
+ fontSize: 14,
269
+ cornerRadius: 8,
270
+ },
271
+ { id: 'tx', tool: 'text', color: '#000', fontSize: 14, text: 'free', x: 5, y: 6 },
272
+ ];
273
+ const svg = strokesToSvg(scene);
274
+ expect(reparse(svg)).toBe(svg);
275
+ });
276
+ });
@@ -0,0 +1,73 @@
1
+ // canvas-cursors — Phase 21 custom tool cursors. Validates the data-URI CSS
2
+ // cursor shape so a malformed string (which silently disables the cursor in the
3
+ // browser) can't ship undetected.
4
+
5
+ import { describe, expect, test } from 'bun:test';
6
+
7
+ import { TOOL_CURSORS } from '../canvas-cursors.ts';
8
+
9
+ const ALL_TOOLS = [
10
+ 'move',
11
+ 'hand',
12
+ 'comment',
13
+ 'pen',
14
+ 'rect',
15
+ 'ellipse',
16
+ 'sticky',
17
+ 'arrow',
18
+ 'text',
19
+ 'eraser',
20
+ ] as const;
21
+
22
+ describe('canvas-cursors / TOOL_CURSORS', () => {
23
+ test('covers every tool', () => {
24
+ for (const t of ALL_TOOLS) {
25
+ expect(typeof TOOL_CURSORS[t]).toBe('string');
26
+ expect(TOOL_CURSORS[t].length).toBeGreaterThan(0);
27
+ }
28
+ });
29
+
30
+ test('move keeps the system arrow', () => {
31
+ expect(TOOL_CURSORS.move).toBe('default');
32
+ });
33
+
34
+ test('custom cursors are well-formed data-URI CSS values with hotspot + fallback', () => {
35
+ const fallback: Record<string, string> = {
36
+ hand: 'grab',
37
+ comment: 'crosshair',
38
+ pen: 'crosshair',
39
+ rect: 'crosshair',
40
+ ellipse: 'crosshair',
41
+ sticky: 'crosshair',
42
+ arrow: 'crosshair',
43
+ text: 'text',
44
+ eraser: 'cell',
45
+ };
46
+ for (const [tool, fb] of Object.entries(fallback)) {
47
+ const v = TOOL_CURSORS[tool as keyof typeof TOOL_CURSORS];
48
+ // url("data:image/svg+xml,<encoded>") <hx> <hy>, <fallback>
49
+ expect(v).toMatch(
50
+ new RegExp(`^url\\("data:image/svg\\+xml,%3Csvg[^"]+"\\) \\d+ \\d+, ${fb}$`)
51
+ );
52
+ // The SVG must declare a 32×32 box and be valid encoded markup.
53
+ const decoded = decodeURIComponent(v.slice(v.indexOf(',') + 1, v.indexOf('")')));
54
+ expect(decoded).toContain("width='32'");
55
+ expect(decoded).toContain("height='32'");
56
+ expect(decoded).toContain('</svg>');
57
+ }
58
+ });
59
+
60
+ test('hotspots are within the 0..31 image bounds', () => {
61
+ for (const t of ALL_TOOLS) {
62
+ if (TOOL_CURSORS[t] === 'default') continue;
63
+ const m = TOOL_CURSORS[t].match(/"\) (\d+) (\d+),/);
64
+ expect(m).not.toBeNull();
65
+ const hx = Number(m?.[1]);
66
+ const hy = Number(m?.[2]);
67
+ expect(hx).toBeGreaterThanOrEqual(0);
68
+ expect(hx).toBeLessThanOrEqual(31);
69
+ expect(hy).toBeGreaterThanOrEqual(0);
70
+ expect(hy).toBeLessThanOrEqual(31);
71
+ }
72
+ });
73
+ });
@@ -0,0 +1,76 @@
1
+ // Canvas-origin route gate — A1/A2 (DDR-060 F1 re-audit). Boots a real server
2
+ // with MAUDE_CANVAS_ORIGIN_SPLIT=1 and probes the segregated canvas origin to
3
+ // prove: (1) the %2f-encoded path-traversal that previously leaked repo files
4
+ // outside designRoot is now 403'd; (2) privileged routes stay 403; (3) legit
5
+ // designRoot assets + the shell still serve; (4) the shell carries the hardened
6
+ // CSP. See .ai/logs/security-reviews/phase-9.1-t2-f1-cross-origin-reaudit.md.
7
+
8
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
9
+ import { join } from 'node:path';
10
+
11
+ import { describe, expect, test } from 'bun:test';
12
+
13
+ import { bootServer, killProc, makeSandbox, nextPort } from './_helpers.ts';
14
+
15
+ async function readCanvasOrigin(designRoot: string): Promise<string> {
16
+ // server.ts writes _server.json under the designRoot once both listeners bind.
17
+ for (let i = 0; i < 40; i++) {
18
+ try {
19
+ const info = JSON.parse(readFileSync(join(designRoot, '_server.json'), 'utf8'));
20
+ if (info.canvasOrigin) return info.canvasOrigin as string;
21
+ } catch {
22
+ /* not written yet */
23
+ }
24
+ await Bun.sleep(50);
25
+ }
26
+ throw new Error('canvasOrigin never appeared in _server.json');
27
+ }
28
+
29
+ describe('canvas-origin gate — A1/A2 traversal + privilege containment', () => {
30
+ test('%2f traversal out of designRoot is 403; privileged 403; legit 200; CSP hardened', async () => {
31
+ const { root, designRoot } = makeSandbox();
32
+ // A real asset OUTSIDE .design/ but INSIDE repoRoot — the traversal target.
33
+ writeFileSync(join(root, 'secret-outside.css'), '.leak{content:"SECRET"}');
34
+ // A legit canvas asset under designRoot (the allowed case).
35
+ mkdirSync(join(designRoot, 'ui'), { recursive: true });
36
+ writeFileSync(
37
+ join(designRoot, 'ui', 'Gate.tsx'),
38
+ 'export default function G(){return <main/>}\n'
39
+ );
40
+
41
+ const port = nextPort();
42
+ const proc = await bootServer(root, port, { MAUDE_CANVAS_ORIGIN_SPLIT: '1' });
43
+ try {
44
+ const canvas = await readCanvasOrigin(designRoot);
45
+ const code = async (p: string) =>
46
+ (await fetch(canvas + p, { signal: AbortSignal.timeout(2000) })).status;
47
+
48
+ // (1) The confirmed bypass — must now be 403, not 200.
49
+ expect(await code('/.design/..%2fsecret-outside.css')).toBe(403);
50
+ expect(await code('/.design/ui/..%2f..%2fsecret-outside.css')).toBe(403);
51
+ // Double-encoded + mixed-case variants stay closed.
52
+ expect(await code('/.design/%2e%2e%2fsecret-outside.css')).toBe(403);
53
+
54
+ // (2) Privileged / out-of-scope routes stay 403 on the canvas origin.
55
+ for (const p of ['/_config', '/_sync-status', '/_api/export', '/package.json']) {
56
+ expect(await code(p)).toBe(403);
57
+ }
58
+
59
+ // (3) Legit surfaces still serve.
60
+ expect(await code('/_health')).toBe(200);
61
+ expect(await code('/_canvas-shell.html')).toBe(200);
62
+ // A real .tsx canvas under designRoot transpiles + serves (200).
63
+ expect(await code('/.design/ui/Gate.tsx')).toBe(200);
64
+
65
+ // (4) The shell carries the hardened CSP (A6).
66
+ const shell = await fetch(`${canvas}/_canvas-shell.html`);
67
+ const csp = shell.headers.get('content-security-policy') ?? '';
68
+ expect(csp).toContain("connect-src 'self'");
69
+ expect(csp).toContain("webrtc 'block'");
70
+ expect(csp).toContain('frame-ancestors');
71
+ expect(csp).toContain(`http://localhost:${port}`);
72
+ } finally {
73
+ await killProc(proc);
74
+ }
75
+ });
76
+ });
@@ -0,0 +1,78 @@
1
+ // Gap 1 (file-sync clobber fix) — the syncRoomFrom* no-op guards.
2
+ //
3
+ // createCollab now re-seeds a live room from disk whenever a synced file
4
+ // changes externally (sync-agent hub-push or `design:edit` write), so the
5
+ // room stops clobbering the external change back. That re-seed reuses
6
+ // syncRoomFromComments/syncRoomFromAnnotations, which ALSO run on the room's
7
+ // own persist → file → fs-event path. Without an equality short-circuit that
8
+ // would spin an 800ms persist storm. These tests pin the guard: an identical
9
+ // re-seed emits NO doc update (→ no persist → no loop); a real change emits one.
10
+
11
+ import { describe, expect, test } from 'bun:test';
12
+
13
+ import { Y_TYPES } from '../collab/persistence.ts';
14
+ import { createRegistry } from '../collab/registry.ts';
15
+ import type { RoomCallbacks } from '../collab/room.ts';
16
+
17
+ function noopCallbacks(): RoomCallbacks {
18
+ return {
19
+ async seed() {},
20
+ async persistJson() {},
21
+ async persistBinary() {},
22
+ };
23
+ }
24
+
25
+ describe('Gap 1 — syncRoomFromComments no-op guard', () => {
26
+ test('re-seeding identical comments emits NO doc update (loop-safe)', () => {
27
+ const r = createRegistry(noopCallbacks());
28
+ const room = r.get('s');
29
+ const list = [{ id: 'c1', text: 'hi' }];
30
+ r.syncRoomFromComments('s', list);
31
+
32
+ let updates = 0;
33
+ room.doc.on('update', () => {
34
+ updates++;
35
+ });
36
+ // Identical content (the room's-own-persist echo, or a redundant hub push)
37
+ // → must not emit, or the reseed↔persist loop never settles.
38
+ r.syncRoomFromComments('s', [{ id: 'c1', text: 'hi' }]);
39
+ expect(updates).toBe(0);
40
+
41
+ // A genuine change still propagates — exactly one update.
42
+ r.syncRoomFromComments('s', [
43
+ { id: 'c1', text: 'hi' },
44
+ { id: 'c2', text: 'yo' },
45
+ ]);
46
+ expect(updates).toBe(1);
47
+ });
48
+
49
+ test('re-seed brings a STALE room up to the on-disk list (the clobber fix)', () => {
50
+ const r = createRegistry(noopCallbacks());
51
+ const room = r.get('s');
52
+ const arr = room.doc.getArray(Y_TYPES.comments);
53
+ expect(arr.length).toBe(0); // room opened empty (the "B shows []" state)
54
+
55
+ // Sync agent wrote a hub-pushed comment to disk → createCollab re-seeds:
56
+ r.syncRoomFromComments('s', [{ id: 'c1', text: 'from peer A' }]);
57
+ expect(arr.toArray()).toEqual([{ id: 'c1', text: 'from peer A' }]);
58
+ });
59
+ });
60
+
61
+ describe('Gap 1 — syncRoomFromAnnotations no-op guard', () => {
62
+ test('re-seeding the identical svg emits NO doc update', () => {
63
+ const r = createRegistry(noopCallbacks());
64
+ const room = r.get('s');
65
+ const svg = '<svg xmlns="http://www.w3.org/2000/svg"><g/></svg>';
66
+ r.syncRoomFromAnnotations('s', svg);
67
+
68
+ let updates = 0;
69
+ room.doc.on('update', () => {
70
+ updates++;
71
+ });
72
+ r.syncRoomFromAnnotations('s', svg); // identical → no-op
73
+ expect(updates).toBe(0);
74
+
75
+ r.syncRoomFromAnnotations('s', '<svg xmlns="http://www.w3.org/2000/svg"><rect/></svg>');
76
+ expect(updates).toBe(1);
77
+ });
78
+ });
@@ -3,9 +3,15 @@
3
3
 
4
4
  import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
5
5
 
6
+ import { Awareness } from 'y-protocols/awareness';
6
7
  import * as Y from 'yjs';
7
8
 
8
- import { encodeHandshake, encodeSyncUpdate, handleMessage } from '../collab/protocol.ts';
9
+ import {
10
+ encodeAwarenessFrame,
11
+ encodeHandshake,
12
+ encodeSyncUpdate,
13
+ handleMessage,
14
+ } from '../collab/protocol.ts';
9
15
  import { type RoomCallbacks, type RoomConn, createRoom } from '../collab/room.ts';
10
16
 
11
17
  function makeConn(id: string): RoomConn & { recv: Uint8Array[] } {
@@ -140,24 +146,29 @@ describe('Room', () => {
140
146
  await room.destroy();
141
147
  });
142
148
 
143
- test('disconnect cleans up awareness states tagged with __connId', async () => {
149
+ test('disconnect removes the awareness states a conn published', async () => {
144
150
  const cb = makeCallbacks();
145
151
  const room = createRoom('aw-cleanup-slug', cb);
146
152
  const A = makeConn('a');
147
153
  await room.connect(A);
148
154
 
149
- // Simulate Alice publishing her awareness state with the matching __connId.
150
- room.awareness.setLocalState({ name: 'Alice', __connId: 'a' });
151
- expect(room.awareness.getStates().size).toBe(1);
155
+ // Alice (conn A) publishes her awareness over the wire — the server learns
156
+ // her clientID belongs to conn 'a' from the update origin (server-authoritative;
157
+ // it does NOT trust a client-supplied __connId). Mirrors the real receive path.
158
+ const clientDoc = new Y.Doc();
159
+ const clientAw = new Awareness(clientDoc);
160
+ clientAw.setLocalState({ name: 'Alice' });
161
+ room.receive(A, encodeAwarenessFrame(clientAw, [clientAw.clientID]));
162
+ expect(room.awareness.getStates().has(clientAw.clientID)).toBe(true);
152
163
 
153
164
  room.disconnect(A);
154
165
  expect(room.size()).toBe(0);
155
- // Awareness state owned by the disconnected conn is gone.
156
- const states = room.awareness.getStates();
157
- for (const s of states.values()) {
158
- expect((s as { __connId?: string }).__connId).not.toBe('a');
159
- }
166
+ // The disconnected conn's awareness state is dropped immediately no waiting
167
+ // out the ~30s awareness timeout (the phantom-accumulation bug).
168
+ expect(room.awareness.getStates().has(clientAw.clientID)).toBe(false);
160
169
 
170
+ clientAw.destroy();
171
+ clientDoc.destroy();
161
172
  await room.destroy();
162
173
  });
163
174
 
@@ -0,0 +1,46 @@
1
+ // cspForCanvasShell — A6 (DDR-060 F1 re-audit) CSP hardening for the segregated
2
+ // canvas origin. Asserts the exfil-relevant directives are present and that
3
+ // `frame-ancestors` is only emitted when the main origin is known (so the legit
4
+ // cross-origin embed is never accidentally blocked).
5
+
6
+ import { describe, expect, test } from 'bun:test';
7
+
8
+ import { cspForCanvasShell } from '../http.ts';
9
+
10
+ const SHELL = '<!doctype html><html><head><script>const x=1;</script></head><body></body></html>';
11
+
12
+ describe('cspForCanvasShell — A6 hardening', () => {
13
+ test('locks down the dangerous lanes', () => {
14
+ const csp = cspForCanvasShell(SHELL);
15
+ expect(csp).toContain("default-src 'none'");
16
+ expect(csp).toContain("connect-src 'self'");
17
+ expect(csp).toContain("object-src 'none'");
18
+ expect(csp).toContain("base-uri 'none'");
19
+ // No eval / no inline-script escape hatch.
20
+ expect(csp).not.toContain('unsafe-eval');
21
+ expect(csp).not.toMatch(/script-src[^;]*unsafe-inline/);
22
+ });
23
+
24
+ test("adds webrtc 'block' (connect-src does not cover WebRTC exfil)", () => {
25
+ expect(cspForCanvasShell(SHELL)).toContain("webrtc 'block'");
26
+ });
27
+
28
+ test("adds form-action 'none'", () => {
29
+ expect(cspForCanvasShell(SHELL)).toContain("form-action 'none'");
30
+ });
31
+
32
+ test('hashes inline scripts (no unsafe-inline for script-src)', () => {
33
+ const csp = cspForCanvasShell(SHELL);
34
+ expect(csp).toMatch(/script-src 'self' 'sha256-[A-Za-z0-9+/=]+'/);
35
+ });
36
+
37
+ test('frame-ancestors allowlists the main origin when known', () => {
38
+ const csp = cspForCanvasShell(SHELL, 'http://localhost:4399');
39
+ expect(csp).toContain("frame-ancestors 'self' http://localhost:4399");
40
+ });
41
+
42
+ test('frame-ancestors is OMITTED when the main origin is unknown (would block the legit embed)', () => {
43
+ const csp = cspForCanvasShell(SHELL);
44
+ expect(csp).not.toContain('frame-ancestors');
45
+ });
46
+ });
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" data-mdcc-annotations="1"><path data-id="p1" data-tool="pen" stroke="#d63b1f" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" vector-effect="non-scaling-stroke" fill="none" d="M0 0 L10 10 L20 5" pointer-events="stroke"/><rect data-id="r1" data-tool="rect" stroke="#222" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" vector-effect="non-scaling-stroke" fill="#fff4d6" x="10" y="20" width="30" height="40"/><ellipse data-id="e1" data-tool="ellipse" stroke="#222" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" vector-effect="non-scaling-stroke" fill="#e6f4ea" cx="100" cy="100" rx="40" ry="25"/><g data-id="a1" data-tool="arrow" stroke="#1a8f3e" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" vector-effect="non-scaling-stroke" fill="none"><line x1="0" y1="0" x2="100" y2="0"/><polyline points="85.5844981135613,6.94213982588093 100,0 85.5844981135613,-6.94213982588093" fill="#1a8f3e"/></g><text data-id="t1" data-tool="text" data-anchor-id="r1" data-font-size="14" fill="#1a1a1a" text-anchor="middle" dominant-baseline="middle">needs work</text></svg>
@@ -229,6 +229,20 @@ describe('input-router / keydown — Phase 5 draw tools', () => {
229
229
  });
230
230
  });
231
231
 
232
+ test('N → tool sticky (Phase 21)', () => {
233
+ expect(classify(base({ type: 'keydown', key: 'n' }))).toEqual({
234
+ kind: 'tool',
235
+ tool: 'sticky',
236
+ });
237
+ });
238
+
239
+ test('T → tool text (Phase 21)', () => {
240
+ expect(classify(base({ type: 'keydown', key: 't' }))).toEqual({
241
+ kind: 'tool',
242
+ tool: 'text',
243
+ });
244
+ });
245
+
232
246
  test('uppercase B (shift held) — still maps to pen (lowercased)', () => {
233
247
  expect(classify(base({ type: 'keydown', key: 'B', shiftKey: true }))).toEqual({
234
248
  kind: 'tool',
@@ -268,6 +282,13 @@ describe('input-router / pointer events — Phase 5 annotation tools', () => {
268
282
  expect(classify(base({ type: 'pointermove', activeTool: 'ellipse' })).kind).toBe('no-op');
269
283
  });
270
284
 
285
+ test('sticky + text tools own bare pointer events (Phase 21 — SVG overlay claims)', () => {
286
+ for (const activeTool of ['sticky', 'text'] as const) {
287
+ expect(classify(base({ type: 'pointerdown', activeTool, button: 0 })).kind).toBe('no-op');
288
+ expect(classify(base({ type: 'pointermove', activeTool })).kind).toBe('no-op');
289
+ }
290
+ });
291
+
271
292
  test('cmd+left-click in arrow tool → select replace (escape hatch to move)', () => {
272
293
  expect(
273
294
  classify(