@1agh/maude 0.29.0 → 0.30.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 (99) hide show
  1. package/README.md +1 -1
  2. package/apps/studio/ai-banner.tsx +0 -1
  3. package/apps/studio/annotations-align.ts +149 -0
  4. package/apps/studio/annotations-bindings.ts +197 -0
  5. package/apps/studio/annotations-context-toolbar.tsx +436 -186
  6. package/apps/studio/annotations-groups.ts +270 -0
  7. package/apps/studio/annotations-layer.tsx +1708 -1660
  8. package/apps/studio/annotations-model.ts +2077 -0
  9. package/apps/studio/annotations-snap.ts +125 -0
  10. package/apps/studio/api.ts +208 -116
  11. package/apps/studio/bin/_png-playwright.mjs +1 -1
  12. package/apps/studio/bin/annotate.mjs +732 -0
  13. package/apps/studio/bin/annotate.sh +17 -0
  14. package/apps/studio/bin/read-annotations.mjs +152 -17
  15. package/apps/studio/build.ts +1 -1
  16. package/apps/studio/canvas-arrowheads.ts +78 -9
  17. package/apps/studio/canvas-cursors.ts +2 -0
  18. package/apps/studio/canvas-edit.ts +257 -7
  19. package/apps/studio/canvas-icons.tsx +105 -0
  20. package/apps/studio/canvas-shell.tsx +305 -8
  21. package/apps/studio/client/app.jsx +2812 -238
  22. package/apps/studio/client/index.html +1 -1
  23. package/apps/studio/client/styles/3-shell-maude.css +713 -11
  24. package/apps/studio/client/styles/4-components.css +39 -0
  25. package/apps/studio/client/styles/5-maude-overrides.css +16 -2
  26. package/apps/studio/client/tour/overlay.jsx +105 -17
  27. package/apps/studio/client/tour/usage-tour.js +23 -10
  28. package/apps/studio/commands/annotation-strokes-command.ts +1 -1
  29. package/apps/studio/commands/move-artboards-command.ts +1 -1
  30. package/apps/studio/comments-overlay.tsx +7 -5
  31. package/apps/studio/context-menu.tsx +4 -3
  32. package/apps/studio/contextual-toolbar.tsx +14 -0
  33. package/apps/studio/cursors-overlay.tsx +20 -20
  34. package/apps/studio/dist/client.bundle.js +3322 -602
  35. package/apps/studio/dist/comment-mount.js +95 -9
  36. package/apps/studio/dist/styles.css +2490 -167
  37. package/apps/studio/dom-selection.ts +115 -0
  38. package/apps/studio/export-dialog.tsx +3 -3
  39. package/apps/studio/history.ts +20 -3
  40. package/apps/studio/http.ts +124 -5
  41. package/apps/studio/input-router.tsx +7 -0
  42. package/apps/studio/inspect.ts +37 -8
  43. package/apps/studio/participants-chrome.tsx +1 -7
  44. package/apps/studio/server.ts +5 -2
  45. package/apps/studio/sync/agent.ts +231 -57
  46. package/apps/studio/sync/codec.ts +45 -0
  47. package/apps/studio/sync/cold-start.ts +158 -0
  48. package/apps/studio/sync/connection-state.ts +58 -2
  49. package/apps/studio/sync/index.ts +563 -221
  50. package/apps/studio/sync/journal.ts +190 -0
  51. package/apps/studio/sync/migrate-seed.ts +202 -44
  52. package/apps/studio/sync/projection.ts +22 -3
  53. package/apps/studio/sync/status.ts +15 -4
  54. package/apps/studio/test/activity.test.ts +1 -7
  55. package/apps/studio/test/annotate-write.test.ts +184 -0
  56. package/apps/studio/test/annotations-align.test.ts +88 -0
  57. package/apps/studio/test/annotations-bindings.test.ts +124 -0
  58. package/apps/studio/test/annotations-groups.test.ts +231 -0
  59. package/apps/studio/test/annotations-snap.test.ts +79 -0
  60. package/apps/studio/test/canvas-edit.test.ts +181 -1
  61. package/apps/studio/test/canvas-origin-gate.test.ts +8 -0
  62. package/apps/studio/test/collab-bridge.test.ts +0 -2
  63. package/apps/studio/test/collab-room.test.ts +2 -7
  64. package/apps/studio/test/csp-canvas-shell.test.ts +5 -0
  65. package/apps/studio/test/csrf-write-guard.test.ts +52 -0
  66. package/apps/studio/test/figjam-v3-model.test.ts +342 -0
  67. package/apps/studio/test/fixtures/figjam-v3-groups-bindings.svg +1 -0
  68. package/apps/studio/test/shared-doc-migrate.test.ts +224 -14
  69. package/apps/studio/test/sync-agent.test.ts +272 -2
  70. package/apps/studio/test/sync-codec.test.ts +65 -0
  71. package/apps/studio/test/sync-cold-start.test.ts +244 -0
  72. package/apps/studio/test/sync-connection-state.test.ts +68 -0
  73. package/apps/studio/test/sync-hardening.test.ts +0 -8
  74. package/apps/studio/test/sync-hubs-config.test.ts +0 -1
  75. package/apps/studio/test/sync-incident-replay.test.ts +211 -0
  76. package/apps/studio/test/sync-journal.test.ts +176 -0
  77. package/apps/studio/test/sync-runtime.test.ts +360 -5
  78. package/apps/studio/test/sync-status.test.ts +66 -0
  79. package/apps/studio/test/use-annotation-resize.test.ts +159 -0
  80. package/apps/studio/test/use-artboard-drag.test.ts +0 -1
  81. package/apps/studio/test/use-collab.test.ts +0 -0
  82. package/apps/studio/test/use-tool-mode.test.tsx +5 -2
  83. package/apps/studio/tool-palette.tsx +12 -2
  84. package/apps/studio/undo-hud.tsx +0 -1
  85. package/apps/studio/use-agent-presence.tsx +4 -2
  86. package/apps/studio/use-annotation-resize.tsx +484 -54
  87. package/apps/studio/use-annotation-selection.tsx +16 -4
  88. package/apps/studio/use-canvas-activity.tsx +2 -2
  89. package/apps/studio/use-collab.tsx +20 -15
  90. package/apps/studio/use-selection-set.tsx +9 -0
  91. package/apps/studio/use-tool-mode.tsx +5 -1
  92. package/apps/studio/whats-new.json +135 -10
  93. package/apps/studio/whats-new.schema.json +18 -2
  94. package/cli/commands/design-link.test.mjs +84 -0
  95. package/cli/commands/design.mjs +2 -1
  96. package/cli/commands/design.test.mjs +5 -1
  97. package/cli/lib/design-link.mjs +51 -1
  98. package/package.json +8 -8
  99. package/plugins/design/templates/_shell.html +28 -4
