@1agh/maude 0.22.2 → 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 (125) 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 +84 -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 +135 -10
  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 +222 -11
  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/hubs-config.mjs +42 -4
  22. package/cli/lib/plugin-cli-reachability.test.mjs +56 -0
  23. package/cli/lib/preflight.mjs +41 -10
  24. package/package.json +8 -8
  25. package/plugins/design/dev-server/ai-banner.tsx +2 -2
  26. package/plugins/design/dev-server/annotations-context-toolbar.tsx +349 -112
  27. package/plugins/design/dev-server/annotations-layer.tsx +906 -137
  28. package/plugins/design/dev-server/api.ts +109 -4
  29. package/plugins/design/dev-server/bin/preflight.sh +21 -10
  30. package/plugins/design/dev-server/bin/prep.sh +211 -0
  31. package/plugins/design/dev-server/bin/scenario-report.mjs +209 -0
  32. package/plugins/design/dev-server/bin/screenshot.sh +18 -1
  33. package/plugins/design/dev-server/bin/smoke.sh +94 -11
  34. package/plugins/design/dev-server/build.ts +69 -3
  35. package/plugins/design/dev-server/canvas-comment-mount.tsx +384 -0
  36. package/plugins/design/dev-server/canvas-cursors.ts +125 -0
  37. package/plugins/design/dev-server/canvas-icons.tsx +130 -0
  38. package/plugins/design/dev-server/canvas-lib.tsx +47 -27
  39. package/plugins/design/dev-server/canvas-meta.schema.json +20 -0
  40. package/plugins/design/dev-server/canvas-shell.tsx +358 -245
  41. package/plugins/design/dev-server/client/app.jsx +191 -21
  42. package/plugins/design/dev-server/client/styles/3-shell.css +10 -0
  43. package/plugins/design/dev-server/collab/awareness-bridge.ts +77 -0
  44. package/plugins/design/dev-server/collab/index.ts +87 -9
  45. package/plugins/design/dev-server/collab/persistence.ts +34 -3
  46. package/plugins/design/dev-server/collab/registry.ts +131 -2
  47. package/plugins/design/dev-server/collab/room.ts +21 -8
  48. package/plugins/design/dev-server/config.schema.json +20 -0
  49. package/plugins/design/dev-server/context-menu.tsx +167 -23
  50. package/plugins/design/dev-server/context.ts +31 -0
  51. package/plugins/design/dev-server/contextual-toolbar.tsx +7 -7
  52. package/plugins/design/dev-server/dist/client.bundle.js +144 -22
  53. package/plugins/design/dev-server/dist/comment-mount.js +1868 -0
  54. package/plugins/design/dev-server/dist/styles.css +16 -0
  55. package/plugins/design/dev-server/dom-selection.ts +156 -0
  56. package/plugins/design/dev-server/equal-spacing-handles.tsx +1 -1
  57. package/plugins/design/dev-server/export-dialog.tsx +19 -17
  58. package/plugins/design/dev-server/fs-watch.ts +1 -0
  59. package/plugins/design/dev-server/hmr-broadcast.ts +51 -20
  60. package/plugins/design/dev-server/http.ts +260 -20
  61. package/plugins/design/dev-server/input-router.tsx +125 -64
  62. package/plugins/design/dev-server/participants-chrome.tsx +10 -10
  63. package/plugins/design/dev-server/server.ts +141 -1
  64. package/plugins/design/dev-server/sync/agent.ts +418 -0
  65. package/plugins/design/dev-server/sync/atomic-write.ts +103 -0
  66. package/plugins/design/dev-server/sync/codec.ts +324 -0
  67. package/plugins/design/dev-server/sync/connection-state.ts +203 -0
  68. package/plugins/design/dev-server/sync/echo-guard.ts +108 -0
  69. package/plugins/design/dev-server/sync/fs-mirror.ts +160 -0
  70. package/plugins/design/dev-server/sync/hubs-config.ts +87 -0
  71. package/plugins/design/dev-server/sync/index.ts +918 -0
  72. package/plugins/design/dev-server/sync/materialize.ts +62 -0
  73. package/plugins/design/dev-server/sync/migrate-seed.ts +163 -0
  74. package/plugins/design/dev-server/sync/origins.ts +57 -0
  75. package/plugins/design/dev-server/sync/projection.ts +368 -0
  76. package/plugins/design/dev-server/sync/status.ts +115 -0
  77. package/plugins/design/dev-server/sync/untrusted.ts +153 -0
  78. package/plugins/design/dev-server/test/_helpers.ts +6 -2
  79. package/plugins/design/dev-server/test/annotations-layer.test.ts +231 -0
  80. package/plugins/design/dev-server/test/annotations-roundtrip.test.ts +276 -0
  81. package/plugins/design/dev-server/test/canvas-cursors.test.ts +73 -0
  82. package/plugins/design/dev-server/test/canvas-origin-gate.test.ts +76 -0
  83. package/plugins/design/dev-server/test/collab-awareness-bridge.test.ts +223 -0
  84. package/plugins/design/dev-server/test/collab-reseed-guard.test.ts +78 -0
  85. package/plugins/design/dev-server/test/collab-room.test.ts +21 -10
  86. package/plugins/design/dev-server/test/comment-mount.test.ts +87 -0
  87. package/plugins/design/dev-server/test/csp-canvas-shell.test.ts +46 -0
  88. package/plugins/design/dev-server/test/fixtures/phase-20-annotations.svg +1 -0
  89. package/plugins/design/dev-server/test/hmr-classify.test.ts +70 -0
  90. package/plugins/design/dev-server/test/input-router.test.ts +21 -0
  91. package/plugins/design/dev-server/test/sanitize-annotation-svg.test.ts +100 -0
  92. package/plugins/design/dev-server/test/shared-doc-convergence.test.ts +265 -0
  93. package/plugins/design/dev-server/test/shared-doc-foundation.test.ts +63 -0
  94. package/plugins/design/dev-server/test/shared-doc-migrate.test.ts +160 -0
  95. package/plugins/design/dev-server/test/shared-doc-projection.test.ts +238 -0
  96. package/plugins/design/dev-server/test/sync-agent.test.ts +278 -0
  97. package/plugins/design/dev-server/test/sync-atomic-write.test.ts +63 -0
  98. package/plugins/design/dev-server/test/sync-codec.test.ts +165 -0
  99. package/plugins/design/dev-server/test/sync-connection-state.test.ts +146 -0
  100. package/plugins/design/dev-server/test/sync-echo-guard.test.ts +96 -0
  101. package/plugins/design/dev-server/test/sync-fs-mirror.test.ts +182 -0
  102. package/plugins/design/dev-server/test/sync-hardening.test.ts +520 -0
  103. package/plugins/design/dev-server/test/sync-hubs-config.test.ts +130 -0
  104. package/plugins/design/dev-server/test/sync-meta-codec.test.ts +123 -0
  105. package/plugins/design/dev-server/test/sync-runtime.test.ts +812 -0
  106. package/plugins/design/dev-server/test/sync-status.test.ts +80 -0
  107. package/plugins/design/dev-server/test/sync-untrusted.test.ts +104 -0
  108. package/plugins/design/dev-server/test/use-collab.test.ts +0 -0
  109. package/plugins/design/dev-server/test/use-tool-mode.test.tsx +38 -10
  110. package/plugins/design/dev-server/tool-palette.tsx +18 -16
  111. package/plugins/design/dev-server/undo-hud.tsx +4 -4
  112. package/plugins/design/dev-server/undo-stack.ts +20 -4
  113. package/plugins/design/dev-server/use-annotation-resize.tsx +16 -5
  114. package/plugins/design/dev-server/use-collab.tsx +157 -13
  115. package/plugins/design/dev-server/use-selection-set.tsx +12 -0
  116. package/plugins/design/dev-server/use-tool-mode.tsx +27 -12
  117. package/plugins/design/dev-server/ws.ts +40 -1
  118. package/plugins/design/templates/_shell.html +63 -5
  119. package/plugins/design/templates/canvas.tsx.template +13 -0
  120. package/plugins/design/templates/design-system-inspiration/SUB-AGENT-PROMPTS.md +15 -7
  121. package/plugins/flow/.claude-plugin/config.schema.json +5 -0
  122. package/plugins/flow/templates/ai-skeleton/README.md +1 -1
  123. package/plugins/flow/templates/ai-skeleton/gitignore +10 -0
  124. package/plugins/flow/templates/ai-skeleton/scenarios/README.md +3 -1
  125. 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,223 @@
