@1agh/maude 0.22.2 → 0.23.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 (44) hide show
  1. package/cli/commands/design-link.test.mjs +53 -1
  2. package/cli/commands/hub.test.mjs +10 -9
  3. package/cli/lib/design-link.mjs +154 -7
  4. package/cli/lib/hubs-config.mjs +42 -4
  5. package/package.json +8 -8
  6. package/plugins/design/dev-server/build.ts +69 -3
  7. package/plugins/design/dev-server/canvas-comment-mount.tsx +384 -0
  8. package/plugins/design/dev-server/canvas-lib.tsx +22 -6
  9. package/plugins/design/dev-server/canvas-shell.tsx +25 -226
  10. package/plugins/design/dev-server/client/app.jsx +37 -15
  11. package/plugins/design/dev-server/collab/awareness-bridge.ts +77 -0
  12. package/plugins/design/dev-server/collab/registry.ts +51 -0
  13. package/plugins/design/dev-server/config.schema.json +20 -0
  14. package/plugins/design/dev-server/context.ts +7 -0
  15. package/plugins/design/dev-server/dist/client.bundle.js +21 -12
  16. package/plugins/design/dev-server/dist/comment-mount.js +1801 -0
  17. package/plugins/design/dev-server/dom-selection.ts +156 -0
  18. package/plugins/design/dev-server/hmr-broadcast.ts +51 -20
  19. package/plugins/design/dev-server/input-router.tsx +99 -61
  20. package/plugins/design/dev-server/server.ts +18 -0
  21. package/plugins/design/dev-server/sync/agent.ts +323 -0
  22. package/plugins/design/dev-server/sync/atomic-write.ts +103 -0
  23. package/plugins/design/dev-server/sync/codec.ts +169 -0
  24. package/plugins/design/dev-server/sync/echo-guard.ts +108 -0
  25. package/plugins/design/dev-server/sync/fs-mirror.ts +160 -0
  26. package/plugins/design/dev-server/sync/hubs-config.ts +87 -0
  27. package/plugins/design/dev-server/sync/index.ts +474 -0
  28. package/plugins/design/dev-server/test/collab-awareness-bridge.test.ts +223 -0
  29. package/plugins/design/dev-server/test/comment-mount.test.ts +87 -0
  30. package/plugins/design/dev-server/test/hmr-classify.test.ts +70 -0
  31. package/plugins/design/dev-server/test/sync-agent.test.ts +278 -0
  32. package/plugins/design/dev-server/test/sync-atomic-write.test.ts +63 -0
  33. package/plugins/design/dev-server/test/sync-codec.test.ts +165 -0
  34. package/plugins/design/dev-server/test/sync-echo-guard.test.ts +96 -0
  35. package/plugins/design/dev-server/test/sync-fs-mirror.test.ts +182 -0
  36. package/plugins/design/dev-server/test/sync-hardening.test.ts +520 -0
  37. package/plugins/design/dev-server/test/sync-hubs-config.test.ts +130 -0
  38. package/plugins/design/dev-server/test/sync-runtime.test.ts +285 -0
  39. package/plugins/design/dev-server/test/use-collab.test.ts +0 -0
  40. package/plugins/design/dev-server/use-collab.tsx +157 -13
  41. package/plugins/design/dev-server/use-selection-set.tsx +12 -0
  42. package/plugins/design/dev-server/use-tool-mode.tsx +12 -0
  43. package/plugins/design/templates/_shell.html +15 -5
  44. package/plugins/design/templates/design-system-inspiration/SUB-AGENT-PROMPTS.md +1 -0