@@ -0,0 +1,79 @@
1
+ // annotations-snap — FigJam v3 drag snapping + smart guides (pure, DOM-free).
2
+
3
+ import { describe, expect, test } from 'bun:test';
4
+
5
+ import {
6
+ computeSnap,
7
+ GRID_PITCH_PX,
8
+ SNAP_THRESHOLD_PX,
9
+ type SnapBox,
10
+ } from '../annotations-snap.ts';
11
+
12
+ const box = (x: number, y: number, w = 100, h = 100): SnapBox => ({ x, y, w, h });
13
+
14
+ describe('computeSnap', () => {
15
+ test('snaps left edge to a candidate left edge within the threshold', () => {
16
+ const res = computeSnap(box(104, 300), [box(100, 0)], SNAP_THRESHOLD_PX);
17
+ expect(res.dx).toBe(-4);
18
+ expect(res.dy).toBe(0);
19
+ expect(res.guides).toHaveLength(1);
20
+ const guide = res.guides[0];
21
+ expect(guide?.axis).toBe('x');
22
+ expect(guide?.at).toBe(100);
23
+ // The guide spans from the candidate to the (snapped) moving box.
24
+ expect(guide?.from).toBe(0);
25
+ expect(guide?.to).toBe(400);
26
+ });
27
+
28
+ test('centers participate — center-to-center snap', () => {
29
+ // moving center x = 153, candidate center x = 150 → dx -3.
30
+ const res = computeSnap(box(103, 0), [box(100, 300)], SNAP_THRESHOLD_PX);
31
+ expect(res.dx).toBe(-3);
32
+ });
33
+
34
+ test('axes resolve independently against different candidates', () => {
35
+ const res = computeSnap(box(104, 203), [box(100, 500), box(500, 200)], SNAP_THRESHOLD_PX);
36
+ expect(res.dx).toBe(-4); // left edge → first candidate
37
+ expect(res.dy).toBe(-3); // top edge → second candidate
38
+ expect(res.guides).toHaveLength(2);
39
+ });
40
+
41
+ test('nearest line wins; outside the threshold = no snap', () => {
42
+ const res = computeSnap(box(104, 0), [box(100, 0), box(105, 0)], SNAP_THRESHOLD_PX);
43
+ expect(res.dx).toBe(1); // 105 is 1 away vs 100 being 4 away
44
+ const none = computeSnap(box(120, 0), [box(100, 300)], SNAP_THRESHOLD_PX);
45
+ expect(none.dx).toBe(0);
46
+ expect(none.guides).toHaveLength(0);
47
+ });
48
+
49
+ test('empty candidates / zero threshold → no snap', () => {
50
+ expect(computeSnap(box(0, 0), [], SNAP_THRESHOLD_PX).guides).toHaveLength(0);
51
+ expect(computeSnap(box(101, 0), [box(100, 0)], 0).dx).toBe(0);
52
+ });
53
+
54
+ test('grid fallback — leading edge snaps to the 24px lattice with no candidates', () => {
55
+ // x=98 → nearest grid line 96 (4·24), 2 away; y=50 → nearest 48, 2 away.
56
+ const res = computeSnap(box(98, 50), [], SNAP_THRESHOLD_PX, { grid: GRID_PITCH_PX });
57
+ expect(res.dx).toBe(-2);
58
+ expect(res.dy).toBe(-2);
59
+ // Grid snap is silent — the dots themselves are the visual, no guide lines.
60
+ expect(res.guides).toHaveLength(0);
61
+ });
62
+
63
+ test('grid fallback is per-axis — a smart-guide axis keeps its candidate snap', () => {
64
+ // x snaps to the candidate edge (dx -4 → x=100, NOT the 96 grid line);
65
+ // y has no candidate within reach → grid line 48 wins (dy -2).
66
+ const res = computeSnap(box(104, 50), [box(100, 500)], SNAP_THRESHOLD_PX, {
67
+ grid: GRID_PITCH_PX,
68
+ });
69
+ expect(res.dx).toBe(-4);
70
+ expect(res.dy).toBe(-2);
71
+ });
72
+
73
+ test('grid outside the threshold → no correction', () => {
74
+ // x=84 sits 12 from both 72 and 96 — beyond the 6px threshold.
75
+ const res = computeSnap(box(84, 84), [], SNAP_THRESHOLD_PX, { grid: GRID_PITCH_PX });
76
+ expect(res.dx).toBe(0);
77
+ expect(res.dy).toBe(0);
78
+ });
79
+ });
@@ -5,7 +5,13 @@
5
5
 
