@1agh/maude 0.29.0 → 0.31.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 (162) hide show
  1. package/README.md +6 -6
  2. package/apps/studio/acp/bridge.ts +285 -0
  3. package/apps/studio/acp/env.ts +48 -0
  4. package/apps/studio/acp/index.ts +132 -0
  5. package/apps/studio/acp/probe.ts +112 -0
  6. package/apps/studio/acp/transcript.ts +149 -0
  7. package/apps/studio/ai-banner.tsx +0 -1
  8. package/apps/studio/annotations-align.ts +149 -0
  9. package/apps/studio/annotations-bindings.ts +197 -0
  10. package/apps/studio/annotations-context-toolbar.tsx +436 -186
  11. package/apps/studio/annotations-groups.ts +270 -0
  12. package/apps/studio/annotations-layer.tsx +1714 -1661
  13. package/apps/studio/annotations-model.ts +2077 -0
  14. package/apps/studio/annotations-snap.ts +125 -0
  15. package/apps/studio/api.ts +433 -182
  16. package/apps/studio/bin/_png-playwright.mjs +1 -1
  17. package/apps/studio/bin/annotate.mjs +732 -0
  18. package/apps/studio/bin/annotate.sh +17 -0
  19. package/apps/studio/bin/chat-open.sh +44 -0
  20. package/apps/studio/bin/read-annotations.mjs +152 -17
  21. package/apps/studio/build.ts +1 -1
  22. package/apps/studio/canvas-arrowheads.ts +78 -9
  23. package/apps/studio/canvas-cursors.ts +2 -0
  24. package/apps/studio/canvas-edit.ts +257 -7
  25. package/apps/studio/canvas-icons.tsx +105 -0
  26. package/apps/studio/canvas-lib.tsx +112 -19
  27. package/apps/studio/canvas-list-watch.ts +177 -0
  28. package/apps/studio/canvas-shell.tsx +326 -9
  29. package/apps/studio/client/app.jsx +3579 -250
  30. package/apps/studio/client/canvas-url.js +5 -0
  31. package/apps/studio/client/github.js +99 -0
  32. package/apps/studio/client/index.html +1 -1
  33. package/apps/studio/client/panels/ChatPanel.jsx +770 -0
  34. package/apps/studio/client/panels/CollabModelInfographic.jsx +71 -0
  35. package/apps/studio/client/panels/CreateProject.jsx +334 -0
  36. package/apps/studio/client/panels/DiffView.jsx +590 -0
  37. package/apps/studio/client/panels/GitPanel.jsx +767 -0
  38. package/apps/studio/client/panels/IdentityBar.jsx +294 -0
  39. package/apps/studio/client/panels/OnboardingWizard.jsx +563 -0
  40. package/apps/studio/client/panels/RepoBranchSwitcher.jsx +349 -0
  41. package/apps/studio/client/panels/acp-runtime.js +286 -0
  42. package/apps/studio/client/panels/chat-markdown.jsx +138 -0
  43. package/apps/studio/client/panels/git-grouping.js +86 -0
  44. package/apps/studio/client/styles/0-reset.css +4 -0
  45. package/apps/studio/client/styles/3-shell-maude.css +1326 -14
  46. package/apps/studio/client/styles/4-components.css +39 -0
  47. package/apps/studio/client/styles/5-maude-overrides.css +41 -2
  48. package/apps/studio/client/styles/6-acp-chat.css +771 -0
  49. package/apps/studio/client/styles/_index.css +2 -0
  50. package/apps/studio/client/tour/collab-tour.js +61 -0
  51. package/apps/studio/client/tour/overlay.jsx +116 -18
  52. package/apps/studio/client/tour/usage-tour.js +23 -10
  53. package/apps/studio/client/whats-new.jsx +25 -10
  54. package/apps/studio/collab/registry.ts +13 -0
  55. package/apps/studio/collab/room.ts +36 -0
  56. package/apps/studio/commands/annotation-strokes-command.ts +1 -1
  57. package/apps/studio/commands/move-artboards-command.ts +1 -1
  58. package/apps/studio/comments-overlay.tsx +7 -5
  59. package/apps/studio/context-menu.tsx +4 -3
  60. package/apps/studio/contextual-toolbar.tsx +14 -0
  61. package/apps/studio/cursors-overlay.tsx +37 -21
  62. package/apps/studio/dist/client.bundle.js +31795 -1829
  63. package/apps/studio/dist/comment-mount.js +97 -9
  64. package/apps/studio/dist/styles.css +7938 -1599
  65. package/apps/studio/dom-selection.ts +115 -0
  66. package/apps/studio/export-dialog.tsx +3 -3
  67. package/apps/studio/git/endpoints.ts +338 -0
  68. package/apps/studio/git/service.ts +1334 -0
  69. package/apps/studio/git/watch.ts +97 -0
  70. package/apps/studio/github/endpoints.ts +358 -0
  71. package/apps/studio/github/service.ts +231 -0
  72. package/apps/studio/github/token.ts +53 -0
  73. package/apps/studio/history.ts +20 -3
  74. package/apps/studio/hmr-broadcast.ts +9 -2
  75. package/apps/studio/http.ts +508 -6
  76. package/apps/studio/input-router.tsx +7 -0
  77. package/apps/studio/inspect.ts +37 -8
  78. package/apps/studio/participants-chrome.tsx +70 -16
  79. package/apps/studio/paths.ts +12 -0
  80. package/apps/studio/scaffold-design.ts +57 -0
  81. package/apps/studio/server.ts +70 -4
  82. package/apps/studio/sync/agent.ts +311 -57
  83. package/apps/studio/sync/codec.ts +69 -0
  84. package/apps/studio/sync/cold-start.ts +198 -0
  85. package/apps/studio/sync/connection-state.ts +58 -2
  86. package/apps/studio/sync/hub-link.ts +137 -0
  87. package/apps/studio/sync/index.ts +563 -221
  88. package/apps/studio/sync/journal.ts +190 -0
  89. package/apps/studio/sync/migrate-seed.ts +202 -44
  90. package/apps/studio/sync/projection.ts +22 -3
  91. package/apps/studio/sync/status.ts +15 -4
  92. package/apps/studio/test/acp-bridge.test.ts +127 -0
  93. package/apps/studio/test/acp-env.test.ts +65 -0
  94. package/apps/studio/test/acp-origin-gate.test.ts +78 -0
  95. package/apps/studio/test/acp-transcript.test.ts +112 -0
  96. package/apps/studio/test/activity.test.ts +1 -7
  97. package/apps/studio/test/annotate-write.test.ts +184 -0
  98. package/apps/studio/test/annotations-align.test.ts +88 -0
  99. package/apps/studio/test/annotations-bindings.test.ts +124 -0
  100. package/apps/studio/test/annotations-groups.test.ts +231 -0
  101. package/apps/studio/test/annotations-snap.test.ts +79 -0
  102. package/apps/studio/test/canvas-create-api.test.ts +72 -0
  103. package/apps/studio/test/canvas-edit.test.ts +181 -1
  104. package/apps/studio/test/canvas-list-watch.test.ts +322 -0
  105. package/apps/studio/test/canvas-meta-api.test.ts +161 -27
  106. package/apps/studio/test/canvas-origin-gate.test.ts +43 -0
  107. package/apps/studio/test/chat-markdown.test.tsx +58 -0
  108. package/apps/studio/test/collab-bridge.test.ts +0 -2
  109. package/apps/studio/test/collab-room.test.ts +2 -7
  110. package/apps/studio/test/collab-session-survival.test.tsx +176 -0
  111. package/apps/studio/test/csp-canvas-shell.test.ts +5 -0
  112. package/apps/studio/test/csrf-write-guard.test.ts +78 -0
  113. package/apps/studio/test/editing-presence.test.ts +103 -0
  114. package/apps/studio/test/figjam-v3-model.test.ts +342 -0
  115. package/apps/studio/test/fixtures/figjam-v3-groups-bindings.svg +1 -0
  116. package/apps/studio/test/fixtures/mock-acp-agent.mjs +45 -0
  117. package/apps/studio/test/git-api.test.ts +0 -0
  118. package/apps/studio/test/git-branches.test.ts +106 -0
  119. package/apps/studio/test/git-grouping.test.ts +106 -0
  120. package/apps/studio/test/git-watch.test.ts +97 -0
  121. package/apps/studio/test/github-api.test.ts +465 -0
  122. package/apps/studio/test/hub-link.test.ts +69 -0
  123. package/apps/studio/test/participants-chrome.test.ts +36 -1
  124. package/apps/studio/test/shared-doc-migrate.test.ts +224 -14
  125. package/apps/studio/test/sync-agent.test.ts +272 -2
  126. package/apps/studio/test/sync-codec.test.ts +65 -0
  127. package/apps/studio/test/sync-cold-seed-dedup.test.ts +187 -0
  128. package/apps/studio/test/sync-cold-start.test.ts +304 -0
  129. package/apps/studio/test/sync-connection-state.test.ts +68 -0
  130. package/apps/studio/test/sync-hardening.test.ts +0 -8
  131. package/apps/studio/test/sync-hubs-config.test.ts +0 -1
  132. package/apps/studio/test/sync-incident-replay.test.ts +211 -0
  133. package/apps/studio/test/sync-journal.test.ts +176 -0
  134. package/apps/studio/test/sync-runtime.test.ts +360 -5
  135. package/apps/studio/test/sync-status.test.ts +66 -0
  136. package/apps/studio/test/tour-overlay.test.tsx +18 -0
  137. package/apps/studio/test/use-annotation-resize.test.ts +159 -0
  138. package/apps/studio/test/use-artboard-drag.test.ts +0 -1
  139. package/apps/studio/test/use-collab.test.ts +0 -0
  140. package/apps/studio/test/use-tool-mode.test.tsx +5 -2
  141. package/apps/studio/tool-palette.tsx +30 -11
  142. package/apps/studio/undo-hud.tsx +0 -1
  143. package/apps/studio/use-agent-presence.tsx +4 -2
  144. package/apps/studio/use-annotation-resize.tsx +484 -54
  145. package/apps/studio/use-annotation-selection.tsx +16 -4
  146. package/apps/studio/use-canvas-activity.tsx +2 -2
  147. package/apps/studio/use-chrome-visibility.tsx +66 -0
  148. package/apps/studio/use-collab.tsx +434 -202
  149. package/apps/studio/use-selection-set.tsx +9 -0
  150. package/apps/studio/use-tool-mode.tsx +5 -1
  151. package/apps/studio/whats-new.json +208 -10
  152. package/apps/studio/whats-new.schema.json +18 -2
  153. package/apps/studio/ws.ts +44 -1
  154. package/cli/commands/design-link.test.mjs +84 -0
  155. package/cli/commands/design.mjs +3 -1
  156. package/cli/commands/design.test.mjs +5 -1
  157. package/cli/lib/design-link.mjs +51 -1
  158. package/cli/lib/gitignore-block.mjs +16 -3
  159. package/cli/lib/gitignore-block.test.mjs +13 -1
  160. package/package.json +11 -9
  161. package/plugins/design/dependencies.json +17 -0
  162. package/plugins/design/templates/_shell.html +58 -12