@@ -0,0 +1,285 @@
1
+ // Sync runtime wiring tests — Phase 9 Task 4.
2
+ //
3
+ // Uses an in-memory ProviderFactory so we don't need to start a Hocuspocus
4
+ // server. The agents do real Y.Doc work; the test verifies the runtime
5
+ // honors linkedHub config, discovers canvases, wires up agents, and
6
+ // dispatches fs events through the bus correctly.
7
+
8
+ import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
9
+ import { tmpdir } from 'node:os';
10
+ import { join } from 'node:path';
11
+
12
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
13
+ import { Awareness } from 'y-protocols/awareness';
14
+ import * as Y from 'yjs';
15
+
16
+ import type { Context, DevServerConfig } from '../context.ts';
17
+ import { createBus } from '../context.ts';
18
+ import {
19
+ type AwarenessRegistry,
20
+ type SyncProvider,
21
+ createSyncRuntime,
22
+ discoverCanvases,
23
+ toWsUrl,
24
+ } from '../sync/index.ts';
25
+
26
+ let dir: string;
27
+ let cfgPathEnv: string | undefined;
28
+
29
+ beforeEach(() => {
30
+ dir = mkdtempSync(join(tmpdir(), 'sync-runtime-'));
31
+ cfgPathEnv = process.env.HUBS_CONFIG_PATH;
32
+ // Point hubs.json to a temp file we control.
33
+ process.env.HUBS_CONFIG_PATH = join(dir, 'hubs.json');
34
+ });
35
+
36
+ afterEach(() => {
37
+ // Node's process.env stringifies on assignment; assigning undefined yields
38
+ // the literal string "undefined". delete is the correct restoration.
39
+ // biome-ignore lint/performance/noDelete: process.env semantics.
40
+ if (cfgPathEnv === undefined) delete process.env.HUBS_CONFIG_PATH;
41
+ else process.env.HUBS_CONFIG_PATH = cfgPathEnv;
42
+ rmSync(dir, { recursive: true, force: true });
43
+ });
44
+
45
+ function writeHubsConfig(url: string, token: string): void {
46
+ const cfgPath = process.env.HUBS_CONFIG_PATH;
47
+ if (!cfgPath) throw new Error('HUBS_CONFIG_PATH not set by beforeEach');
48
+ writeFileSync(cfgPath, JSON.stringify({ hubs: { [url]: { token, linkedAt: 1 } } }));
49
+ // Match the CLI's saveHubsConfig mode so the DDR-054 §2h mode-warning
50
+ // doesn't fire on every test.
51
+ chmodSync(cfgPath, 0o600);
52
+ }
53
+
54
+ function makeCtx(linkedHub?: DevServerConfig['linkedHub']): Context {
55
+ // Minimal Context — only the fields the sync runtime touches.
56
+ const designRoot = join(dir, 'design');
57
+ mkdirSync(join(designRoot, 'ui'), { recursive: true });
58
+ mkdirSync(join(designRoot, '_comments'), { recursive: true });
59
+ return {
60
+ cfg: {
61
+ name: 'test',
62
+ projectLabel: null,
63
+ designRoot: 'design',
64
+ canvasGroups: [{ label: 'Canvases', path: 'ui' }],
65
+ rootClass: 'app',
66
+ themeDefault: 'dark',
67
+ tokensCssRel: 'system/colors.css',
68
+ teamAccentDefault: null,
69
+ handoffTargets: [],
70
+ newCanvasDir: 'ui',
71
+ newComponentDir: 'ui/components',
72
+ linkedHub,
73
+ _source: 'defaults',
74
+ },
75
+ projectLabel: 'test',
76
+ paths: {
77
+ repoRoot: dir,
78
+ designRel: 'design',
79
+ designRoot,
80
+ serverInfoFile: join(designRoot, '_server.json'),
81
+ activeFile: join(designRoot, '_active.json'),
82
+ commentsDir: join(designRoot, '_comments'),
83
+ canvasStateDir: join(designRoot, '_canvas-state'),
84
+ historyDir: join(designRoot, '_history'),
85
+ tokensUrlRel: 'design/system/colors.css',
86
+ systemDirRel: 'system',
87
+ },
88
+ bus: createBus(),
89
+ };
90
+ }
91
+
92
+ function inMemoryProviderFactory(): {
93
+ factory: (args: {
94
+ url: string;
95
+ token: string;
96
+ documentName: string;
97
+ }) => SyncProvider;
98
+ peerOf: (slug: string) => Y.Doc;
99
+ } {
100
+ // Map of slug -> { local, peer } Y.Docs cross-linked via applyUpdate.
101
+ const peers = new Map<string, { local: Y.Doc; peer: Y.Doc }>();
102
+ const TRANSPORT = Symbol('test-transport');
103
+
104
+ function factory(args: {
105
+ url: string;
106
+ token: string;
107
+ documentName: string;
108
+ }): SyncProvider {
109
+ const local = new Y.Doc();
110
+ const peer = new Y.Doc();
111
+ local.on('update', (update: Uint8Array, origin: unknown) => {
112
+ if (origin === TRANSPORT) return;
113
+ Y.applyUpdate(peer, update, TRANSPORT);
114
+ });
115
+ peer.on('update', (update: Uint8Array, origin: unknown) => {
116
+ if (origin === TRANSPORT) return;
117
+ Y.applyUpdate(local, update, TRANSPORT);
118
+ });
119
+ peers.set(args.documentName, { local, peer });
120
+ return {
121
+ document: local,
122
+ awareness: new Awareness(local),
123
+ async onceSynced() {
124
+ // Synced immediately for the in-memory pair.
125
+ },
126
+ destroy() {
127
+ local.destroy();
128
+ peer.destroy();
129
+ },
130
+ };
131
+ }
132
+
133
+ return {
134
+ factory,
135
+ peerOf(slug: string): Y.Doc {
136
+ const entry = peers.get(slug);
137
+ if (!entry) throw new Error(`no provider for slug ${slug}`);
138
+ return entry.peer;
139
+ },
140
+ };
141
+ }
142
+
143
+ describe('createSyncRuntime', () => {
144
+ test('returns null when linkedHub is absent (solo mode)', () => {
145
+ const ctx = makeCtx(undefined);
146
+ expect(createSyncRuntime(ctx)).toBeNull();
147
+ });
148
+
149
+ test('returns null when token is missing from hubs.json', () => {
150
+ const ctx = makeCtx({ url: 'https://hub.example.com', linkedAt: 1 });
151
+ // No hubs.json written — token lookup returns null.
152
+ expect(createSyncRuntime(ctx)).toBeNull();
153
+ });
154
+
155
+ test('starts agents for each discovered canvas', async () => {
156
+ const url = 'https://hub.example.com';
157
+ writeHubsConfig(url, 'mau_test');
158
+ const ctx = makeCtx({ url, linkedAt: 1 });
159
+
160
+ writeFileSync(join(ctx.paths.designRoot, 'ui', 'screen.html'), '<button>hi</button>');
161
+ writeFileSync(join(ctx.paths.designRoot, 'ui', 'modal.html'), '<dialog>x</dialog>');
162
+
163
+ const { factory } = inMemoryProviderFactory();
164
+ const runtime = createSyncRuntime(ctx, { providerFactory: factory });
165
+ expect(runtime).not.toBeNull();
166
+
167
+ await runtime?.start();
168
+ expect(runtime?.size()).toBe(2);
169
+ expect(runtime?.agentFor('ui-screen')).toBeDefined();
170
+ expect(runtime?.agentFor('ui-modal')).toBeDefined();
171
+
172
+ await runtime?.stop();
173
+ });
174
+
175
+ test('adopt mode: pushes local disk state up to the hub on first sync', async () => {
176
+ const url = 'https://hub.example.com';
177
+ writeHubsConfig(url, 'mau_test');
178
+ const ctx = makeCtx({ url, linkedAt: 1, adopt: true });
179
+
180
+ writeFileSync(
181
+ join(ctx.paths.designRoot, 'ui', 'screen.html'),
182
+ '<button>local-bootstrap</button>'
183
+ );
184
+
185
+ const { factory, peerOf } = inMemoryProviderFactory();
186
+ const runtime = createSyncRuntime(ctx, { providerFactory: factory });
187
+ await runtime?.start();
188
+
189
+ // Reconcile fires after onceSynced() resolves — give one tick.
190
+ await new Promise((res) => setTimeout(res, 10));
191
+
192
+ expect(peerOf('ui-screen').getText('html').toString()).toBe('<button>local-bootstrap</button>');
193
+ await runtime?.stop();
194
+ });
195
+
196
+ test('bus fs:any event dispatches through the agent', async () => {
197
+ const url = 'https://hub.example.com';
198
+ writeHubsConfig(url, 'mau_test');
199
+ const ctx = makeCtx({ url, linkedAt: 1 });
200
+
201
+ const htmlPath = join(ctx.paths.designRoot, 'ui', 'screen.html');
202
+ writeFileSync(htmlPath, '');
203
+
204
+ const { factory, peerOf } = inMemoryProviderFactory();
205
+ const runtime = createSyncRuntime(ctx, { providerFactory: factory });
206
+ await runtime?.start();
207
+
208
+ // Simulate a local edit: write to disk, then fire the bus event the
209
+ // existing fs-watch.ts would emit.
210
+ writeFileSync(htmlPath, '<button>local</button>');
211
+ ctx.bus.emit('fs:any', 'ui/screen.html');
212
+
213
+ // Wait for fs-mirror's 250ms quiet window + agent flush slack.
214
+ await new Promise((res) => setTimeout(res, 400));
215
+
216
+ expect(peerOf('ui-screen').getText('html').toString()).toBe('<button>local</button>');
217
+ await runtime?.stop();
218
+ });
219
+
220
+ test('attaches each provider awareness to the registry and detaches on stop', async () => {
221
+ const url = 'https://hub.example.com';
222
+ writeHubsConfig(url, 'mau_test');
223
+ const ctx = makeCtx({ url, linkedAt: 1 });
224
+
225
+ writeFileSync(join(ctx.paths.designRoot, 'ui', 'screen.html'), '<button>hi</button>');
226
+ writeFileSync(join(ctx.paths.designRoot, 'ui', 'modal.html'), '<dialog>x</dialog>');
227
+
228
+ const attached: string[] = [];
229
+ let detachCount = 0;
230
+ const registry: AwarenessRegistry = {
231
+ attachHubAwareness(slug, _awareness) {
232
+ attached.push(slug);
233
+ return () => {
234
+ detachCount++;
235
+ };
236
+ },
237
+ };
238
+
239
+ const { factory } = inMemoryProviderFactory();
240
+ const runtime = createSyncRuntime(ctx, { providerFactory: factory, registry });
241
+ await runtime?.start();
242
+
243
+ expect(attached.sort()).toEqual(['ui-modal', 'ui-screen']);
244
+
245
+ await runtime?.stop();
246
+ expect(detachCount).toBe(2);
247
+ });
248
+ });
249
+
250
+ describe('discoverCanvases', () => {
251
+ test('finds .html files but EXCLUDES .tsx (DDR-054 §2b)', async () => {
252
+ const ctx = makeCtx({ url: 'https://h.example.com', linkedAt: 1 });
253
+ writeFileSync(join(ctx.paths.designRoot, 'ui', 'a.html'), '');
254
+ writeFileSync(join(ctx.paths.designRoot, 'ui', 'b.tsx'), '');
255
+
256
+ const list = await discoverCanvases(ctx);
257
+ const slugs = list.map((c) => c.slug).sort();
258
+ // .tsx is deliberately refused — hostile-hub-pushed JSX would be
259
+ // transpiled and executed in iframe same-origin. Solo-mode editing
260
+ // of .tsx is unaffected.
261
+ expect(slugs).toEqual(['ui-a']);
262
+ });
263
+
264
+ test('skips dirs starting with _ (e.g. _history, _comments)', async () => {
265
+ const ctx = makeCtx({ url: 'https://h.example.com', linkedAt: 1 });
266
+ mkdirSync(join(ctx.paths.designRoot, 'ui', '_history'));
267
+ writeFileSync(join(ctx.paths.designRoot, 'ui', '_history', 'snap.html'), '');
268
+ writeFileSync(join(ctx.paths.designRoot, 'ui', 'real.html'), '');
269
+
270
+ const list = await discoverCanvases(ctx);
271
+ expect(list.map((c) => c.slug)).toEqual(['ui-real']);
272
+ });
273
+ });
274
+
275
+ describe('toWsUrl', () => {
276
+ test('https → wss', () => {
277
+ expect(toWsUrl('https://hub.example.com')).toBe('wss://hub.example.com');
278
+ });
279
+ test('http → ws', () => {
280
+ expect(toWsUrl('http://localhost:1234')).toBe('ws://localhost:1234');
281
+ });
282
+ test('passthrough for ws://', () => {
283
+ expect(toWsUrl('ws://localhost:1234')).toBe('ws://localhost:1234');
284
+ });
285
+ });
@@ -111,11 +111,164 @@ export interface CollabAwarenessState {
111
111
 
112
112
  export type ForeignAwareness = Omit<CollabAwarenessState, '__connId'> & { clientID: number };
113
113
 
114
+ // ─────────────────────────────────────────────────────────────────────────────
115
+ // Untrusted-input sanitization at the awareness trust boundary.
116
+ //
117
+ // Phase 8 awareness was loopback-only — every state came from a trusted local
118
+ // tab. Phase 9 (Task 5) bridges awareness through a SEMI-TRUSTED hub (DDR-054),
119
+ // so foreign states are now attacker-influenceable. `useForeignAwareness` is
120
+ // the single chokepoint where remote state is read before it reaches the
121
+ // cursor / participant render sinks, so all validation lives here. Fields are
122
+ // validated for VALUE, not just type:
123
+ // - color: re-derived locally from the (sanitized) name and the wire value
124
+ // is DISCARDED — a hub-chosen `color` string would otherwise flow into an
125
+ // inline `style` and a `url(...)` value beacons every viewer's browser.
126
+ // The palette is deterministic, so re-derivation is visually identical.
127
+ // - name: control / bidi / zero-width chars stripped, length-capped — blocks
128
+ // identity spoofing + render bloat.
129
+ // - cursor / viewport: finite-number gated — a NaN/Infinity would poison the
130
+ // CSS transform / the local viewport controller during Follow mode.
131
+ // - selection.cssPath: charset + length allowlist before it reaches
132
+ // `querySelector` — blocks selector-complexity DoS + arbitrary DOM probing.
133
+ // - annotationSelection: per-id token + array-length capped — blocks a
134
+ // querySelector render-storm.
135
+ // - peer count capped — blocks an unbounded-clients memory/render DoS.
136
+
137
+ const MAX_FOREIGN_PEERS = 64;
138
+ const MAX_NAME_LEN = 64;
139
+ const MAX_CSSPATH_LEN = 512;
140
+ const MAX_ANNOTATION_IDS = 256;
141
+ const MAX_ANNOTATION_ID_LEN = 128;
142
+
143
+ // Charset of every selector the canvas-shell `cssPath()` emits
144
+ // (`[data-*="..."]`, `#id`, `tag.cls:nth-child(N)`, ` > ` combinators).
145
+ const CSSPATH_ALLOWED = /^[A-Za-z0-9 ._#>:[\]="'()-]+$/;
146
+ // `cssPath()` only ever emits `:nth-child(N)` as a parenthesised construct.
147
+ // Functional pseudo-classes (`:has()`, `:is()`, `:where()`, `:not()`) trigger
148
+ // per-render subtree walks → a malicious hub peer could publish a deeply
149
+ // nested `:has()` selector and pin every viewer's main thread (querySelector
150
+ // re-runs each render). So after stripping the legit `:nth-child/of-type(N)`
151
+ // forms, any residual paren means a functional pseudo — reject. The charset
152
+ // allowlist alone was wider than the generator (the original DoS hole).
153
+ const CSSPATH_NTH = /:nth-(child|of-type)\(\d{1,4}\)/g;
154
+ const ANNOTATION_ID_ALLOWED = /^[A-Za-z0-9._:-]+$/;
155
+
156
+ function isSafeCssPath(p: string): boolean {
157
+ if (p.length > MAX_CSSPATH_LEN || !CSSPATH_ALLOWED.test(p)) return false;
158
+ const stripped = p.replace(CSSPATH_NTH, '');
159
+ return !stripped.includes('(') && !stripped.includes(')');
160
+ }
161
+
162
+ function isFiniteNum(v: unknown): v is number {
163
+ return typeof v === 'number' && Number.isFinite(v);
164
+ }
165
+
166
+ // Control (C0/C1), zero-width, and bidi-override code points get stripped from
167
+ // displayed strings so a remote peer can't spoof another's identity or hide
168
+ // payloads in labels. A charCode scan (not a regex literal with raw control
169
+ // chars) sidesteps biome's noControlCharactersInRegex while keeping the same
170
+ // semantics — same approach as the hub's sanitizeForLog (DDR-053).
171
+ function isUnsafeCodePoint(cp: number): boolean {
172
+ return (
173
+ cp <= 0x1f ||
174
+ (cp >= 0x7f && cp <= 0x9f) ||
175
+ (cp >= 0x200b && cp <= 0x200f) ||
176
+ (cp >= 0x202a && cp <= 0x202e) ||
177
+ (cp >= 0x2066 && cp <= 0x2069) ||
178
+ cp === 0xfeff
179
+ );
180
+ }
181
+
182
+ function sanitizeName(raw: unknown): string {
183
+ if (typeof raw !== 'string') return 'anonymous';
184
+ let cleaned = '';
185
+ for (const ch of raw) {
186
+ if (!isUnsafeCodePoint(ch.codePointAt(0) ?? 0)) cleaned += ch;
187
+ }
188
+ cleaned = cleaned.trim().slice(0, MAX_NAME_LEN);
189
+ return cleaned || 'anonymous';
190
+ }
191
+
192
+ function sanitizeCursor(raw: unknown): { x: number; y: number } | null {
193
+ if (!raw || typeof raw !== 'object') return null;
194
+ const c = raw as { x?: unknown; y?: unknown };
195
+ return isFiniteNum(c.x) && isFiniteNum(c.y) ? { x: c.x, y: c.y } : null;
196
+ }
197
+
198
+ function sanitizeViewport(raw: unknown): { x: number; y: number; zoom: number } {
199
+ const fallback = { x: 0, y: 0, zoom: 1 };
200
+ if (!raw || typeof raw !== 'object') return fallback;
201
+ const v = raw as { x?: unknown; y?: unknown; zoom?: unknown };
202
+ if (!isFiniteNum(v.x) || !isFiniteNum(v.y) || !isFiniteNum(v.zoom) || v.zoom <= 0)
203
+ return fallback;
204
+ return { x: v.x, y: v.y, zoom: v.zoom };
205
+ }
206
+
207
+ function sanitizeSelection(raw: unknown): CollabAwarenessState['selection'] {
208
+ if (!raw || typeof raw !== 'object') return null;
209
+ const s = raw as { cssPath?: unknown; bounds?: unknown };
210
+ const b = s.bounds as { x?: unknown; y?: unknown; w?: unknown; h?: unknown } | undefined;
211
+ const bounds =
212
+ b && isFiniteNum(b.x) && isFiniteNum(b.y) && isFiniteNum(b.w) && isFiniteNum(b.h)
213
+ ? { x: b.x, y: b.y, w: b.w, h: b.h }
214
+ : null;
215
+ // Only keep cssPath if it matches the locator grammar — otherwise drop it and
216
+ // let the renderer fall back to the (validated) bounds.
217
+ const cssPath = typeof s.cssPath === 'string' && isSafeCssPath(s.cssPath) ? s.cssPath : '';
218
+ if (!cssPath && !bounds) return null;
219
+ return { cssPath, bounds: bounds ?? { x: 0, y: 0, w: 0, h: 0 } };
220
+ }
221
+
222
+ function sanitizeAnnotationSelection(raw: unknown): string[] {
223
+ if (!Array.isArray(raw)) return [];
224
+ const out: string[] = [];
225
+ for (const id of raw) {
226
+ if (out.length >= MAX_ANNOTATION_IDS) break;
227
+ if (
228
+ typeof id === 'string' &&
229
+ id.length <= MAX_ANNOTATION_ID_LEN &&
230
+ ANNOTATION_ID_ALLOWED.test(id)
231
+ )
232
+ out.push(id);
233
+ }
234
+ return out;
235
+ }
236
+
237
+ /**
238
+ * Validate + normalize one foreign awareness state at the trust boundary.
239
+ * Returns null for states that can't be a peer (no usable name). `color` is
240
+ * always re-derived locally from the sanitized name — the wire value is never
241
+ * trusted, which is what closes the hub CSS-`url()` exfil channel. Exported so
242
+ * the hostile-input matrix can exercise it without a React harness.
243
+ */
244
+ export function sanitizeForeignState(clientID: number, state: unknown): ForeignAwareness | null {
245
+ if (!state || typeof state !== 'object') return null;
246
+ const s = state as Partial<CollabAwarenessState>;
247
+ if (typeof s.name !== 'string') return null;
248
+ const name = sanitizeName(s.name);
249
+ return {
250
+ clientID,
251
+ name,
252
+ color: colorForName(name),
253
+ cursor: sanitizeCursor(s.cursor),
254
+ selection: sanitizeSelection(s.selection),
255
+ annotationSelection: sanitizeAnnotationSelection(s.annotationSelection),
256
+ viewport: sanitizeViewport(s.viewport),
257
+ };
258
+ }
259
+
114
260
  // ─────────────────────────────────────────────────────────────────────────────
115
261
  // Context.
116
262
 
117
263
  interface CollabValue {
118
264
  doc: Y.Doc;
265
+ /**
266
+ * SECURITY INVARIANT: in linked mode this Awareness carries states relayed
267
+ * from a SEMI-TRUSTED hub (DDR-054). Foreign states are untrusted input —
268
+ * read them ONLY through `useForeignAwareness`, which sanitizes every field
269
+ * at the trust boundary (`sanitizeForeignState`). Do NOT call
270
+ * `awareness.getStates()` directly in render code; that bypasses the gate.
271
+ */
119
272
  awareness: Awareness;
120
273
  /** Local peer's session-stable color (derived from git user.name). */
121
274
  myColor: string;
@@ -158,19 +311,10 @@ export function useForeignAwareness(): ForeignAwareness[] {
158
311
  const myId = awareness.clientID;
159
312
  for (const [clientID, state] of awareness.getStates() as Map<number, unknown>) {
160
313
  if (clientID === myId) continue;
161
- if (!state || typeof state !== 'object') continue;
162
- const s = state as Partial<CollabAwarenessState>;
163
- // Guard against partial states from stale peers / older protocol versions.
164
- if (typeof s.name !== 'string' || typeof s.color !== 'string') continue;
165
- out.push({
166
- clientID,
167
- name: s.name,
168
- color: s.color,
169
- cursor: s.cursor ?? null,
170
- selection: s.selection ?? null,
171
- annotationSelection: Array.isArray(s.annotationSelection) ? s.annotationSelection : [],
172
- viewport: s.viewport ?? { x: 0, y: 0, zoom: 1 },
173
- });
314
+ if (out.length >= MAX_FOREIGN_PEERS) break; // bound DoS via unbounded peers
315
+ // Sanitize every now-remote field at this trust boundary (Task 5).
316
+ const peer = sanitizeForeignState(clientID, state);
317
+ if (peer) out.push(peer);
174
318
  }
175
319
  return out;
176
320
  }
@@ -189,6 +189,18 @@ export function SelectionSetProvider({
189
189
  return <SelectionSetContext.Provider value={value}>{children}</SelectionSetContext.Provider>;
190
190
  }
191
191
 
192
+ /**
193
+ * Mount a `SelectionSetProvider` only when none exists above us. The shell-
194
+ * owned comment mount layer provides one so both the lite comment router and
195
+ * `CanvasShell` share a single selection set. Hook called unconditionally;
196
+ * only the returned tree branches (hook rules).
197
+ */
198
+ export function MaybeSelectionSetProvider({ children }: { children: ReactNode }) {
199
+ const outer = useContext(SelectionSetContext);
200
+ if (outer) return <>{children}</>;
201
+ return <SelectionSetProvider>{children}</SelectionSetProvider>;
202
+ }
203
+
192
204
  // ─────────────────────────────────────────────────────────────────────────────
193
205
  // Hooks
194
206
 
@@ -122,6 +122,18 @@ export function ToolProvider({
122
122
  return <ToolContext.Provider value={value}>{children}</ToolContext.Provider>;
123
123
  }
124
124
 
125
+ /**
126
+ * Mount a `ToolProvider` only when none exists above us. When the shell-owned
127
+ * comment mount layer (canvas-comment-mount.tsx) already provides one,
128
+ * `DesignCanvas` consumes that instance instead of double-mounting. The hook
129
+ * is called unconditionally; only the returned tree branches (hook rules).
130
+ */
131
+ export function MaybeToolProvider({ children }: { children: ReactNode }) {
132
+ const outer = useContext(ToolContext);
133
+ if (outer) return <>{children}</>;
134
+ return <ToolProvider>{children}</ToolProvider>;
135
+ }
136
+
125
137
  // ─────────────────────────────────────────────────────────────────────────────
126
138
  // Hook
127
139
 
@@ -122,6 +122,10 @@
122
122
  const styleEl = document.getElementById('canvas-hide-chrome');
123
123
  if (styleEl) styleEl.media = 'all';
124
124
  }
125
+ // Shell-owned comment layer toggle. Gallery thumbnails pass `?comments=0`
126
+ // so the comment chrome is suppressed in previews; opened tabs omit it
127
+ // (comments on). hide-chrome exports also suppress comments.
128
+ const commentsEnabled = params.get('comments') !== '0' && params.get('hide-chrome') !== '1';
125
129
 
126
130
  function showError(msg) {
127
131
  const el = document.getElementById('canvas-mount-error');
@@ -234,10 +238,13 @@
234
238
  window.__canvas_meta_file__ = designRel + '/' + canvasRel;
235
239
  });
236
240
  try {
237
- const [{ createRoot }, mod, react] = await Promise.all([
238
- import('react-dom/client'),
241
+ // mountCanvas (dist/comment-mount.js) owns the React root + the lite
242
+ // comment provider tree. It externalizes react / react-dom/client to
243
+ // the importmap so the canvas module and the comment layer share one
244
+ // React singleton (inlining a second React would break hooks).
245
+ const [{ mountCanvas }, mod] = await Promise.all([
246
+ import('/_client/comment-mount.js'),
239
247
  import(canvasUrl),
240
- import('react'),
241
248
  metaFetch,
242
249
  ]);
243
250
  const Canvas = mod.default;
@@ -246,8 +253,11 @@
246
253
  'Canvas module at ' + canvasUrl + ' has no default export (or it is not a component).'
247
254
  );
248
255
  }
249
- const root = createRoot(document.getElementById('canvas-root'));
250
- root.render(react.createElement(Canvas));
256
+ mountCanvas(Canvas, {
257
+ rootEl: document.getElementById('canvas-root'),
258
+ file: designRel + '/' + canvasRel,
259
+ commentsEnabled,
260
+ });
251
261
  } catch (err) {
252
262
  showError((err && err.stack) || String(err));
253
263
  }
@@ -14,6 +14,7 @@ The three MANDATORY safety blocks (ANIMATION SAFETY, RELATIVE-URL SAFETY, PLACEH
14
14
 
15
15
  ### ANIMATION SAFETY (mandatory — applies to motion specimen + any canvas with @keyframes / transitions)
16
16
 
17
+ - **Tooling contract first (DDR-049).** The authoritative rule for WHICH tool to use is the "Animation tooling contract" in `skills/design-system/SKILL.md`: the **default for both specimens and canvases is `<MotionDemo role>` / the canvas-lib vocabulary from `@maude/canvas-lib`** (wraps `motion/react`), NOT hand-rolled `@keyframes`. Pure-CSS `.motion-*` role classes are a justified-only zero-JS escape hatch. The CSS rules below apply to that escape hatch and to the bounded-geometry constraints the vocabulary already satisfies — they do NOT license reaching for `@keyframes` by default.
17
18
  - **Bounded geometry.** Every tile that hosts a rotating / scaling animation MUST have `overflow: hidden`. Otherwise the bounding box extends ~√2× at 45°/135° rotations and overflows adjacent rows. **The motion specimen tiles all set `overflow: hidden` explicitly** — codify this with a CSS comment so future agents reading the file see the rationale.
18
19
  - **Sparkle / pulse / twinkle = small only.** Keyframes that scale from `0 → 1 → 0` (or similar "appear-and-disappear" patterns) are for elements ≤56px. **Never apply to full-width tiles.** Demo sparkle on a 32×32 chip inside a tile, never on the tile itself. The studyfi imprint retro D-3 caught a sparkle-on-full-tile that exploded the whole row.
19
20
  - **Loop motion = `infinite alternate`.** Specimens meant for continuous display use `animation: <kf> <dur> infinite alternate <easing>`. Single-shot animations finish in 150-200ms and leave the demo invisible on the second look — "looks dead until you hover" is the regression mode.