1
+ // Phase 9 Task 5 — awareness over WSS.
2
+ //
3
+ // Unit-tests the in-process Awareness bridge (collab Room ↔ sync provider) and
4
+ // the registry wiring that owns its lifecycle, plus an end-to-end cross-peer
5
+ // cursor relay through a simulated hub.
6
+
7
+ import { describe, expect, test } from 'bun:test';
8
+
9
+ import { Awareness, applyAwarenessUpdate, encodeAwarenessUpdate } from 'y-protocols/awareness';
10
+ import * as Y from 'yjs';
11
+
12
+ import { bridgeAwareness } from '../collab/awareness-bridge.ts';
13
+ import { createRegistry } from '../collab/registry.ts';
14
+ import type { RoomCallbacks } from '../collab/room.ts';
15
+
16
+ function noopCallbacks(): RoomCallbacks {
17
+ return {
18
+ async seed() {},
19
+ async persistJson() {},
20
+ async persistBinary() {},
21
+ };
22
+ }
23
+
24
+ function mkAwareness(): Awareness {
25
+ return new Awareness(new Y.Doc());
26
+ }
27
+
28
+ // Simulate the Hocuspocus hub relay: two Awareness instances cross-linked so a
29
+ // change on one appears on the other (keyed by the originating clientID).
30
+ function linkAwareness(x: Awareness, y: Awareness): () => void {
31
+ const T = { transport: true };
32
+ function fwd(from: Awareness, to: Awareness) {
33
+ return (
34
+ { added, updated, removed }: { added: number[]; updated: number[]; removed: number[] },
35
+ origin: unknown
36
+ ) => {
37
+ if (origin === T) return;
38
+ const changed = added.concat(updated, removed);
39
+ if (changed.length === 0) return;
40
+ applyAwarenessUpdate(to, encodeAwarenessUpdate(from, changed), T);
41
+ };
42
+ }
43
+ const xy = fwd(x, y);
44
+ const yx = fwd(y, x);
45
+ x.on('update', xy);
46
+ y.on('update', yx);
47
+ return () => {
48
+ x.off('update', xy);
49
+ y.off('update', yx);
50
+ };
51
+ }
52
+
53
+ describe('bridgeAwareness', () => {
54
+ test('relays a local state set on a → b', () => {
55
+ const a = mkAwareness();
56
+ const b = mkAwareness();
57
+ bridgeAwareness(a, b);
58
+
59
+ a.setLocalState({ name: 'Alice', color: '#f00', cursor: { x: 1, y: 2 } });
60
+
61
+ const seen = b.getStates().get(a.clientID) as { name?: string; color?: string } | undefined;
62
+ expect(seen?.name).toBe('Alice');
63
+ expect(seen?.color).toBe('#f00');
64
+ });
65
+
66
+ test('relays both directions', () => {
67
+ const a = mkAwareness();
68
+ const b = mkAwareness();
69
+ bridgeAwareness(a, b);
70
+
71
+ b.setLocalState({ name: 'Bob' });
72
+ expect((a.getStates().get(b.clientID) as { name?: string } | undefined)?.name).toBe('Bob');
73
+ });
74
+
75
+ test('propagates removal when the source clears its state', () => {
76
+ const a = mkAwareness();
77
+ const b = mkAwareness();
78
+ bridgeAwareness(a, b);
79
+
80
+ a.setLocalState({ name: 'Alice' });
81
+ expect(b.getStates().has(a.clientID)).toBe(true);
82
+
83
+ a.setLocalState(null);
84
+ expect(b.getStates().has(a.clientID)).toBe(false);
85
+ });
86
+
87
+ test('exchanges pre-existing states at wire time', () => {
88
+ const a = mkAwareness();
89
+ const b = mkAwareness();
90
+ a.setLocalState({ name: 'Alice' });
91
+ b.setLocalState({ name: 'Bob' });
92
+
93
+ bridgeAwareness(a, b);
94
+
95
+ expect((b.getStates().get(a.clientID) as { name?: string } | undefined)?.name).toBe('Alice');
96
+ expect((a.getStates().get(b.clientID) as { name?: string } | undefined)?.name).toBe('Bob');
97
+ });
98
+
99
+ test('does not echo — a single update yields a single foreign state, no storm', () => {
100
+ const a = mkAwareness();
101
+ const b = mkAwareness();
102
+ bridgeAwareness(a, b);
103
+
104
+ let bUpdates = 0;
105
+ b.on('update', () => {
106
+ bUpdates++;
107
+ });
108
+
109
+ a.setLocalState({ name: 'Alice' });
110
+
111
+ // Exactly one relayed update; a's own state count stays 1 (no bounce-back
112
+ // creating phantom clients).
113
+ expect(bUpdates).toBe(1);
114
+ expect(a.getStates().size).toBe(1);
115
+ expect(b.getStates().size).toBe(2); // a (relayed) + b's own
116
+ });
117
+
118
+ test('detach stops further relay', () => {
119
+ const a = mkAwareness();
120
+ const b = mkAwareness();
121
+ const detach = bridgeAwareness(a, b);
122
+
123
+ detach();
124
+ a.setLocalState({ name: 'Alice' });
125
+ expect(b.getStates().has(a.clientID)).toBe(false);
126
+ });
127
+ });
128
+
129
+ describe('Registry.attachHubAwareness', () => {
130
+ test('attach-then-create wires the bridge so room state reaches the hub', () => {
131
+ const r = createRegistry(noopCallbacks());
132
+ const hub = mkAwareness();
133
+ r.attachHubAwareness('s', hub);
134
+
135
+ const room = r.get('s');
136
+ room.awareness.setLocalState({ name: 'Alice' });
137
+
138
+ expect((hub.getStates().get(room.awareness.clientID) as { name?: string })?.name).toBe('Alice');
139
+ });
140
+
141
+ test('create-then-attach wires immediately', () => {
142
+ const r = createRegistry(noopCallbacks());
143
+ const room = r.get('s');
144
+ room.awareness.setLocalState({ name: 'Alice' });
145
+
146
+ const hub = mkAwareness();
147
+ r.attachHubAwareness('s', hub);
148
+
149
+ // Pre-existing room state is exchanged at wire time.
150
+ expect((hub.getStates().get(room.awareness.clientID) as { name?: string })?.name).toBe('Alice');
151
+ });
152
+
153
+ test('detach stops relaying and prevents re-wire on a fresh room', async () => {
154
+ const r = createRegistry(noopCallbacks());
155
+ const hub = mkAwareness();
156
+ const detach = r.attachHubAwareness('s', hub);
157
+ r.get('s');
158
+
159
+ detach();
160
+ await r.drop('s'); // room had no conns → destroyed
161
+
162
+ const room2 = r.get('s');
163
+ room2.awareness.setLocalState({ name: 'Alice' });
164
+ expect(hub.getStates().has(room2.awareness.clientID)).toBe(false);
165
+ });
166
+
167
+ test('room churn re-wires the bridge (hub awareness persists)', async () => {
168
+ const r = createRegistry(noopCallbacks());
169
+ const hub = mkAwareness();
170
+ r.attachHubAwareness('s', hub);
171
+
172
+ const room1 = r.get('s');
173
+ room1.awareness.setLocalState({ name: 'Alice' });
174
+ expect(hub.getStates().has(room1.awareness.clientID)).toBe(true);
175
+
176
+ await r.drop('s'); // last browser left
177
+
178
+ const room2 = r.get('s'); // browser reconnects
179
+ room2.awareness.setLocalState({ name: 'Alice2' });
180
+ expect((hub.getStates().get(room2.awareness.clientID) as { name?: string })?.name).toBe(
181
+ 'Alice2'
182
+ );
183
+ });
184
+
185
+ test('destroyAll tears down bridges without throwing', async () => {
186
+ const r = createRegistry(noopCallbacks());
187
+ const hub = mkAwareness();
188
+ r.attachHubAwareness('s', hub);
189
+ r.get('s').awareness.setLocalState({ name: 'Alice' });
190
+
191
+ await r.destroyAll();
192
+ // Hub Awareness is no longer relayed-to; setting a fresh room would not
193
+ // re-wire because destroyAll cleared the attachment.
194
+ const room = r.get('s');
195
+ room.awareness.setLocalState({ name: 'Bob' });
196
+ expect(hub.getStates().has(room.awareness.clientID)).toBe(false);
197
+ });
198
+ });
199
+
200
+ describe('cross-peer cursor relay (Room A → hub → Room B)', () => {
201
+ test('a cursor published in one peer reaches the other peer', () => {
202
+ // Peer A: room A ↔ hubA. Peer B: room B ↔ hubB. hubA ↔ hubB is the
203
+ // Hocuspocus relay (simulated by linkAwareness).
204
+ const regA = createRegistry(noopCallbacks());
205
+ const regB = createRegistry(noopCallbacks());
206
+ const hubA = mkAwareness();
207
+ const hubB = mkAwareness();
208
+ linkAwareness(hubA, hubB);
209
+ regA.attachHubAwareness('canvas', hubA);
210
+ regB.attachHubAwareness('canvas', hubB);
211
+
212
+ const roomA = regA.get('canvas');
213
+ const roomB = regB.get('canvas');
214
+
215
+ roomA.awareness.setLocalState({ name: 'Alice', cursor: { x: 10, y: 20 } });
216
+
217
+ const onB = roomB.awareness.getStates().get(roomA.awareness.clientID) as
218
+ | { name?: string; cursor?: { x: number; y: number } }
219
+ | undefined;
220
+ expect(onB?.name).toBe('Alice');
221
+ expect(onB?.cursor).toEqual({ x: 10, y: 20 });
222
+ });
223
+ });