@1agh/maude 0.22.2 → 0.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +34 -1
- package/cli/bin/maude.mjs +3 -0
- package/cli/commands/cache.mjs +181 -0
- package/cli/commands/cache.test.mjs +166 -0
- package/cli/commands/design-link.test.mjs +84 -2
- package/cli/commands/design.mjs +95 -4
- package/cli/commands/design.test.mjs +56 -0
- package/cli/commands/help.mjs +34 -0
- package/cli/commands/hub.mjs +255 -30
- package/cli/commands/hub.test.mjs +135 -10
- package/cli/commands/init.mjs +3 -0
- package/cli/commands/preflight.mjs +11 -0
- package/cli/commands/preflight.test.mjs +46 -0
- package/cli/commands/scenario-report.mjs +45 -0
- package/cli/lib/cache.mjs +407 -0
- package/cli/lib/cache.test.mjs +303 -0
- package/cli/lib/design-link.mjs +222 -11
- package/cli/lib/flow-design-integration.test.mjs +165 -0
- package/cli/lib/gitignore-block.mjs +90 -0
- package/cli/lib/gitignore-block.test.mjs +123 -0
- package/cli/lib/hubs-config.mjs +42 -4
- package/cli/lib/plugin-cli-reachability.test.mjs +56 -0
- package/cli/lib/preflight.mjs +41 -10
- package/package.json +8 -8
- package/plugins/design/dev-server/ai-banner.tsx +2 -2
- package/plugins/design/dev-server/annotations-context-toolbar.tsx +349 -112
- package/plugins/design/dev-server/annotations-layer.tsx +906 -137
- package/plugins/design/dev-server/api.ts +109 -4
- package/plugins/design/dev-server/bin/preflight.sh +21 -10
- package/plugins/design/dev-server/bin/prep.sh +211 -0
- package/plugins/design/dev-server/bin/scenario-report.mjs +209 -0
- package/plugins/design/dev-server/bin/screenshot.sh +18 -1
- package/plugins/design/dev-server/bin/smoke.sh +94 -11
- 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-cursors.ts +125 -0
- package/plugins/design/dev-server/canvas-icons.tsx +130 -0
- package/plugins/design/dev-server/canvas-lib.tsx +47 -27
- package/plugins/design/dev-server/canvas-meta.schema.json +20 -0
- package/plugins/design/dev-server/canvas-shell.tsx +358 -245
- package/plugins/design/dev-server/client/app.jsx +191 -21
- package/plugins/design/dev-server/client/styles/3-shell.css +10 -0
- package/plugins/design/dev-server/collab/awareness-bridge.ts +77 -0
- package/plugins/design/dev-server/collab/index.ts +87 -9
- package/plugins/design/dev-server/collab/persistence.ts +34 -3
- package/plugins/design/dev-server/collab/registry.ts +131 -2
- package/plugins/design/dev-server/collab/room.ts +21 -8
- package/plugins/design/dev-server/config.schema.json +20 -0
- package/plugins/design/dev-server/context-menu.tsx +167 -23
- package/plugins/design/dev-server/context.ts +31 -0
- package/plugins/design/dev-server/contextual-toolbar.tsx +7 -7
- package/plugins/design/dev-server/dist/client.bundle.js +144 -22
- package/plugins/design/dev-server/dist/comment-mount.js +1868 -0
- package/plugins/design/dev-server/dist/styles.css +16 -0
- package/plugins/design/dev-server/dom-selection.ts +156 -0
- package/plugins/design/dev-server/equal-spacing-handles.tsx +1 -1
- package/plugins/design/dev-server/export-dialog.tsx +19 -17
- package/plugins/design/dev-server/fs-watch.ts +1 -0
- package/plugins/design/dev-server/hmr-broadcast.ts +51 -20
- package/plugins/design/dev-server/http.ts +260 -20
- package/plugins/design/dev-server/input-router.tsx +125 -64
- package/plugins/design/dev-server/participants-chrome.tsx +10 -10
- package/plugins/design/dev-server/server.ts +141 -1
- package/plugins/design/dev-server/sync/agent.ts +418 -0
- package/plugins/design/dev-server/sync/atomic-write.ts +103 -0
- package/plugins/design/dev-server/sync/codec.ts +324 -0
- package/plugins/design/dev-server/sync/connection-state.ts +203 -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 +918 -0
- package/plugins/design/dev-server/sync/materialize.ts +62 -0
- package/plugins/design/dev-server/sync/migrate-seed.ts +163 -0
- package/plugins/design/dev-server/sync/origins.ts +57 -0
- package/plugins/design/dev-server/sync/projection.ts +368 -0
- package/plugins/design/dev-server/sync/status.ts +115 -0
- package/plugins/design/dev-server/sync/untrusted.ts +153 -0
- package/plugins/design/dev-server/test/_helpers.ts +6 -2
- package/plugins/design/dev-server/test/annotations-layer.test.ts +231 -0
- package/plugins/design/dev-server/test/annotations-roundtrip.test.ts +276 -0
- package/plugins/design/dev-server/test/canvas-cursors.test.ts +73 -0
- package/plugins/design/dev-server/test/canvas-origin-gate.test.ts +76 -0
- package/plugins/design/dev-server/test/collab-awareness-bridge.test.ts +223 -0
- package/plugins/design/dev-server/test/collab-reseed-guard.test.ts +78 -0
- package/plugins/design/dev-server/test/collab-room.test.ts +21 -10
- package/plugins/design/dev-server/test/comment-mount.test.ts +87 -0
- package/plugins/design/dev-server/test/csp-canvas-shell.test.ts +46 -0
- package/plugins/design/dev-server/test/fixtures/phase-20-annotations.svg +1 -0
- package/plugins/design/dev-server/test/hmr-classify.test.ts +70 -0
- package/plugins/design/dev-server/test/input-router.test.ts +21 -0
- package/plugins/design/dev-server/test/sanitize-annotation-svg.test.ts +100 -0
- package/plugins/design/dev-server/test/shared-doc-convergence.test.ts +265 -0
- package/plugins/design/dev-server/test/shared-doc-foundation.test.ts +63 -0
- package/plugins/design/dev-server/test/shared-doc-migrate.test.ts +160 -0
- package/plugins/design/dev-server/test/shared-doc-projection.test.ts +238 -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-connection-state.test.ts +146 -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-meta-codec.test.ts +123 -0
- package/plugins/design/dev-server/test/sync-runtime.test.ts +812 -0
- package/plugins/design/dev-server/test/sync-status.test.ts +80 -0
- package/plugins/design/dev-server/test/sync-untrusted.test.ts +104 -0
- package/plugins/design/dev-server/test/use-collab.test.ts +0 -0
- package/plugins/design/dev-server/test/use-tool-mode.test.tsx +38 -10
- package/plugins/design/dev-server/tool-palette.tsx +18 -16
- package/plugins/design/dev-server/undo-hud.tsx +4 -4
- package/plugins/design/dev-server/undo-stack.ts +20 -4
- package/plugins/design/dev-server/use-annotation-resize.tsx +16 -5
- 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 +27 -12
- package/plugins/design/dev-server/ws.ts +40 -1
- package/plugins/design/templates/_shell.html +63 -5
- package/plugins/design/templates/canvas.tsx.template +13 -0
- package/plugins/design/templates/design-system-inspiration/SUB-AGENT-PROMPTS.md +15 -7
- package/plugins/flow/.claude-plugin/config.schema.json +5 -0
- package/plugins/flow/templates/ai-skeleton/README.md +1 -1
- package/plugins/flow/templates/ai-skeleton/gitignore +10 -0
- package/plugins/flow/templates/ai-skeleton/scenarios/README.md +3 -1
- package/plugins/flow/templates/ai-skeleton/workflows.config.json +2 -1
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// Sync status store tests — Phase 9 Task 8.
|
|
2
|
+
|
|
3
|
+
import { describe, expect, test } from 'bun:test';
|
|
4
|
+
|
|
5
|
+
import type { SyncStatusSnapshot } from '../sync/connection-state.ts';
|
|
6
|
+
import { type SyncStatusPayload, createSyncStatusStore } from '../sync/status.ts';
|
|
7
|
+
|
|
8
|
+
function snap(partial: Partial<SyncStatusSnapshot> = {}): SyncStatusSnapshot {
|
|
9
|
+
return {
|
|
10
|
+
state: 'online',
|
|
11
|
+
queuedOps: 0,
|
|
12
|
+
lastSyncAt: null,
|
|
13
|
+
offlineSince: null,
|
|
14
|
+
flash: null,
|
|
15
|
+
updatedAt: 1,
|
|
16
|
+
...partial,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function makeStore() {
|
|
21
|
+
const writes: SyncStatusPayload[] = [];
|
|
22
|
+
const broadcasts: SyncStatusPayload[] = [];
|
|
23
|
+
const store = createSyncStatusStore({
|
|
24
|
+
url: 'https://hub.example.com',
|
|
25
|
+
canvases: 3,
|
|
26
|
+
write: (p) => writes.push(p),
|
|
27
|
+
broadcast: (p) => broadcasts.push(p),
|
|
28
|
+
now: () => 42,
|
|
29
|
+
});
|
|
30
|
+
return { store, writes, broadcasts };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe('sync status store', () => {
|
|
34
|
+
test('update() writes + broadcasts the merged payload', () => {
|
|
35
|
+
const { store, writes, broadcasts } = makeStore();
|
|
36
|
+
store.update(snap({ state: 'offline', queuedOps: 2 }));
|
|
37
|
+
expect(writes).toHaveLength(1);
|
|
38
|
+
expect(broadcasts).toHaveLength(1);
|
|
39
|
+
expect(writes[0].state).toBe('offline');
|
|
40
|
+
expect(writes[0].queuedOps).toBe(2);
|
|
41
|
+
expect(writes[0].url).toBe('https://hub.example.com');
|
|
42
|
+
expect(writes[0].canvases).toBe(3);
|
|
43
|
+
expect(writes[0].conflicts).toEqual([]);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('addConflict() appends a stamped conflict + reflects in get()', () => {
|
|
47
|
+
const { store } = makeStore();
|
|
48
|
+
store.addConflict({ slug: 'screen', kind: 'cold-start-hub-wins' });
|
|
49
|
+
const p = store.get();
|
|
50
|
+
expect(p.conflicts).toHaveLength(1);
|
|
51
|
+
expect(p.conflicts[0]).toEqual({ slug: 'screen', kind: 'cold-start-hub-wins', at: 42 });
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('conflict list is capped at maxConflicts (drops oldest)', () => {
|
|
55
|
+
const writes: SyncStatusPayload[] = [];
|
|
56
|
+
const store = createSyncStatusStore({
|
|
57
|
+
url: 'https://h',
|
|
58
|
+
canvases: 1,
|
|
59
|
+
write: (p) => writes.push(p),
|
|
60
|
+
maxConflicts: 2,
|
|
61
|
+
now: () => 1,
|
|
62
|
+
});
|
|
63
|
+
store.addConflict({ slug: 'a', kind: 'git-pull' });
|
|
64
|
+
store.addConflict({ slug: 'b', kind: 'git-pull' });
|
|
65
|
+
store.addConflict({ slug: 'c', kind: 'git-pull' });
|
|
66
|
+
const p = store.get();
|
|
67
|
+
expect(p.conflicts.map((c) => c.slug)).toEqual(['b', 'c']);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('a throwing writer never propagates into the caller', () => {
|
|
71
|
+
const store = createSyncStatusStore({
|
|
72
|
+
url: 'https://h',
|
|
73
|
+
canvases: 1,
|
|
74
|
+
write: () => {
|
|
75
|
+
throw new Error('disk full');
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
expect(() => store.update(snap({ state: 'offline' }))).not.toThrow();
|
|
79
|
+
});
|
|
80
|
+
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// T4.5 (DDR-054 §3 F3) — untrusted-marker for sync-written files. Verifies the
|
|
2
|
+
// `_untrusted/INDEX.json` marker + the managed `.claudeignore` block: written
|
|
3
|
+
// for the syncable set, cleared when empty, stale entries dropped on re-write,
|
|
4
|
+
// and user-authored `.claudeignore` content outside the block preserved.
|
|
5
|
+
|
|
6
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
|
|
7
|
+
import { tmpdir } from 'node:os';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
|
|
10
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
11
|
+
|
|
12
|
+
import type { Context } from '../context.ts';
|
|
13
|
+
import type { CanvasDescriptor } from '../sync/index.ts';
|
|
14
|
+
import { clearUntrustedMarkers, writeUntrustedMarkers } from '../sync/untrusted.ts';
|
|
15
|
+
|
|
16
|
+
let repoRoot: string;
|
|
17
|
+
let designRoot: string;
|
|
18
|
+
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
repoRoot = mkdtempSync(join(tmpdir(), 'untrusted-'));
|
|
21
|
+
designRoot = join(repoRoot, '.design');
|
|
22
|
+
mkdirSync(designRoot, { recursive: true });
|
|
23
|
+
});
|
|
24
|
+
afterEach(() => {
|
|
25
|
+
/* tmpdir — left for the OS to reap */
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
function ctxOf(): Context {
|
|
29
|
+
return { paths: { repoRoot, designRoot } } as unknown as Context;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function desc(slug: string, body: string): CanvasDescriptor {
|
|
33
|
+
return {
|
|
34
|
+
slug,
|
|
35
|
+
html: join(designRoot, body),
|
|
36
|
+
comments: join(designRoot, '_comments', `${slug}.json`),
|
|
37
|
+
annotations: join(designRoot, `${slug}.annotations.svg`),
|
|
38
|
+
meta: join(designRoot, body.replace(/\.(tsx|html)$/i, '.meta.json')),
|
|
39
|
+
css: join(designRoot, body.replace(/\.(tsx|html)$/i, '.css')),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const HUB = 'https://hub.example.com';
|
|
44
|
+
|
|
45
|
+
describe('writeUntrustedMarkers — T4.5', () => {
|
|
46
|
+
test('writes INDEX.json + a managed .claudeignore block for the synced set', () => {
|
|
47
|
+
const ctx = ctxOf();
|
|
48
|
+
writeUntrustedMarkers(ctx, [desc('ui-a', 'ui/a.tsx')], HUB);
|
|
49
|
+
|
|
50
|
+
const indexAbs = join(designRoot, '_untrusted', 'INDEX.json');
|
|
51
|
+
expect(existsSync(indexAbs)).toBe(true);
|
|
52
|
+
const index = JSON.parse(readFileSync(indexAbs, 'utf8'));
|
|
53
|
+
expect(index.hubUrl).toBe(HUB);
|
|
54
|
+
expect(index.canvases[0].slug).toBe('ui-a');
|
|
55
|
+
expect(index.canvases[0].body).toBe('.design/ui/a.tsx');
|
|
56
|
+
expect(index.canvases[0].meta).toBe('.design/ui/a.meta.json'); // Gap 2: meta is untrusted too
|
|
57
|
+
expect(index.canvases[0].css).toBe('.design/ui/a.css'); // Gap 3: css too
|
|
58
|
+
expect(index.note.toLowerCase()).toContain('untrusted');
|
|
59
|
+
|
|
60
|
+
const ci = readFileSync(join(repoRoot, '.claudeignore'), 'utf8');
|
|
61
|
+
expect(ci).toContain('maude:sync-untrusted begin');
|
|
62
|
+
expect(ci).toContain('.design/ui/a.tsx');
|
|
63
|
+
expect(ci).toContain('.design/ui-a.annotations.svg');
|
|
64
|
+
expect(ci).toContain('.design/ui/a.meta.json');
|
|
65
|
+
expect(ci).toContain('.design/ui/a.css');
|
|
66
|
+
expect(ci).toContain('maude:sync-untrusted end');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('re-write with a smaller set drops the stale entry', () => {
|
|
70
|
+
const ctx = ctxOf();
|
|
71
|
+
writeUntrustedMarkers(ctx, [desc('ui-a', 'ui/a.tsx'), desc('ui-b', 'ui/b.tsx')], HUB);
|
|
72
|
+
writeUntrustedMarkers(ctx, [desc('ui-a', 'ui/a.tsx')], HUB);
|
|
73
|
+
const ci = readFileSync(join(repoRoot, '.claudeignore'), 'utf8');
|
|
74
|
+
expect(ci).toContain('.design/ui/a.tsx');
|
|
75
|
+
expect(ci).not.toContain('.design/ui/b.tsx');
|
|
76
|
+
// Only one managed block (no accumulation).
|
|
77
|
+
expect(ci.match(/maude:sync-untrusted begin/g)?.length).toBe(1);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('empty set clears INDEX.json + the .claudeignore block', () => {
|
|
81
|
+
const ctx = ctxOf();
|
|
82
|
+
writeUntrustedMarkers(ctx, [desc('ui-a', 'ui/a.tsx')], HUB);
|
|
83
|
+
writeUntrustedMarkers(ctx, [], HUB);
|
|
84
|
+
expect(existsSync(join(designRoot, '_untrusted', 'INDEX.json'))).toBe(false);
|
|
85
|
+
// .claudeignore had only our block → removed entirely.
|
|
86
|
+
expect(existsSync(join(repoRoot, '.claudeignore'))).toBe(false);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('preserves user-authored .claudeignore content outside the block', () => {
|
|
90
|
+
writeFileSync(join(repoRoot, '.claudeignore'), 'secrets/\n*.key\n');
|
|
91
|
+
const ctx = ctxOf();
|
|
92
|
+
writeUntrustedMarkers(ctx, [desc('ui-a', 'ui/a.tsx')], HUB);
|
|
93
|
+
let ci = readFileSync(join(repoRoot, '.claudeignore'), 'utf8');
|
|
94
|
+
expect(ci).toContain('secrets/');
|
|
95
|
+
expect(ci).toContain('*.key');
|
|
96
|
+
expect(ci).toContain('.design/ui/a.tsx');
|
|
97
|
+
// Clearing leaves the user content intact.
|
|
98
|
+
clearUntrustedMarkers(ctx);
|
|
99
|
+
ci = readFileSync(join(repoRoot, '.claudeignore'), 'utf8');
|
|
100
|
+
expect(ci).toContain('secrets/');
|
|
101
|
+
expect(ci).not.toContain('.design/ui/a.tsx');
|
|
102
|
+
expect(ci).not.toContain('maude:sync-untrusted');
|
|
103
|
+
});
|
|
104
|
+
});
|
|
Binary file
|
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
} from '../use-tool-mode.tsx';
|
|
13
13
|
|
|
14
14
|
describe('use-tool-mode / static', () => {
|
|
15
|
-
test('DEFAULT_TOOLS exposes V/H/C +
|
|
15
|
+
test('DEFAULT_TOOLS exposes V/H/C + draw set B/R/O/N/A/T/E (Phase 21 sticky+text)', () => {
|
|
16
16
|
expect(DEFAULT_TOOLS.map((t) => t.id)).toEqual([
|
|
17
17
|
'move',
|
|
18
18
|
'hand',
|
|
@@ -20,26 +20,54 @@ describe('use-tool-mode / static', () => {
|
|
|
20
20
|
'pen',
|
|
21
21
|
'rect',
|
|
22
22
|
'ellipse',
|
|
23
|
+
'sticky',
|
|
23
24
|
'arrow',
|
|
25
|
+
'text',
|
|
24
26
|
'eraser',
|
|
25
27
|
]);
|
|
26
|
-
expect(DEFAULT_TOOLS.map((t) => t.shortcut)).toEqual([
|
|
28
|
+
expect(DEFAULT_TOOLS.map((t) => t.shortcut)).toEqual([
|
|
29
|
+
'V',
|
|
30
|
+
'H',
|
|
31
|
+
'C',
|
|
32
|
+
'B',
|
|
33
|
+
'R',
|
|
34
|
+
'O',
|
|
35
|
+
'N',
|
|
36
|
+
'A',
|
|
37
|
+
'T',
|
|
38
|
+
'E',
|
|
39
|
+
]);
|
|
27
40
|
});
|
|
28
41
|
|
|
29
42
|
test('DEFAULT_TOOLS is immutable (Object.freeze applied)', () => {
|
|
30
43
|
expect(Object.isFrozen(DEFAULT_TOOLS)).toBe(true);
|
|
31
44
|
});
|
|
32
45
|
|
|
33
|
-
test('default cursors per tool', () => {
|
|
46
|
+
test('default cursors per tool (Phase 21 — custom SVG cursors + native fallback)', () => {
|
|
34
47
|
const byId = Object.fromEntries(DEFAULT_TOOLS.map((t) => [t.id, t.cursor]));
|
|
48
|
+
// Move keeps the system arrow; every other tool ships a data-URI SVG cursor
|
|
49
|
+
// that falls back to the right native cursor if the image is rejected.
|
|
35
50
|
expect(byId.move).toBe('default');
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
51
|
+
for (const id of [
|
|
52
|
+
'hand',
|
|
53
|
+
'comment',
|
|
54
|
+
'pen',
|
|
55
|
+
'rect',
|
|
56
|
+
'ellipse',
|
|
57
|
+
'sticky',
|
|
58
|
+
'arrow',
|
|
59
|
+
'text',
|
|
60
|
+
'eraser',
|
|
61
|
+
]) {
|
|
62
|
+
expect(byId[id]).toContain('url("data:image/svg+xml,');
|
|
63
|
+
expect(byId[id]).toContain('32'); // 32×32 cursor
|
|
64
|
+
}
|
|
65
|
+
// Native fallbacks are preserved after the custom URL.
|
|
66
|
+
expect(byId.hand).toMatch(/, grab$/);
|
|
67
|
+
expect(byId.text).toMatch(/, text$/);
|
|
68
|
+
expect(byId.eraser).toMatch(/, cell$/);
|
|
69
|
+
expect(byId.pen).toMatch(/, crosshair$/);
|
|
70
|
+
expect(byId.rect).toMatch(/, crosshair$/);
|
|
43
71
|
});
|
|
44
72
|
});
|
|
45
73
|
|
|
@@ -35,13 +35,13 @@ const PALETTE_CSS = `
|
|
|
35
35
|
transform: translateX(-50%);
|
|
36
36
|
display: flex;
|
|
37
37
|
align-items: stretch;
|
|
38
|
-
background: var(--
|
|
39
|
-
border: 1px solid var(--
|
|
38
|
+
background: var(--maude-chrome-bg-0, #ffffff);
|
|
39
|
+
border: 1px solid var(--maude-chrome-fg-0, #1c1917);
|
|
40
40
|
border-radius: 8px;
|
|
41
|
-
box-shadow: 0 6px 24px color-mix(in oklab,
|
|
42
|
-
font-family: var(--
|
|
41
|
+
box-shadow: 0 6px 24px var(--maude-chrome-shadow, color-mix(in oklab, #1c1917 10%, transparent));
|
|
42
|
+
font-family: var(--maude-chrome-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
|
|
43
43
|
font-size: 12px;
|
|
44
|
-
color: var(--
|
|
44
|
+
color: var(--maude-chrome-fg-0, #1a1a1a);
|
|
45
45
|
z-index: 6;
|
|
46
46
|
user-select: none;
|
|
47
47
|
/* Intentionally NO overflow:hidden — the zoom popover (.dc-tp-popover) is
|
|
@@ -56,7 +56,7 @@ const PALETTE_CSS = `
|
|
|
56
56
|
}
|
|
57
57
|
.dc-tool-palette .dc-tp-sep {
|
|
58
58
|
width: 1px;
|
|
59
|
-
background: var(--
|
|
59
|
+
background: var(--maude-chrome-border, rgba(0,0,0,0.08));
|
|
60
60
|
margin: 6px 0;
|
|
61
61
|
}
|
|
62
62
|
.dc-tool-palette button {
|
|
@@ -70,14 +70,14 @@ const PALETTE_CSS = `
|
|
|
70
70
|
display: inline-flex;
|
|
71
71
|
align-items: center;
|
|
72
72
|
justify-content: center;
|
|
73
|
-
color: var(--fg-1, rgba(40,30,20,0.75));
|
|
73
|
+
color: var(--maude-chrome-fg-1, rgba(40,30,20,0.75));
|
|
74
74
|
cursor: pointer;
|
|
75
75
|
position: relative;
|
|
76
76
|
transition: background-color 80ms linear, color 80ms linear;
|
|
77
77
|
}
|
|
78
78
|
.dc-tool-palette button:hover {
|
|
79
79
|
background: color-mix(in oklab, var(--maude-hud-accent, #d63b1f) 8%, transparent);
|
|
80
|
-
color: var(--fg-0, #1a1a1a);
|
|
80
|
+
color: var(--maude-chrome-fg-0, #1a1a1a);
|
|
81
81
|
}
|
|
82
82
|
.dc-tool-palette button:focus-visible {
|
|
83
83
|
outline: 2px solid var(--maude-hud-accent, #d63b1f);
|
|
@@ -117,7 +117,7 @@ const PALETTE_CSS = `
|
|
|
117
117
|
height: 6px;
|
|
118
118
|
background: var(--maude-hud-accent, #d63b1f);
|
|
119
119
|
border-radius: 1px;
|
|
120
|
-
box-shadow: 0 0 0 1px var(--bg-0, #ffffff);
|
|
120
|
+
box-shadow: 0 0 0 1px var(--maude-chrome-bg-0, #ffffff);
|
|
121
121
|
opacity: 0;
|
|
122
122
|
transition: opacity 50ms linear 50ms;
|
|
123
123
|
pointer-events: none;
|
|
@@ -132,7 +132,7 @@ const PALETTE_CSS = `
|
|
|
132
132
|
min-width: 56px;
|
|
133
133
|
padding: 0 8px;
|
|
134
134
|
font-variant-numeric: tabular-nums;
|
|
135
|
-
font-family: var(--
|
|
135
|
+
font-family: var(--maude-chrome-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
|
|
136
136
|
font-size: 11px;
|
|
137
137
|
letter-spacing: 0.04em;
|
|
138
138
|
border-radius: 6px;
|
|
@@ -145,16 +145,16 @@ const PALETTE_CSS = `
|
|
|
145
145
|
position: absolute;
|
|
146
146
|
right: 4px;
|
|
147
147
|
bottom: 44px;
|
|
148
|
-
background: var(--
|
|
149
|
-
border: 1px solid var(--
|
|
148
|
+
background: var(--maude-chrome-bg-0, #ffffff);
|
|
149
|
+
border: 1px solid var(--maude-chrome-fg-0, #1c1917);
|
|
150
150
|
border-radius: 8px;
|
|
151
|
-
box-shadow: 0 6px 24px color-mix(in oklab,
|
|
151
|
+
box-shadow: 0 6px 24px var(--maude-chrome-shadow, color-mix(in oklab, #1c1917 10%, transparent));
|
|
152
152
|
display: flex;
|
|
153
153
|
flex-direction: column;
|
|
154
154
|
padding: 4px;
|
|
155
155
|
min-width: 160px;
|
|
156
156
|
z-index: 7;
|
|
157
|
-
font-family: var(--
|
|
157
|
+
font-family: var(--maude-chrome-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
|
|
158
158
|
}
|
|
159
159
|
.dc-tp-popover button {
|
|
160
160
|
appearance: none;
|
|
@@ -171,7 +171,7 @@ const PALETTE_CSS = `
|
|
|
171
171
|
gap: 16px;
|
|
172
172
|
width: 100%;
|
|
173
173
|
}
|
|
174
|
-
.dc-tp-popover button:hover { background:
|
|
174
|
+
.dc-tp-popover button:hover { background: color-mix(in oklab, var(--maude-chrome-fg-0, #1c1917) 5%, transparent); }
|
|
175
175
|
.dc-tp-popover .dc-tp-kbd {
|
|
176
176
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
177
177
|
font-size: 11px;
|
|
@@ -189,7 +189,9 @@ function ensurePaletteStyles(): void {
|
|
|
189
189
|
}
|
|
190
190
|
|
|
191
191
|
const NAV_TOOLS = ['move', 'hand', 'comment'] as const;
|
|
192
|
-
|
|
192
|
+
// Phase 21 — sticky clusters with the paper primitives (after ellipse); text
|
|
193
|
+
// sits at the constructive end before the destructive eraser, which stays last.
|
|
194
|
+
const DRAW_TOOLS = ['pen', 'rect', 'ellipse', 'sticky', 'arrow', 'text', 'eraser'] as const;
|
|
193
195
|
|
|
194
196
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
195
197
|
// Component
|
|
@@ -23,11 +23,11 @@ const HUD_CSS = `
|
|
|
23
23
|
z-index: 7;
|
|
24
24
|
pointer-events: none;
|
|
25
25
|
padding: 6px 10px;
|
|
26
|
-
background: var(--bg-2, rgba(20, 20, 20, 0.85));
|
|
27
|
-
color: var(--fg-1, rgba(255, 255, 255, 0.85));
|
|
28
|
-
border: 1px solid var(--border
|
|
26
|
+
background: var(--maude-chrome-bg-2, rgba(20, 20, 20, 0.85));
|
|
27
|
+
color: var(--maude-chrome-fg-1, rgba(255, 255, 255, 0.85));
|
|
28
|
+
border: 1px solid var(--maude-chrome-border, rgba(255, 255, 255, 0.08));
|
|
29
29
|
border-radius: 6px;
|
|
30
|
-
font: 11px var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
|
|
30
|
+
font: 11px var(--maude-chrome-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
|
|
31
31
|
letter-spacing: 0.02em;
|
|
32
32
|
user-select: none;
|
|
33
33
|
opacity: 0;
|
|
@@ -203,14 +203,30 @@ interface StoreHost {
|
|
|
203
203
|
|
|
204
204
|
/**
|
|
205
205
|
* Prefer `window.top` so all canvas iframes (children of the dev-server
|
|
206
|
-
* shell) read + write the same Map. Falls back to `window`
|
|
207
|
-
* cross-origin —
|
|
208
|
-
*
|
|
206
|
+
* shell) read + write the same Map. Falls back to `window` when top is
|
|
207
|
+
* cross-origin — which IS the case under the T2 (9.1-A) segregated canvas
|
|
208
|
+
* origin: the canvas iframe and the shell are different origins, so any
|
|
209
|
+
* PROPERTY access on `window.top` throws SecurityError. Then `globalThis`
|
|
210
|
+
* (Node / Bun test runtime where window is absent).
|
|
211
|
+
*
|
|
212
|
+
* Note: merely reading `window.top` (the reference) never throws — only
|
|
213
|
+
* touching a property of a cross-origin window does. So the guard MUST poke a
|
|
214
|
+
* property inside the try, or the cross-origin throw escapes into React render
|
|
215
|
+
* (it did: the UndoStackProvider's useRef initializer crashed the whole canvas
|
|
216
|
+
* mount to 0 children). Under A1 each iframe falls back to its own `window`
|
|
217
|
+
* store — undo works per-session; cross-close/reopen sharing via window.top is
|
|
218
|
+
* intentionally dropped for the origin isolation (see DDR-0xx / phase-9.1).
|
|
209
219
|
*/
|
|
210
220
|
function getStoreHost(): StoreHost {
|
|
211
221
|
if (typeof window !== 'undefined') {
|
|
212
222
|
try {
|
|
213
|
-
|
|
223
|
+
const top = window.top;
|
|
224
|
+
if (top && top !== window) {
|
|
225
|
+
// Poke a property — throws if `top` is a cross-origin window.
|
|
226
|
+
void (top as unknown as StoreHost).__maude_undo_stacks;
|
|
227
|
+
return top as unknown as StoreHost;
|
|
228
|
+
}
|
|
229
|
+
return window as unknown as StoreHost;
|
|
214
230
|
} catch {
|
|
215
231
|
return window as unknown as StoreHost;
|
|
216
232
|
}
|
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
type EllipseStroke,
|
|
23
23
|
type PenStroke,
|
|
24
24
|
type RectStroke,
|
|
25
|
+
type StickyStroke,
|
|
25
26
|
type Stroke,
|
|
26
27
|
type StrokesStoreValue,
|
|
27
28
|
strokeBBox,
|
|
@@ -58,9 +59,17 @@ function ensureResizeStyles(): void {
|
|
|
58
59
|
|
|
59
60
|
type Corner = 'nw' | 'ne' | 'sw' | 'se' | 'ep1' | 'ep2';
|
|
60
61
|
|
|
61
|
-
/** Stroke types that expose resize handles
|
|
62
|
-
function isResizable(
|
|
63
|
-
|
|
62
|
+
/** Stroke types that expose resize handles. Text inherits its anchor bbox. */
|
|
63
|
+
function isResizable(
|
|
64
|
+
s: Stroke
|
|
65
|
+
): s is RectStroke | EllipseStroke | ArrowStroke | PenStroke | StickyStroke {
|
|
66
|
+
return (
|
|
67
|
+
s.tool === 'rect' ||
|
|
68
|
+
s.tool === 'ellipse' ||
|
|
69
|
+
s.tool === 'arrow' ||
|
|
70
|
+
s.tool === 'pen' ||
|
|
71
|
+
s.tool === 'sticky'
|
|
72
|
+
);
|
|
64
73
|
}
|
|
65
74
|
|
|
66
75
|
/**
|
|
@@ -75,7 +84,9 @@ function resizeStroke(
|
|
|
75
84
|
wx: number,
|
|
76
85
|
wy: number
|
|
77
86
|
): Partial<Stroke> | null {
|
|
78
|
-
if (start.tool === 'rect') {
|
|
87
|
+
if (start.tool === 'rect' || start.tool === 'sticky') {
|
|
88
|
+
// Sticky resizes exactly like a rect — it shares x / y / w / h. Text
|
|
89
|
+
// re-wraps inside the foreignObject automatically (no special handling).
|
|
79
90
|
const bbox = { x: start.x, y: start.y, w: start.w, h: start.h };
|
|
80
91
|
const left = corner === 'nw' || corner === 'sw' ? wx : bbox.x;
|
|
81
92
|
const right = corner === 'ne' || corner === 'se' ? wx : bbox.x + bbox.w;
|
|
@@ -86,7 +97,7 @@ function resizeStroke(
|
|
|
86
97
|
y: Math.min(top, bottom),
|
|
87
98
|
w: Math.abs(right - left),
|
|
88
99
|
h: Math.abs(bottom - top),
|
|
89
|
-
} as Partial<RectStroke>;
|
|
100
|
+
} as Partial<RectStroke | StickyStroke>;
|
|
90
101
|
}
|
|
91
102
|
if (start.tool === 'ellipse') {
|
|
92
103
|
// Treat the four corners as the bbox of the ellipse. Drag any corner →
|
|
@@ -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
|
}
|