@@ -0,0 +1,78 @@
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
+ import { readFileSync } from 'node:fs';
19
+ import { fileURLToPath } from 'node:url';
20
+
21
+ import { sameOriginWrite } from '../http.ts';
22
+
23
+ const SELF = 'http://localhost:4399';
24
+ const post = (origin?: string): Request =>
25
+ new Request(`${SELF}/_api/edit-css`, {
26
+ method: 'POST',
27
+ headers: { 'content-type': 'text/plain', ...(origin ? { origin } : {}) },
28
+ body: '{}',
29
+ });
30
+
31
+ describe('CSRF Origin guard — sameOriginWrite (DDR-105)', () => {
32
+ test('rejects a forged cross-origin Origin', () => {
33
+ expect(sameOriginWrite(post('http://evil.example'))).toBe(false);
34
+ // A look-alike host (substring / suffix tricks) must not slip through.
35
+ expect(sameOriginWrite(post('http://localhost.evil.example'))).toBe(false);
36
+ expect(sameOriginWrite(post('http://localhost:4399.evil.example'))).toBe(false);
37
+ // Right host, wrong port is still cross-origin.
38
+ expect(sameOriginWrite(post('http://localhost:4400'))).toBe(false);
39
+ // Right host:port, wrong scheme is still cross-origin.
40
+ expect(sameOriginWrite(post('https://localhost:4399'))).toBe(false);
41
+ });
42
+
43
+ test('allows the legit same-origin shell Origin', () => {
44
+ expect(sameOriginWrite(post(SELF))).toBe(true);
45
+ });
46
+
47
+ test('allows a request with no Origin header (curl / programmatic / bun:test)', () => {
48
+ expect(sameOriginWrite(post())).toBe(true);
49
+ });
50
+
51
+ test('rejects an unparseable Origin rather than letting it through', () => {
52
+ expect(sameOriginWrite(post('not a url'))).toBe(false);
53
+ });
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
+ });
@@ -0,0 +1,103 @@
1
+ // Unit: soft editing-presence (Phase 30) — the `editing` awareness field at the
2
+ // hub trust boundary. Locking is deliberately NOT built (no lease/takeover); the
3
+ // `editing` field is a soft heads-up. Every foreign value is attacker-influenceable
4
+ // through the semi-trusted hub (DDR-054), so it MUST be validated for VALUE — a
5
+ // future / NaN / non-positive `since` is rejected so a hostile peer can't pin a
6
+ // permanent badge or poison a `Date.now() - since` age computation.
7
+
8
+ import { describe, expect, test } from 'bun:test';
9
+
10
+ import { createRoom, type RoomCallbacks } from '../collab/room.ts';
11
+ import { sanitizeForeignState } from '../use-collab.tsx';
12
+
13
+ const noopCallbacks: RoomCallbacks = {
14
+ seed() {},
15
+ persistJson() {},
16
+ persistBinary() {},
17
+ };
18
+
19
+ describe('sanitizeForeignState — editing field (Phase 30)', () => {
20
+ test('keeps a valid current `editing.since`', () => {
21
+ const now = Date.now();
22
+ const peer = sanitizeForeignState(3, { name: 'Anna', editing: { since: now } });
23
+ expect(peer?.editing).toEqual({ since: now });
24
+ });
25
+
26
+ test('absent editing → null (peer is not editing)', () => {
27
+ const peer = sanitizeForeignState(3, { name: 'Anna' });
28
+ expect(peer?.editing).toBeNull();
29
+ });
30
+
31
+ test('rejects a FUTURE since beyond the ±5s skew window (no permanent-badge pin)', () => {
32
+ const future = Date.now() + 60_000;
33
+ const peer = sanitizeForeignState(3, { name: 'Anna', editing: { since: future } });
34
+ expect(peer?.editing).toBeNull();
35
+ });
36
+
37
+ test('allows a near-future since inside the 5s skew window', () => {
38
+ const skew = Date.now() + 3_000;
39
+ const peer = sanitizeForeignState(3, { name: 'Anna', editing: { since: skew } });
40
+ expect(peer?.editing).toEqual({ since: skew });
41
+ });
42
+
43
+ test('rejects NaN / Infinity / non-positive since (no Date-math poison)', () => {
44
+ for (const bad of [Number.NaN, Number.POSITIVE_INFINITY, 0, -1]) {
45
+ const peer = sanitizeForeignState(3, { name: 'Anna', editing: { since: bad } });
46
+ expect(peer?.editing).toBeNull();
47
+ }
48
+ });
49
+
50
+ test('rejects a non-object / malformed editing payload', () => {
51
+ expect(sanitizeForeignState(3, { name: 'Anna', editing: 'now' })?.editing).toBeNull();
52
+ expect(sanitizeForeignState(3, { name: 'Anna', editing: 12345 })?.editing).toBeNull();
53
+ expect(sanitizeForeignState(3, { name: 'Anna', editing: {} })?.editing).toBeNull();
54
+ expect(sanitizeForeignState(3, { name: 'Anna', editing: { since: '5' } })?.editing).toBeNull();
55
+ });
56
+
57
+ test('editing presence does not disturb the other sanitized fields', () => {
58
+ const peer = sanitizeForeignState(9, {
59
+ name: 'Anna',
60
+ cursor: { x: 1, y: 2 },
61
+ editing: { since: Date.now() },
62
+ });
63
+ expect(peer?.name).toBe('Anna');
64
+ expect(peer?.cursor).toEqual({ x: 1, y: 2 });
65
+ expect(peer?.editing).not.toBeNull();
66
+ });
67
+ });
68
+
69
+ describe('room.setAgentEditing — agent presence projected onto awareness (Phase 30)', () => {
70
+ test('projects an editing state that a remote peer can sanitize, then clears it', async () => {
71
+ const room = createRoom('agent-editing-slug', noopCallbacks);
72
+ const since = Date.now();
73
+
74
+ room.setAgentEditing({ name: 'Anna', since });
75
+ const states = Array.from(room.awareness.getStates().values());
76
+ expect(states.length).toBe(1);
77
+ // The projected state survives the client trust-boundary sanitizer with the
78
+ // editing field intact (name → re-derived color; since preserved).
79
+ const sanitized = sanitizeForeignState(1, states[0]);
80
+ expect(sanitized?.name).toBe('Anna');
81
+ expect(sanitized?.editing).toEqual({ since });
82
+
83
+ room.setAgentEditing(null);
84
+ expect(room.awareness.getStates().size).toBe(0);
85
+
86
+ await room.destroy();
87
+ });
88
+
89
+ test('re-setting the same {name, since} is a no-op (heartbeat re-emit guard)', async () => {
90
+ const room = createRoom('agent-editing-idempotent', noopCallbacks);
91
+ const since = Date.now();
92
+ let updates = 0;
93
+ room.awareness.on('update', () => {
94
+ updates += 1;
95
+ });
96
+
97
+ room.setAgentEditing({ name: 'Anna', since });
98
+ room.setAgentEditing({ name: 'Anna', since }); // identical → no second update
99
+ expect(updates).toBe(1);
100
+
101
+ await room.destroy();
102
+ });
103
+ });
@@ -0,0 +1,342 @@
1
+ // FigJam v3 — model schema extension: data-group-ids / data-author /
2
+ // data-start-bind / data-end-bind. Three load-bearing assertions:
3
+ // 1. the new fixture round-trips BYTE-IDENTICAL (parse → re-serialize),
4
+ // 2. legacy strokes (no new fields) serialize WITHOUT any new attribute
5
+ // (the Phase-24 canary holds — covered again here from the model side),
6
+ // 3. the sanitizer passes the new data-* attrs through (it denylists only
7
+ // on*/style/href) and poisoned bind values are rejected on parse.
8
+
9
+ import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
10
+ import { GlobalRegistrator } from '@happy-dom/global-registrator';
11
+
12
+ import {
13
+ type AnchorHost,
14
+ type ArrowStroke,
15
+ convertShapeKind,
16
+ type PolygonStroke,
17
+ type RectStroke,
18
+ type StickyStroke,
19
+ type Stroke,
20
+ shapeKindOf,
21
+ strokeBBox,
22
+ strokeHitTest,
23
+ strokesToSvg,
24
+ svgToStrokes,
25
+ type TextStroke,
26
+ } from '../annotations-model.ts';
27
+ import { sanitizeAnnotationSvg } from '../api.ts';
28
+
29
+ beforeAll(() => {
30
+ GlobalRegistrator.register();
31
+ });
32
+ afterAll(async () => {
33
+ await GlobalRegistrator.unregister();
34
+ });
35
+
36
+ const FIXTURE_URL = new URL('./fixtures/figjam-v3-groups-bindings.svg', import.meta.url);
37
+
38
+ describe('figjam-v3 fixture canary', () => {
39
+ test('round-trips byte-identical (groups + nested groups + binds + author)', async () => {
40
+ const fixture = await Bun.file(FIXTURE_URL).text();
41
+ expect(strokesToSvg(svgToStrokes(fixture))).toBe(fixture);
42
+ });
43
+
44
+ test('round-trips byte-identical THROUGH the sanitizer', async () => {
45
+ const fixture = await Bun.file(FIXTURE_URL).text();
46
+ expect(sanitizeAnnotationSvg(fixture)).toBe(fixture);
47
+ });
48
+
49
+ test('parse recovers the new fields exactly', async () => {
50
+ const strokes = svgToStrokes(await Bun.file(FIXTURE_URL).text());
51
+ const rect = strokes.find((s) => s.id === 's_aaa1') as RectStroke;
52
+ expect(rect.groupIds).toEqual(['g_team1']);
53
+ const sticky = strokes.find((s) => s.id === 's_aaa2') as StickyStroke;
54
+ expect(sticky.groupIds).toEqual(['g_inner', 'g_team1']); // deepest→shallowest order kept
55
+ const arrow = strokes.find((s) => s.id === 's_aaa3') as ArrowStroke;
56
+ expect(arrow.startBind).toEqual({ hostId: 's_aaa1', nx: 1, ny: 0.5 }); // auto
57
+ expect(arrow.endBind).toEqual({ hostId: 's_aaa2', nx: 0, ny: 0.5, pinned: true });
58
+ const note = strokes.find((s) => s.id === 's_aaa4') as TextStroke;
59
+ expect(note.author).toBe('ai');
60
+ // The ellipse carries rotation but none of the other new fields.
61
+ const ell = strokes.find((s) => s.id === 's_aaa5') as Stroke;
62
+ expect(ell.rotation).toBe(15);
63
+ expect('groupIds' in ell).toBe(false);
64
+ expect('author' in ell).toBe(false);
65
+ });
66
+ });
67
+
68
+ describe('serialize-only-non-default invariant for the v3 fields', () => {
69
+ test('a legacy stroke set emits NO v3 attribute', () => {
70
+ const strokes: Stroke[] = [
71
+ { id: 's_1', tool: 'rect', color: '#1f1f1f', width: 3, x: 0, y: 0, w: 10, h: 10 },
72
+ { id: 's_2', tool: 'arrow', color: '#1f1f1f', width: 3, x1: 0, y1: 0, x2: 9, y2: 0 },
73
+ ];
74
+ const svg = strokesToSvg(strokes);
75
+ expect(svg).not.toContain('data-group-ids');
76
+ expect(svg).not.toContain('data-author');
77
+ expect(svg).not.toContain('data-start-bind');
78
+ expect(svg).not.toContain('data-end-bind');
79
+ });
80
+
81
+ test('empty groupIds array serializes as no attribute', () => {
82
+ const svg = strokesToSvg([
83
+ {
84
+ id: 's_1',
85
+ tool: 'rect',
86
+ color: '#1f1f1f',
87
+ width: 3,
88
+ x: 0,
89
+ y: 0,
90
+ w: 10,
91
+ h: 10,
92
+ groupIds: [],
93
+ },
94
+ ]);
95
+ expect(svg).not.toContain('data-group-ids');
96
+ });
97
+ });
98
+
99
+ describe('poisoned input hardening', () => {
100
+ test('malformed / out-of-range bind values are rejected on parse', () => {
101
+ const bad = [
102
+ 'data-start-bind="onlyhost"',
103
+ 'data-start-bind="h 2 0.5"', // nx out of [0..1]
104
+ 'data-start-bind="h NaN 0.5"',
105
+ 'data-start-bind="h 0.5 -1"',
106
+ ];
107
+ for (const attr of bad) {
108
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" data-mdcc-annotations="1"><g data-id="s_x" data-tool="arrow" stroke="#1f1f1f" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" vector-effect="non-scaling-stroke" fill="none" ${attr}><line x1="0" y1="0" x2="9" y2="0"/><polyline points="1,1 9,0 1,-1" fill="#1f1f1f"/></g></svg>`;
109
+ const arrow = svgToStrokes(svg).find((s) => s.tool === 'arrow') as ArrowStroke;
110
+ expect(arrow).toBeDefined();
111
+ expect(arrow.startBind).toBeUndefined();
112
+ }
113
+ });
114
+
115
+ test('data-author only honours the literal "ai"', () => {
116
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" data-mdcc-annotations="1"><rect data-id="s_x" data-tool="rect" stroke="#1f1f1f" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" vector-effect="non-scaling-stroke" fill="none" x="0" y="0" width="10" height="10" data-author="evil"/></svg>`;
117
+ const rect = svgToStrokes(svg)[0] as RectStroke;
118
+ expect('author' in rect).toBe(false);
119
+ });
120
+
121
+ test('sanitizer still strips handlers glued next to the new attrs', () => {
122
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg"><rect data-tool="rect" data-group-ids="g_1" onload="alert(1)" x="0" y="0" width="10" height="10"/></svg>`;
123
+ const clean = sanitizeAnnotationSvg(svg);
124
+ expect(clean).toContain('data-group-ids="g_1"');
125
+ expect(clean).not.toContain('onload');
126
+ });
127
+ });
128
+
129
+ describe('rotation (FigJam v3)', () => {
130
+ test('serializes data-rot + presentational transform only when non-zero; round-trips', () => {
131
+ const r: Stroke = {
132
+ id: 's_r',
133
+ tool: 'rect',
134
+ color: '#1f1f1f',
135
+ width: 3,
136
+ x: 0,
137
+ y: 0,
138
+ w: 100,
139
+ h: 50,
140
+ rotation: 30,
141
+ };
142
+ const svg = strokesToSvg([r]);
143
+ expect(svg).toContain('data-rot="30"');
144
+ expect(svg).toContain('transform="rotate(30 50 25)"');
145
+ const back = svgToStrokes(svg);
146
+ expect((back[0] as RectStroke).rotation).toBe(30);
147
+ expect(strokesToSvg(back)).toBe(svg);
148
+ expect(strokesToSvg([{ ...r, rotation: 0 }])).not.toContain('data-rot');
149
+ expect(sanitizeAnnotationSvg(svg)).toBe(svg);
150
+ });
151
+
152
+ test('hit-testing happens in the rotated frame', () => {
153
+ // A wide flat bar rotated 90° stands upright: a probe above the original
154
+ // top edge now hits; the original east tip is empty space.
155
+ const bar: Stroke = {
156
+ id: 's_b',
157
+ tool: 'rect',
158
+ color: '#000',
159
+ width: 2,
160
+ x: 0,
161
+ y: 0,
162
+ w: 100,
163
+ h: 10,
164
+ fill: '#fff',
165
+ rotation: 90,
166
+ };
167
+ expect(strokeHitTest(bar, 50, -40, 2)).toBe(true);
168
+ expect(strokeHitTest(bar, 95, 5, 2)).toBe(false);
169
+ });
170
+
171
+ test('pen/arrow/anchored text ignore rotation (no attr emitted)', () => {
172
+ const pen: Stroke = {
173
+ id: 's_p',
174
+ tool: 'pen',
175
+ color: '#000',
176
+ width: 3,
177
+ points: [
178
+ [0, 0],
179
+ [10, 10],
180
+ ],
181
+ rotation: 45,
182
+ };
183
+ expect(strokesToSvg([pen])).not.toContain('data-rot');
184
+ });
185
+ });
186
+
187
+ describe('polygon anchored text (Wave G)', () => {
188
+ const diamond: PolygonStroke = {
189
+ id: 'poly1',
190
+ tool: 'polygon',
191
+ shape: 'diamond',
192
+ color: '#1a1a1a',
193
+ width: 2,
194
+ x: 10,
195
+ y: 20,
196
+ w: 120,
197
+ h: 80,
198
+ };
199
+ const label: TextStroke = {
200
+ id: 'txt1',
201
+ tool: 'text',
202
+ color: '#1a1a1a',
203
+ fontSize: 16,
204
+ text: 'decision',
205
+ anchorId: 'poly1',
206
+ };
207
+
208
+ test('anchored text inherits a polygon host bbox', () => {
209
+ const anchors = new Map<string, AnchorHost>([[diamond.id, diamond]]);
210
+ expect(strokeBBox(label, anchors)).toEqual({ x: 10, y: 20, w: 120, h: 80 });
211
+ });
212
+
213
+ test('serialize → parse round-trips the anchor binding', () => {
214
+ const svg = strokesToSvg([diamond, label]);
215
+ expect(svg).toContain('data-anchor-id="poly1"');
216
+ const back = svgToStrokes(svg);
217
+ const txt = back.find((s) => s.tool === 'text') as TextStroke;
218
+ expect(txt.anchorId).toBe('poly1');
219
+ expect(strokesToSvg(back)).toBe(svg);
220
+ });
221
+ });
222
+
223
+ describe('shape-kind conversion (Wave H)', () => {
224
+ const base: RectStroke = {
225
+ id: 's_conv',
226
+ tool: 'rect',
227
+ color: '#3b82f6',
228
+ width: 3,
229
+ x: 10,
230
+ y: 20,
231
+ w: 100,
232
+ h: 60,
233
+ fill: '#e0ebfd',
234
+ rotation: 30,
235
+ groupIds: ['g_1'],
236
+ };
237
+
238
+ test('shapeKindOf maps every closed shape; null otherwise', () => {
239
+ expect(shapeKindOf(base)).toBe('square');
240
+ expect(shapeKindOf({ ...base, cornerRadius: 8 })).toBe('rounded');
241
+ expect(
242
+ shapeKindOf({ id: 'e', tool: 'ellipse', color: '#000', width: 2, cx: 0, cy: 0, rx: 5, ry: 5 })
243
+ ).toBe('circle');
244
+ expect(
245
+ shapeKindOf({
246
+ id: 'p',
247
+ tool: 'polygon',
248
+ shape: 'diamond',
249
+ color: '#000',
250
+ width: 2,
251
+ x: 0,
252
+ y: 0,
253
+ w: 10,
254
+ h: 10,
255
+ })
256
+ ).toBe('diamond');
257
+ expect(shapeKindOf({ id: 't', tool: 'text', color: '#000', fontSize: 14, text: 'x' })).toBe(
258
+ null
259
+ );
260
+ });
261
+
262
+ test('rect → circle preserves bbox, styling, rotation, groups, id', () => {
263
+ const patch = convertShapeKind(base, 'circle');
264
+ expect(patch).toMatchObject({ tool: 'ellipse', cx: 60, cy: 50, rx: 50, ry: 30 });
265
+ const converted = { ...base, ...patch } as Stroke;
266
+ // Styling + identity ride along via the merge; stale rect geometry cleared.
267
+ expect(converted.id).toBe('s_conv');
268
+ expect(converted.color).toBe('#3b82f6');
269
+ expect((converted as { fill?: string | null }).fill).toBe('#e0ebfd');
270
+ expect(converted.rotation).toBe(30);
271
+ expect(converted.groupIds).toEqual(['g_1']);
272
+ expect((converted as { x?: number }).x).toBeUndefined();
273
+ });
274
+
275
+ test('circle → diamond → square round-trips the bbox', () => {
276
+ const circle = {
277
+ id: 'e2',
278
+ tool: 'ellipse',
279
+ color: '#000',
280
+ width: 2,
281
+ cx: 60,
282
+ cy: 50,
283
+ rx: 50,
284
+ ry: 30,
285
+ } as Stroke;
286
+ const toDiamond = { ...circle, ...convertShapeKind(circle, 'diamond') } as Stroke;
287
+ expect(toDiamond).toMatchObject({
288
+ tool: 'polygon',
289
+ shape: 'diamond',
290
+ x: 10,
291
+ y: 20,
292
+ w: 100,
293
+ h: 60,
294
+ });
295
+ const toSquare = { ...toDiamond, ...convertShapeKind(toDiamond, 'square') } as Stroke;
296
+ expect(toSquare).toMatchObject({ tool: 'rect', x: 10, y: 20, w: 100, h: 60, cornerRadius: 0 });
297
+ });
298
+
299
+ test('identity + non-shape conversions return null', () => {
300
+ expect(convertShapeKind(base, 'square')).toBe(null);
301
+ expect(
302
+ convertShapeKind({ id: 't', tool: 'text', color: '#000', fontSize: 14, text: 'x' }, 'circle')
303
+ ).toBe(null);
304
+ });
305
+
306
+ test('converted stroke serializes cleanly and round-trips', () => {
307
+ const converted = { ...base, ...convertShapeKind(base, 'triangle') } as Stroke;
308
+ const svg = strokesToSvg([converted]);
309
+ expect(svg).toContain('data-shape="triangle"');
310
+ expect(strokesToSvg(svgToStrokes(svg))).toBe(svg);
311
+ });
312
+ });
313
+
314
+ describe('non-finite geometry hardening (Wave H — security F4)', () => {
315
+ test('Infinity / NaN / non-numeric coords clamp to 0 on parse (no off-board spoof)', () => {
316
+ // A poisoned/synced SVG carries x="1e999" (→ Infinity) and a non-numeric y.
317
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" data-mdcc-annotations="1"><rect data-id="s_evil" data-tool="rect" stroke="#1f1f1f" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" vector-effect="non-scaling-stroke" fill="none" x="1e999" y="abc" width="100" height="50"/></svg>`;
318
+ const rect = svgToStrokes(svg)[0] as RectStroke;
319
+ expect(rect).toBeDefined();
320
+ expect(Number.isFinite(rect.x)).toBe(true);
321
+ expect(Number.isFinite(rect.y)).toBe(true);
322
+ expect(rect.x).toBe(0);
323
+ expect(rect.y).toBe(0);
324
+ // The bbox the AI reader + the renderer both consume is finite — they agree.
325
+ const bb = strokeBBox(rect);
326
+ expect(bb && Number.isFinite(bb.x) && Number.isFinite(bb.w)).toBe(true);
327
+ });
328
+
329
+ test('polygon with an Infinity vertex does not blow the bbox to Infinity', () => {
330
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" data-mdcc-annotations="1"><polygon data-id="s_p" data-tool="polygon" stroke="#1f1f1f" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" vector-effect="non-scaling-stroke" fill="none" data-shape="diamond" points="50,0 1e999,25 50,50 0,25"/></svg>`;
331
+ const poly = svgToStrokes(svg)[0] as PolygonStroke;
332
+ if (poly) {
333
+ expect(Number.isFinite(poly.w)).toBe(true);
334
+ expect(Number.isFinite(poly.x)).toBe(true);
335
+ }
336
+ });
337
+
338
+ test('finite geometry is untouched (round-trip stays byte-identical)', () => {
339
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" data-mdcc-annotations="1"><rect data-id="s_ok" data-tool="rect" stroke="#1f1f1f" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" vector-effect="non-scaling-stroke" fill="none" x="12.5" y="-3" width="100" height="50"/></svg>`;
340
+ expect(strokesToSvg(svgToStrokes(svg))).toBe(svg);
341
+ });
342
+ });
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" data-mdcc-annotations="1"><rect data-id="s_aaa1" data-tool="rect" stroke="#3b82f6" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" vector-effect="non-scaling-stroke" fill="#e0ebfd" x="100" y="100" width="160" height="100" data-group-ids="g_team1"/><g data-id="s_aaa2" data-tool="sticky" data-r="8" data-fs="14" fill="#fce8a6" data-group-ids="g_inner g_team1"><rect x="320" y="100" width="200" height="200" rx="8" ry="8"/><text data-sticky-body="1" x="332" y="112" font-size="14" fill="#1a1a1a" dominant-baseline="hanging">flow start</text></g><g data-id="s_aaa3" data-tool="arrow" stroke="#1f1f1f" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" vector-effect="non-scaling-stroke" fill="none" data-start-bind="s_aaa1 1 0.5" data-end-bind="s_aaa2 0 0.5 p"><line x1="260" y1="150" x2="320" y2="200"/><polyline points="307.1972004393032,196.78622909829892 320,200 314.5302123070536,187.9866148569984" fill="#1f1f1f"/></g><text data-id="s_aaa4" data-tool="text" x="100" y="260" data-font-size="14" fill="#1f1f1f" text-anchor="start" dominant-baseline="hanging" data-author="ai">AI note</text><ellipse data-id="s_aaa5" data-tool="ellipse" stroke="#30a46c" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" vector-effect="non-scaling-stroke" fill="none" cx="700" cy="150" rx="60" ry="40" data-rot="15" transform="rotate(15 700 150)"/></svg>
@@ -0,0 +1,45 @@
1
+ #!/usr/bin/env bun
2
+ // Mock ACP agent for acp/bridge.test.ts. Stands in for `claude-agent-acp` so the
3
+ // bridge can be exercised without a real `claude` install. Implements the
4
+ // minimal handshake (initialize + session/new + session/prompt) and streams one
5
+ // `agent_message_chunk` whose text echoes whether ANTHROPIC_API_KEY survived
6
+ // into the child env — it MUST read `<unset>`, proving scrubAgentEnv (DDR-123
7
+ // guardrail #1) stripped it before spawn.
8
+
9
+ import { Readable, Writable } from 'node:stream';
10
+
11
+ import * as acp from '@agentclientprotocol/sdk';
12
+
13
+ const stream = acp.ndJsonStream(Writable.toWeb(process.stdout), Readable.toWeb(process.stdin));
14
+
15
+ acp
16
+ .agent({ name: 'mock-acp-agent' })
17
+ .onRequest('initialize', () => ({
18
+ protocolVersion: acp.PROTOCOL_VERSION,
19
+ agentCapabilities: { loadSession: false },
20
+ }))
21
+ .onRequest(
22
+ 'session/new',
23
+ (() => {
24
+ let n = 0;
25
+ return () => ({ sessionId: `mock-session-${++n}` });
26
+ })()
27
+ )
28
+ .onRequest('session/prompt', async (ctx) => {
29
+ // The per-request handler context exposes the client connection at `.client`.
30
+ await ctx.client.notify('session/update', {
31
+ sessionId: ctx.params.sessionId,
32
+ update: {
33
+ sessionUpdate: 'agent_message_chunk',
34
+ content: {
35
+ type: 'text',
36
+ text:
37
+ `apiKey=${process.env.ANTHROPIC_API_KEY ?? '<unset>'} ` +
38
+ `model=${process.env.ANTHROPIC_MODEL ?? '<unset>'} ` +
39
+ `thinking=${process.env.MAX_THINKING_TOKENS ?? '<unset>'}`,
40
+ },
41
+ },
42
+ });
43
+ return { stopReason: 'end_turn' };
44
+ })
45
+ .connect(stream);
Binary file