@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.
- package/cli/commands/design-link.test.mjs +53 -1
- package/cli/commands/hub.test.mjs +10 -9
- package/cli/lib/design-link.mjs +154 -7
- package/cli/lib/hubs-config.mjs +42 -4
- package/package.json +8 -8
- package/plugins/design/dev-server/build.ts +69 -3
- package/plugins/design/dev-server/canvas-comment-mount.tsx +384 -0
- package/plugins/design/dev-server/canvas-lib.tsx +22 -6
- package/plugins/design/dev-server/canvas-shell.tsx +25 -226
- package/plugins/design/dev-server/client/app.jsx +37 -15
- package/plugins/design/dev-server/collab/awareness-bridge.ts +77 -0
- package/plugins/design/dev-server/collab/registry.ts +51 -0
- package/plugins/design/dev-server/config.schema.json +20 -0
- package/plugins/design/dev-server/context.ts +7 -0
- package/plugins/design/dev-server/dist/client.bundle.js +21 -12
- package/plugins/design/dev-server/dist/comment-mount.js +1801 -0
- package/plugins/design/dev-server/dom-selection.ts +156 -0
- package/plugins/design/dev-server/hmr-broadcast.ts +51 -20
- package/plugins/design/dev-server/input-router.tsx +99 -61
- package/plugins/design/dev-server/server.ts +18 -0
- package/plugins/design/dev-server/sync/agent.ts +323 -0
- package/plugins/design/dev-server/sync/atomic-write.ts +103 -0
- package/plugins/design/dev-server/sync/codec.ts +169 -0
- package/plugins/design/dev-server/sync/echo-guard.ts +108 -0
- package/plugins/design/dev-server/sync/fs-mirror.ts +160 -0
- package/plugins/design/dev-server/sync/hubs-config.ts +87 -0
- package/plugins/design/dev-server/sync/index.ts +474 -0
- package/plugins/design/dev-server/test/collab-awareness-bridge.test.ts +223 -0
- package/plugins/design/dev-server/test/comment-mount.test.ts +87 -0
- package/plugins/design/dev-server/test/hmr-classify.test.ts +70 -0
- package/plugins/design/dev-server/test/sync-agent.test.ts +278 -0
- package/plugins/design/dev-server/test/sync-atomic-write.test.ts +63 -0
- package/plugins/design/dev-server/test/sync-codec.test.ts +165 -0
- package/plugins/design/dev-server/test/sync-echo-guard.test.ts +96 -0
- package/plugins/design/dev-server/test/sync-fs-mirror.test.ts +182 -0
- package/plugins/design/dev-server/test/sync-hardening.test.ts +520 -0
- package/plugins/design/dev-server/test/sync-hubs-config.test.ts +130 -0
- package/plugins/design/dev-server/test/sync-runtime.test.ts +285 -0
- package/plugins/design/dev-server/test/use-collab.test.ts +0 -0
- package/plugins/design/dev-server/use-collab.tsx +157 -13
- package/plugins/design/dev-server/use-selection-set.tsx +12 -0
- package/plugins/design/dev-server/use-tool-mode.tsx +12 -0
- package/plugins/design/templates/_shell.html +15 -5
- 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
|
+
});
|
|
Binary file
|
|
@@ -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 (
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
if (
|
|
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
|
-
|
|
238
|
-
|
|
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
|
-
|
|
250
|
-
|
|
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.
|