6
6
  import { describe, expect, test } from 'bun:test';
7
7
 
8
- import { applyEdit, CanvasEditError, editAttribute } from '../canvas-edit.ts';
8
+ import {
9
+ applyEdit,
10
+ applyRemove,
11
+ applyTextEdit,
12
+ CanvasEditError,
13
+ editAttribute,
14
+ } from '../canvas-edit.ts';
9
15
  import { transpileCanvasSource } from '../canvas-pipeline.ts';
10
16
 
11
17
  const CANVAS = '/abs/Canvas.tsx';
@@ -52,6 +58,42 @@ describe('canvas-edit / applyEdit', () => {
52
58
  expect(out.source).toBe(`function Demo() { return <button aria-label="new">x</button>; }`);
53
59
  });
54
60
 
61
+ test('JSX-attribute-escapes a " in a replaced literal value (no JSON \\" corruption)', () => {
62
+ // Regression: the replace branch used to emit JSON.stringify(value) → `\"`,
63
+ // which is invalid JS-escaping inside a JSX attribute and corrupts the source.
64
+ // It must land as the HTML entity &quot; instead. See DDR-105.
65
+ const src = `function Demo() { return <button aria-label="old">x</button>; }`;
66
+ const ids = idsOf(src);
67
+ const id = ids.button as string;
68
+ const out = applyEdit(CANVAS, src, id, 'aria-label', 'say "hi" <b>');
69
+ expect(out.source).toBe(
70
+ `function Demo() { return <button aria-label="say &quot;hi&quot; &lt;b&gt;">x</button>; }`
71
+ );
72
+ // Never the JS-escaped form that broke things.
73
+ expect(out.source).not.toContain('\\"');
74
+ });
75
+
76
+ test('JSX-attribute-escapes a " in a newly inserted attribute value', () => {
77
+ const src = 'function Demo() { return <div>x</div>; }';
78
+ const ids = idsOf(src);
79
+ const id = ids.div as string;
80
+ const out = applyEdit(CANVAS, src, id, 'title', 'a "quoted" title');
81
+ expect(out.source).toBe(
82
+ `function Demo() { return <div title="a &quot;quoted&quot; title">x</div>; }`
83
+ );
84
+ });
85
+
86
+ test('replaces an expression-container attr value with a JSX-escaped literal', () => {
87
+ // <Tag name={expr} /> → <Tag name="value" />, entity-escaped (not JSON-escaped).
88
+ const src = `function Demo() { return <button aria-label={lbl}>x</button>; }`;
89
+ const ids = idsOf(src);
90
+ const id = ids.button as string;
91
+ const out = applyEdit(CANVAS, src, id, 'aria-label', 'with "quote"');
92
+ expect(out.source).toBe(
93
+ `function Demo() { return <button aria-label="with &quot;quote&quot;">x</button>; }`
94
+ );
95
+ });
96
+
55
97
  test('swaps a single style.<prop> value inside style={{...}}', () => {
56
98
  const src = 'function Demo() { return <div style={{ padding: 8, margin: 4 }}>x</div>; }';
57
99
  const ids = idsOf(src);
@@ -119,6 +161,62 @@ describe('canvas-edit / applyEdit', () => {
119
161
  });
120
162
  });
121
163
 
164
+ describe('canvas-edit / applyRemove (reset)', () => {
165
+ test('removes one style key, leaving the siblings + object intact', () => {
166
+ const src = 'function Demo() { return <div style={{ padding: 8, margin: 4 }}>x</div>; }';
167
+ const ids = idsOf(src);
168
+ const out = applyRemove(CANVAS, src, ids.div as string, 'style.margin');
169
+ expect(out.source).toBe('function Demo() { return <div style={{ padding: 8 }}>x</div>; }');
170
+ });
171
+
172
+ test('removes the leading style key (consumes the trailing comma)', () => {
173
+ const src = 'function Demo() { return <div style={{ padding: 8, margin: 4 }}>x</div>; }';
174
+ const ids = idsOf(src);
175
+ const out = applyRemove(CANVAS, src, ids.div as string, 'style.padding');
176
+ expect(out.source).toBe('function Demo() { return <div style={{ margin: 4 }}>x</div>; }');
177
+ });
178
+
179
+ test('removing the only style key drops the whole style attribute', () => {
180
+ const src = 'function Demo() { return <div style={{ padding: 8 }}>x</div>; }';
181
+ const ids = idsOf(src);
182
+ const out = applyRemove(CANVAS, src, ids.div as string, 'style.padding');
183
+ expect(out.source).toBe('function Demo() { return <div>x</div>; }');
184
+ });
185
+
186
+ test('accepts a kebab style prop name (border-radius → borderRadius)', () => {
187
+ const src = 'function Demo() { return <div style={{ borderRadius: 8, padding: 4 }}>x</div>; }';
188
+ const ids = idsOf(src);
189
+ const out = applyRemove(CANVAS, src, ids.div as string, 'style.border-radius');
190
+ expect(out.source).toBe('function Demo() { return <div style={{ padding: 4 }}>x</div>; }');
191
+ });
192
+
193
+ test('removes a plain attribute (custom data-*)', () => {
194
+ const src = 'function Demo() { return <button data-state="active">x</button>; }';
195
+ const ids = idsOf(src);
196
+ const out = applyRemove(CANVAS, src, ids.button as string, 'data-state');
197
+ expect(out.source).toBe('function Demo() { return <button>x</button>; }');
198
+ });
199
+
200
+ test('is a no-op (delta 0) when the key / attribute is absent', () => {
201
+ const src = 'function Demo() { return <div style={{ padding: 8 }}>x</div>; }';
202
+ const ids = idsOf(src);
203
+ const a = applyRemove(CANVAS, src, ids.div as string, 'style.margin');
204
+ expect(a.source).toBe(src);
205
+ expect(a.delta).toBe(0);
206
+ const b = applyRemove(CANVAS, src, ids.div as string, 'data-nope');
207
+ expect(b.source).toBe(src);
208
+ expect(b.delta).toBe(0);
209
+ });
210
+
211
+ test('refuses to remove data-cd-id (pipeline-owned)', () => {
212
+ const src = 'function Demo() { return <div>x</div>; }';
213
+ const ids = idsOf(src);
214
+ expect(() => applyRemove(CANVAS, src, ids.div as string, 'data-cd-id')).toThrow(
215
+ CanvasEditError
216
+ );
217
+ });
218
+ });
219
+
122
220
  describe('canvas-edit / editAttribute (fs)', () => {
123
221
  test('writes the edit atomically + is a no-op when source unchanged', async () => {
124
222
  const tmp = `/tmp/canvas-edit-${Math.random().toString(36).slice(2)}.tsx`;
@@ -137,3 +235,85 @@ describe('canvas-edit / editAttribute (fs)', () => {
137
235
  expect(r2.delta).toBe(0);
138
236
  });
139
237
  });
238
+
239
+ // Phase 12 (DDR-103) — inline text-content edit. Leaf-text only; JSX-escaped.
240
+ describe('canvas-edit / applyTextEdit', () => {
241
+ test('overwrites a leaf text node', () => {
242
+ const src = `function Demo() { return <button>Save</button>; }`;
243
+ const id = idsOf(src).button as string;
244
+ const out = applyTextEdit(CANVAS, src, id, 'Uložit');
245
+ expect(out.source).toBe(`function Demo() { return <button>Uložit</button>; }`);
246
+ });
247
+
248
+ test('preserves surrounding whitespace / indentation', () => {
249
+ const src = [
250
+ 'function Demo() {',
251
+ ' return (',
252
+ ' <button>',
253
+ ' Save',
254
+ ' </button>',
255
+ ' );',
256
+ '}',
257
+ ].join('\n');
258
+ const id = idsOf(src).button as string;
259
+ const out = applyTextEdit(CANVAS, src, id, 'Go');
260
+ expect(out.source).toContain(' <button>\n Go\n </button>');
261
+ });
262
+
263
+ test('JSX-escapes <, >, {, }, & so text can never become markup or an expression', () => {
264
+ const src = `function Demo() { return <code>x</code>; }`;
265
+ const id = idsOf(src).code as string;
266
+ const out = applyTextEdit(CANVAS, src, id, 'a < b && c > {d}');
267
+ expect(out.source).toContain('a &lt; b &amp;&amp; c &gt; &#123;d&#125;');
268
+ expect(out.source).not.toContain('{d}');
269
+ });
270
+
271
+ test('refuses an element with mixed (element) children', () => {
272
+ const src = `function Demo() { return <div>hi <b>there</b></div>; }`;
273
+ const id = idsOf(src).div as string;
274
+ expect(() => applyTextEdit(CANVAS, src, id, 'x')).toThrow(CanvasEditError);
275
+ });
276
+
277
+ test('refuses an element whose only child is a JSX expression', () => {
278
+ const src = 'function Demo() { const t = "x"; return <h1>{t}</h1>; }';
279
+ const id = idsOf(src).h1 as string;
280
+ expect(() => applyTextEdit(CANVAS, src, id, 'x')).toThrow(CanvasEditError);
281
+ });
282
+
283
+ test('refuses a self-closing / empty element (no text to edit)', () => {
284
+ const src = `function Demo() { return <div className="x" />; }`;
285
+ const id = idsOf(src).div as string;
286
+ expect(() => applyTextEdit(CANVAS, src, id, 'x')).toThrow(CanvasEditError);
287
+ });
288
+
289
+ test('throws on a missing id', () => {
290
+ const src = `function Demo() { return <button>Save</button>; }`;
291
+ expect(() => applyTextEdit(CANVAS, src, 'deadbeef', 'x')).toThrow(CanvasEditError);
292
+ });
293
+ });
294
+
295
+ // Phase 12 (DDR-103) — the `style.<prop>` write path the CSS knobs ride
296
+ // (api.editCss maps property → camelCase + always quotes the value).
297
+ describe('canvas-edit / applyEdit style.<prop> (CSS-knob path)', () => {
298
+ test('inserts an inline style object when none exists', () => {
299
+ const src = `function Demo() { return <div>x</div>; }`;
300
+ const id = idsOf(src).div as string;
301
+ const out = applyEdit(CANVAS, src, id, 'style.borderRadius', '"8px"');
302
+ expect(out.source).toContain('style={{ borderRadius: "8px" }}');
303
+ });
304
+
305
+ test('overwrites an existing style key, leaving siblings intact', () => {
306
+ const src = `function Demo() { return <div style={{ padding: 4, color: "red" }}>x</div>; }`;
307
+ const id = idsOf(src).div as string;
308
+ const out = applyEdit(CANVAS, src, id, 'style.color', '"blue"');
309
+ expect(out.source).toContain('color: "blue"');
310
+ expect(out.source).toContain('padding: 4');
311
+ });
312
+
313
+ test('accepts a token-valued (var(--…)) string for on-system edits', () => {
314
+ const src = `function Demo() { return <div>x</div>; }`;
315
+ const id = idsOf(src).div as string;
316
+ const out = applyEdit(CANVAS, src, id, 'style.padding', '"var(--space-3)"');
317
+ expect(out.source).toContain('style={{ padding: "var(--space-3)" }}');
318
+ });
319
+ });
@@ -54,11 +54,19 @@ describe('canvas-origin gate — A1/A2 traversal + privilege containment', () =>
54
54
  // /_api/canvas (Phase 22 file-write) is deliberately NOT in the canvas
55
55
  // origin's route allowlist — an untrusted canvas iframe must never create
56
56
  // arbitrary .tsx files. It is reachable ONLY from the main origin.
57
+ // Phase 12 (DDR-103) — the in-canvas direct-edit write routes are
58
+ // MAIN-ORIGIN ONLY: absent from CANVAS_SAFE_API + startCanvasServer's
59
+ // route map, so the untrusted canvas iframe origin must never reach a
60
+ // source-write endpoint. A GET here 403s at the gate (not 405 from the
61
+ // handler), proving the route is unreachable on this origin.
57
62
  for (const p of [
58
63
  '/_config',
59
64
  '/_sync-status',
60
65
  '/_api/export',
61
66
  '/_api/canvas',
67
+ '/_api/edit-css',
68
+ '/_api/edit-text',
69
+ '/_api/edit-attr',
62
70
  '/package.json',
63
71
  ]) {
64
72
  expect(await code(p)).toBe(403);
@@ -4,8 +4,6 @@
4
4
 
5
5
  import { describe, expect, test } from 'bun:test';
6
6
 
7
- import * as Y from 'yjs';
8
-
9
7
  import { Y_TYPES } from '../collab/persistence.ts';
10
8
  import { createRegistry } from '../collab/registry.ts';
11
9
  import type { RoomCallbacks } from '../collab/room.ts';
@@ -1,17 +1,12 @@
1
1
  // Unit: Room behavior — two in-memory peers see each other, debounced flush
2
2
  // fires, flush is idempotent, room teardown is clean.
3
3
 
4
- import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
4
+ import { describe, expect, test } from 'bun:test';
5
5
 
6
6
  import { Awareness } from 'y-protocols/awareness';
7
7
  import * as Y from 'yjs';
8
8
 
9
- import {
10
- encodeAwarenessFrame,
11
- encodeHandshake,
12
- encodeSyncUpdate,
13
- handleMessage,
14
- } from '../collab/protocol.ts';
9
+ import { encodeAwarenessFrame, handleMessage } from '../collab/protocol.ts';
15
10
  import { createRoom, type RoomCallbacks, type RoomConn } from '../collab/room.ts';
16
11
 
17
12
  function makeConn(id: string): RoomConn & { recv: Uint8Array[] } {
@@ -39,6 +39,11 @@ describe('cspForCanvasShell — A6 hardening', () => {
39
39
  expect(csp).toContain("frame-ancestors 'self' http://localhost:4399");
40
40
  });
41
41
 
42
+ test('frame-ancestors carries every advertised loopback spelling (localhost + 127.0.0.1)', () => {
43
+ const csp = cspForCanvasShell(SHELL, 'http://localhost:4399 http://127.0.0.1:4399');
44
+ expect(csp).toContain("frame-ancestors 'self' http://localhost:4399 http://127.0.0.1:4399");
45
+ });
46
+
42
47
  test('frame-ancestors is OMITTED when the main origin is unknown (would block the legit embed)', () => {
43
48
  const csp = cspForCanvasShell(SHELL);
44
49
  expect(csp).not.toContain('frame-ancestors');
@@ -0,0 +1,52 @@
1
+ // CSRF Origin guard for the main-origin source-write routes (edit-css /
2
+ // edit-text / edit-attr). DDR-105. The DDR-054 origin-split already blocks the
3
+ // untrusted canvas *iframe* (canvas-origin-gate.test.ts proves that). This guard
4
+ // covers the OTHER untrusted origin: a malicious top-level page in another tab
5
+ // forging a `text/plain` CORS simple-request POST to localhost. The browser
6
+ // stamps such a request with an unspoofable cross-origin `Origin` header, so the
7
+ // guard must reject it — while still letting the legit same-origin shell write
8
+ // and not breaking non-browser clients (which send no Origin header).
9
+ //
10
+ // Unit-level on purpose: `sameOriginWrite` is a pure function of the Request, so
11
+ // we exercise the decision directly instead of booting a subprocess server.
12
+ // (A booted-server integration test would add a 124th server-booting file to the
13
+ // suite, which perturbs bun's worker scheduling and surfaces a PRE-EXISTING
14
+ // cross-worker port-collision flake in canvas-meta-api.test.ts — a test-harness
15
+ // race unrelated to this guard. See the Phase 12.2 validate notes.)
16
+
17
+ import { describe, expect, test } from 'bun:test';
18
+
19
+ import { sameOriginWrite } from '../http.ts';
20
+
21
+ const SELF = 'http://localhost:4399';
22
+ const post = (origin?: string): Request =>
23
+ new Request(`${SELF}/_api/edit-css`, {
24
+ method: 'POST',
25
+ headers: { 'content-type': 'text/plain', ...(origin ? { origin } : {}) },
26
+ body: '{}',
27
+ });
28
+
29
+ describe('CSRF Origin guard — sameOriginWrite (DDR-105)', () => {
30
+ test('rejects a forged cross-origin Origin', () => {
31
+ expect(sameOriginWrite(post('http://evil.example'))).toBe(false);
32
+ // A look-alike host (substring / suffix tricks) must not slip through.
33
+ expect(sameOriginWrite(post('http://localhost.evil.example'))).toBe(false);
34
+ expect(sameOriginWrite(post('http://localhost:4399.evil.example'))).toBe(false);
35
+ // Right host, wrong port is still cross-origin.
36
+ expect(sameOriginWrite(post('http://localhost:4400'))).toBe(false);
37
+ // Right host:port, wrong scheme is still cross-origin.
38
+ expect(sameOriginWrite(post('https://localhost:4399'))).toBe(false);
39
+ });
40
+
41
+ test('allows the legit same-origin shell Origin', () => {
42
+ expect(sameOriginWrite(post(SELF))).toBe(true);
43
+ });
44
+
45
+ test('allows a request with no Origin header (curl / programmatic / bun:test)', () => {
46
+ expect(sameOriginWrite(post())).toBe(true);
47
+ });
48
+
49
+ test('rejects an unparseable Origin rather than letting it through', () => {
50
+ expect(sameOriginWrite(post('not a url'))).toBe(false);
51
+ });
52
+ });