From c1241ee2e85c66ca37bcbd0f245cc4c18567362d Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Sat, 18 Apr 2026 12:08:44 -0700 Subject: [PATCH 01/41] feat(collab): add packages/shared/collab protocol contract (Slice 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Zero-knowledge room collaboration primitives for Plannotator Live Rooms. No server, no UI — types, crypto, and helpers with 84 tests. - HKDF key derivation (auth, event, presence, admin keys) - HMAC verifier/proof generation with null-byte delimited inputs - AES-256-GCM encrypt/decrypt for event, presence, and snapshot channels - Canonical JSON for admin proof binding - Base64url encoding with padding normalization - Room URL parsing (client-only barrel) and construction - Annotation image stripping for V1 room compatibility - Server-safe vs client barrel exports For provenance purposes, this commit was AI assisted. --- packages/shared/collab/canonical-json.test.ts | 105 ++++++ packages/shared/collab/canonical-json.ts | 48 +++ packages/shared/collab/client.ts | 10 + packages/shared/collab/constants.ts | 4 + packages/shared/collab/crypto.test.ts | 356 ++++++++++++++++++ packages/shared/collab/crypto.ts | 299 +++++++++++++++ packages/shared/collab/encoding.test.ts | 77 ++++ packages/shared/collab/encoding.ts | 37 ++ packages/shared/collab/ids.test.ts | 73 ++++ packages/shared/collab/ids.ts | 47 +++ packages/shared/collab/index.ts | 18 + packages/shared/collab/strip-images.test.ts | 95 +++++ packages/shared/collab/strip-images.ts | 22 ++ packages/shared/collab/types.ts | 226 +++++++++++ packages/shared/collab/url.test.ts | 92 +++++ packages/shared/collab/url.ts | 72 ++++ packages/shared/package.json | 4 +- specs/done/v1-slice1-plan.md | 263 +++++++++++++ 18 files changed, 1847 insertions(+), 1 deletion(-) create mode 100644 packages/shared/collab/canonical-json.test.ts create mode 100644 packages/shared/collab/canonical-json.ts create mode 100644 packages/shared/collab/client.ts create mode 100644 packages/shared/collab/constants.ts create mode 100644 packages/shared/collab/crypto.test.ts create mode 100644 packages/shared/collab/crypto.ts create mode 100644 packages/shared/collab/encoding.test.ts create mode 100644 packages/shared/collab/encoding.ts create mode 100644 packages/shared/collab/ids.test.ts create mode 100644 packages/shared/collab/ids.ts create mode 100644 packages/shared/collab/index.ts create mode 100644 packages/shared/collab/strip-images.test.ts create mode 100644 packages/shared/collab/strip-images.ts create mode 100644 packages/shared/collab/types.ts create mode 100644 packages/shared/collab/url.test.ts create mode 100644 packages/shared/collab/url.ts create mode 100644 specs/done/v1-slice1-plan.md diff --git a/packages/shared/collab/canonical-json.test.ts b/packages/shared/collab/canonical-json.test.ts new file mode 100644 index 00000000..190dd694 --- /dev/null +++ b/packages/shared/collab/canonical-json.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, test } from 'bun:test'; +import { canonicalJson } from './canonical-json'; + +describe('canonicalJson', () => { + test('serializes null', () => { + expect(canonicalJson(null)).toBe('null'); + }); + + test('serializes booleans', () => { + expect(canonicalJson(true)).toBe('true'); + expect(canonicalJson(false)).toBe('false'); + }); + + test('serializes numbers', () => { + expect(canonicalJson(42)).toBe('42'); + expect(canonicalJson(-3.14)).toBe('-3.14'); + expect(canonicalJson(0)).toBe('0'); + }); + + test('serializes strings', () => { + expect(canonicalJson('hello')).toBe('"hello"'); + expect(canonicalJson('')).toBe('""'); + expect(canonicalJson('has "quotes"')).toBe('"has \\"quotes\\""'); + }); + + test('serializes arrays preserving order', () => { + expect(canonicalJson([3, 1, 2])).toBe('[3,1,2]'); + expect(canonicalJson([])).toBe('[]'); + expect(canonicalJson(['b', 'a'])).toBe('["b","a"]'); + }); + + test('sorts object keys lexicographically', () => { + expect(canonicalJson({ z: 1, a: 2, m: 3 })).toBe('{"a":2,"m":3,"z":1}'); + }); + + test('sorts nested object keys at every level', () => { + const input = { b: { d: 1, c: 2 }, a: { f: 3, e: 4 } }; + expect(canonicalJson(input)).toBe('{"a":{"e":4,"f":3},"b":{"c":2,"d":1}}'); + }); + + test('omits undefined fields', () => { + expect(canonicalJson({ a: 1, b: undefined, c: 3 })).toBe('{"a":1,"c":3}'); + }); + + test('handles undefined as top-level value', () => { + expect(canonicalJson(undefined)).toBe('null'); + }); + + test('produces no whitespace', () => { + const result = canonicalJson({ key: [1, { nested: true }] }); + expect(result).not.toContain(' '); + expect(result).not.toContain('\n'); + expect(result).not.toContain('\t'); + }); + + test('throws on NaN', () => { + expect(() => canonicalJson(NaN)).toThrow('not serializable'); + }); + + test('throws on Infinity', () => { + expect(() => canonicalJson(Infinity)).toThrow('not serializable'); + expect(() => canonicalJson(-Infinity)).toThrow('not serializable'); + }); + + test('throws on functions', () => { + expect(() => canonicalJson(() => {})).toThrow('not serializable'); + }); + + test('throws on symbols', () => { + expect(() => canonicalJson(Symbol('test'))).toThrow('not serializable'); + }); + + test('throws on bigint', () => { + expect(() => canonicalJson(BigInt(42))).toThrow('not serializable'); + }); + + // Known-output test vectors — security-critical stability tests + describe('test vectors', () => { + test('AdminCommand room.lock', () => { + expect(canonicalJson({ type: 'room.lock' })).toBe('{"type":"room.lock"}'); + }); + + test('AdminCommand room.lock with snapshot', () => { + expect(canonicalJson({ type: 'room.lock', finalSnapshotCiphertext: 'abc' })) + .toBe('{"finalSnapshotCiphertext":"abc","type":"room.lock"}'); + }); + + test('AdminCommand room.unlock', () => { + expect(canonicalJson({ type: 'room.unlock' })).toBe('{"type":"room.unlock"}'); + }); + + test('AdminCommand room.delete', () => { + expect(canonicalJson({ type: 'room.delete' })).toBe('{"type":"room.delete"}'); + }); + + test('same input always produces same output', () => { + const input = { type: 'room.lock', finalSnapshotCiphertext: 'data' }; + const first = canonicalJson(input); + const second = canonicalJson(input); + const third = canonicalJson({ finalSnapshotCiphertext: 'data', type: 'room.lock' }); + expect(first).toBe(second); + expect(first).toBe(third); + }); + }); +}); diff --git a/packages/shared/collab/canonical-json.ts b/packages/shared/collab/canonical-json.ts new file mode 100644 index 00000000..eecaecbd --- /dev/null +++ b/packages/shared/collab/canonical-json.ts @@ -0,0 +1,48 @@ +/** + * Deterministic JSON serialization for HMAC proof binding. + * + * Lexicographically sorted object keys at every nesting level, + * no whitespace, UTF-8 bytes. Arrays preserve order. + * undefined fields are omitted. Throws on functions, symbols, NaN, Infinity. + * + * This function is security-critical: its output is included in admin + * command HMAC proofs. Any change to its output for the same input is + * a protocol-breaking change. + */ +export function canonicalJson(value: unknown): string { + if (value === null) return 'null'; + if (value === undefined) return 'null'; + + const t = typeof value; + + if (t === 'boolean') return value ? 'true' : 'false'; + + if (t === 'number') { + if (!Number.isFinite(value as number)) { + throw new Error(`canonicalJson: ${value} is not serializable`); + } + return JSON.stringify(value); + } + + if (t === 'string') return JSON.stringify(value); + + if (t === 'function' || t === 'symbol' || t === 'bigint') { + throw new Error(`canonicalJson: ${t} is not serializable`); + } + + if (Array.isArray(value)) { + const elements = value.map(v => canonicalJson(v)); + return '[' + elements.join(',') + ']'; + } + + // Plain object — sort keys lexicographically + const obj = value as Record; + const keys = Object.keys(obj).sort(); + const entries: string[] = []; + for (const key of keys) { + const v = obj[key]; + if (v === undefined) continue; // omit undefined fields + entries.push(JSON.stringify(key) + ':' + canonicalJson(v)); + } + return '{' + entries.join(',') + '}'; +} diff --git a/packages/shared/collab/client.ts b/packages/shared/collab/client.ts new file mode 100644 index 00000000..c39e4902 --- /dev/null +++ b/packages/shared/collab/client.ts @@ -0,0 +1,10 @@ +/** + * Plannotator Live Rooms — client barrel export. + * + * Re-exports the server-safe barrel plus client-only URL helpers. + * This is the import path for browser and direct-agent clients: + * import { ..., parseRoomUrl, buildRoomJoinUrl } from '@plannotator/shared/collab/client' + */ + +export * from './index'; +export * from './url'; diff --git a/packages/shared/collab/constants.ts b/packages/shared/collab/constants.ts new file mode 100644 index 00000000..8124b03b --- /dev/null +++ b/packages/shared/collab/constants.ts @@ -0,0 +1,4 @@ +/** Plannotator Live Rooms protocol constants. */ + +/** Room and admin secrets are 256-bit raw byte values. */ +export const ROOM_SECRET_LENGTH_BYTES = 32; diff --git a/packages/shared/collab/crypto.test.ts b/packages/shared/collab/crypto.test.ts new file mode 100644 index 00000000..7a8f3176 --- /dev/null +++ b/packages/shared/collab/crypto.test.ts @@ -0,0 +1,356 @@ +import { describe, expect, test } from 'bun:test'; +import { + deriveRoomKeys, + deriveAdminKey, + computeRoomVerifier, + computeAdminVerifier, + computeAuthProof, + verifyAuthProof, + computeAdminProof, + verifyAdminProof, + encryptPayload, + decryptPayload, + encryptEventOp, + decryptEventPayload, + encryptPresence, + decryptPresence, + encryptSnapshot, + decryptSnapshot, +} from './crypto'; +import type { AdminCommand, PresenceState, RoomClientOp, RoomSnapshot } from './types'; + +// Stable test secret (32 bytes) +const TEST_SECRET = new Uint8Array(32); +TEST_SECRET.fill(0xab); + +const TEST_ADMIN_SECRET = new Uint8Array(32); +TEST_ADMIN_SECRET.fill(0xcd); + +const TEST_ROOM_ID = 'test-room-abc123'; + +// --------------------------------------------------------------------------- +// Key Derivation — tested via observable outputs +// --------------------------------------------------------------------------- + +describe('deriveRoomKeys', () => { + test('rejects non-256-bit room secrets', async () => { + await expect(deriveRoomKeys(new Uint8Array(31))).rejects.toThrow('Invalid room secret'); + await expect(deriveRoomKeys(new Uint8Array(33))).rejects.toThrow('Invalid room secret'); + }); + + test('same secret produces same verifier (deterministic)', async () => { + const keys1 = await deriveRoomKeys(TEST_SECRET); + const keys2 = await deriveRoomKeys(TEST_SECRET); + + const v1 = await computeRoomVerifier(keys1.authKey, TEST_ROOM_ID); + const v2 = await computeRoomVerifier(keys2.authKey, TEST_ROOM_ID); + expect(v1).toBe(v2); + }); + + test('derives from the Uint8Array view, not the entire backing buffer', async () => { + const backing = new Uint8Array(96); + backing.fill(0xee); + backing.set(TEST_SECRET, 32); + const secretView = backing.subarray(32, 64); + + const keys1 = await deriveRoomKeys(TEST_SECRET); + const keys2 = await deriveRoomKeys(secretView); + + const v1 = await computeRoomVerifier(keys1.authKey, TEST_ROOM_ID); + const v2 = await computeRoomVerifier(keys2.authKey, TEST_ROOM_ID); + expect(v1).toBe(v2); + }); + + test('different secrets produce different verifiers', async () => { + const secret2 = new Uint8Array(32); + secret2.fill(0x99); + + const keys1 = await deriveRoomKeys(TEST_SECRET); + const keys2 = await deriveRoomKeys(secret2); + + const v1 = await computeRoomVerifier(keys1.authKey, TEST_ROOM_ID); + const v2 = await computeRoomVerifier(keys2.authKey, TEST_ROOM_ID); + expect(v1).not.toBe(v2); + }); + + test('different labels produce different keys (cross-key isolation)', async () => { + const { eventKey, presenceKey } = await deriveRoomKeys(TEST_SECRET); + + // Encrypt with event key, try to decrypt with presence key — should fail + const ciphertext = await encryptPayload(eventKey, 'secret message'); + await expect(decryptPayload(presenceKey, ciphertext)).rejects.toThrow(); + }); +}); + +describe('deriveAdminKey', () => { + test('rejects non-256-bit admin secrets', async () => { + await expect(deriveAdminKey(new Uint8Array(31))).rejects.toThrow('Invalid admin secret'); + await expect(deriveAdminKey(new Uint8Array(33))).rejects.toThrow('Invalid admin secret'); + }); + + test('same secret produces same admin verifier', async () => { + const key1 = await deriveAdminKey(TEST_ADMIN_SECRET); + const key2 = await deriveAdminKey(TEST_ADMIN_SECRET); + + const v1 = await computeAdminVerifier(key1, TEST_ROOM_ID); + const v2 = await computeAdminVerifier(key2, TEST_ROOM_ID); + expect(v1).toBe(v2); + }); + + test('derives admin key from the Uint8Array view, not the entire backing buffer', async () => { + const backing = new Uint8Array(96); + backing.fill(0xee); + backing.set(TEST_ADMIN_SECRET, 32); + const secretView = backing.subarray(32, 64); + + const key1 = await deriveAdminKey(TEST_ADMIN_SECRET); + const key2 = await deriveAdminKey(secretView); + + const v1 = await computeAdminVerifier(key1, TEST_ROOM_ID); + const v2 = await computeAdminVerifier(key2, TEST_ROOM_ID); + expect(v1).toBe(v2); + }); +}); + +// --------------------------------------------------------------------------- +// Verifiers +// --------------------------------------------------------------------------- + +describe('computeRoomVerifier', () => { + test('different roomIds produce different verifiers', async () => { + const { authKey } = await deriveRoomKeys(TEST_SECRET); + const v1 = await computeRoomVerifier(authKey, 'room-a'); + const v2 = await computeRoomVerifier(authKey, 'room-b'); + expect(v1).not.toBe(v2); + }); +}); + +// --------------------------------------------------------------------------- +// Auth Proofs +// --------------------------------------------------------------------------- + +describe('auth proof', () => { + test('compute and verify round-trip', async () => { + const { authKey } = await deriveRoomKeys(TEST_SECRET); + const verifier = await computeRoomVerifier(authKey, TEST_ROOM_ID); + + const proof = await computeAuthProof(verifier, TEST_ROOM_ID, 'client-1', 'ch_abc', 'nonce123'); + const valid = await verifyAuthProof(verifier, TEST_ROOM_ID, 'client-1', 'ch_abc', 'nonce123', proof); + expect(valid).toBe(true); + }); + + test('wrong clientId rejects', async () => { + const { authKey } = await deriveRoomKeys(TEST_SECRET); + const verifier = await computeRoomVerifier(authKey, TEST_ROOM_ID); + + const proof = await computeAuthProof(verifier, TEST_ROOM_ID, 'client-1', 'ch_abc', 'nonce123'); + const valid = await verifyAuthProof(verifier, TEST_ROOM_ID, 'client-2', 'ch_abc', 'nonce123', proof); + expect(valid).toBe(false); + }); + + test('wrong nonce rejects', async () => { + const { authKey } = await deriveRoomKeys(TEST_SECRET); + const verifier = await computeRoomVerifier(authKey, TEST_ROOM_ID); + + const proof = await computeAuthProof(verifier, TEST_ROOM_ID, 'client-1', 'ch_abc', 'nonce123'); + const valid = await verifyAuthProof(verifier, TEST_ROOM_ID, 'client-1', 'ch_abc', 'wrong-nonce', proof); + expect(valid).toBe(false); + }); + + test('wrong verifier rejects', async () => { + const { authKey } = await deriveRoomKeys(TEST_SECRET); + const verifier = await computeRoomVerifier(authKey, TEST_ROOM_ID); + + const secret2 = new Uint8Array(32); + secret2.fill(0x11); + const keys2 = await deriveRoomKeys(secret2); + const wrongVerifier = await computeRoomVerifier(keys2.authKey, TEST_ROOM_ID); + + const proof = await computeAuthProof(verifier, TEST_ROOM_ID, 'client-1', 'ch_abc', 'nonce123'); + const valid = await verifyAuthProof(wrongVerifier, TEST_ROOM_ID, 'client-1', 'ch_abc', 'nonce123', proof); + expect(valid).toBe(false); + }); + + test('malformed proof rejects without throwing', async () => { + const { authKey } = await deriveRoomKeys(TEST_SECRET); + const verifier = await computeRoomVerifier(authKey, TEST_ROOM_ID); + + await expect(verifyAuthProof(verifier, TEST_ROOM_ID, 'client-1', 'ch_abc', 'nonce123', 'A')) + .resolves.toBe(false); + await expect(verifyAuthProof(verifier, TEST_ROOM_ID, 'client-1', 'ch_abc', 'nonce123', '!!!!')) + .resolves.toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// Admin Proofs +// --------------------------------------------------------------------------- + +describe('admin proof', () => { + test('compute and verify round-trip', async () => { + const adminKey = await deriveAdminKey(TEST_ADMIN_SECRET); + const verifier = await computeAdminVerifier(adminKey, TEST_ROOM_ID); + const command: AdminCommand = { type: 'room.lock' }; + + const proof = await computeAdminProof(verifier, TEST_ROOM_ID, 'client-1', 'ch_xyz', 'nonce456', command); + const valid = await verifyAdminProof(verifier, TEST_ROOM_ID, 'client-1', 'ch_xyz', 'nonce456', command, proof); + expect(valid).toBe(true); + }); + + test('wrong command rejects (lock proof cannot be used as delete)', async () => { + const adminKey = await deriveAdminKey(TEST_ADMIN_SECRET); + const verifier = await computeAdminVerifier(adminKey, TEST_ROOM_ID); + const lockCommand: AdminCommand = { type: 'room.lock' }; + const deleteCommand: AdminCommand = { type: 'room.delete' }; + + const proof = await computeAdminProof(verifier, TEST_ROOM_ID, 'client-1', 'ch_xyz', 'nonce456', lockCommand); + const valid = await verifyAdminProof(verifier, TEST_ROOM_ID, 'client-1', 'ch_xyz', 'nonce456', deleteCommand, proof); + expect(valid).toBe(false); + }); + + test('proof is bound to canonicalJson(command)', async () => { + const adminKey = await deriveAdminKey(TEST_ADMIN_SECRET); + const verifier = await computeAdminVerifier(adminKey, TEST_ROOM_ID); + + // Same command with finalSnapshotCiphertext + const cmd1: AdminCommand = { type: 'room.lock', finalSnapshotCiphertext: 'aaa' }; + const cmd2: AdminCommand = { type: 'room.lock', finalSnapshotCiphertext: 'bbb' }; + + const proof = await computeAdminProof(verifier, TEST_ROOM_ID, 'client-1', 'ch_xyz', 'n', cmd1); + const valid = await verifyAdminProof(verifier, TEST_ROOM_ID, 'client-1', 'ch_xyz', 'n', cmd2, proof); + expect(valid).toBe(false); + }); + + test('malformed proof rejects without throwing', async () => { + const adminKey = await deriveAdminKey(TEST_ADMIN_SECRET); + const verifier = await computeAdminVerifier(adminKey, TEST_ROOM_ID); + const command: AdminCommand = { type: 'room.lock' }; + + await expect(verifyAdminProof(verifier, TEST_ROOM_ID, 'client-1', 'ch_xyz', 'nonce456', command, 'A')) + .resolves.toBe(false); + await expect(verifyAdminProof(verifier, TEST_ROOM_ID, 'client-1', 'ch_xyz', 'nonce456', command, '!!!!')) + .resolves.toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// AES-256-GCM Encrypt / Decrypt +// --------------------------------------------------------------------------- + +describe('encryptPayload / decryptPayload', () => { + test('round-trip', async () => { + const { eventKey } = await deriveRoomKeys(TEST_SECRET); + const plaintext = 'hello, encrypted world!'; + const ciphertext = await encryptPayload(eventKey, plaintext); + const decrypted = await decryptPayload(eventKey, ciphertext); + expect(decrypted).toBe(plaintext); + }); + + test('unique ciphertext per call (fresh IV)', async () => { + const { eventKey } = await deriveRoomKeys(TEST_SECRET); + const plaintext = 'same input'; + const ct1 = await encryptPayload(eventKey, plaintext); + const ct2 = await encryptPayload(eventKey, plaintext); + expect(ct1).not.toBe(ct2); + }); + + test('wrong key fails', async () => { + const keys1 = await deriveRoomKeys(TEST_SECRET); + const secret2 = new Uint8Array(32); + secret2.fill(0x77); + const keys2 = await deriveRoomKeys(secret2); + + const ciphertext = await encryptPayload(keys1.eventKey, 'secret'); + await expect(decryptPayload(keys2.eventKey, ciphertext)).rejects.toThrow(); + }); + + test('tampered ciphertext fails', async () => { + const { eventKey } = await deriveRoomKeys(TEST_SECRET); + const ciphertext = await encryptPayload(eventKey, 'secret'); + + // Flip a character in the middle + const tampered = ciphertext.slice(0, 20) + 'X' + ciphertext.slice(21); + await expect(decryptPayload(eventKey, tampered)).rejects.toThrow(); + }); +}); + +// --------------------------------------------------------------------------- +// Channel Wrappers +// --------------------------------------------------------------------------- + +describe('encryptEventOp / decryptEventPayload', () => { + test('round-trip with annotation.add', async () => { + const { eventKey } = await deriveRoomKeys(TEST_SECRET); + const op: RoomClientOp = { + type: 'annotation.add', + annotations: [{ + id: 'ann-1', + blockId: 'b1', + startOffset: 0, + endOffset: 10, + type: 'COMMENT', + originalText: 'hello', + createdA: Date.now(), + text: 'my comment', + }], + }; + + const ciphertext = await encryptEventOp(eventKey, op); + const decrypted = await decryptEventPayload(eventKey, ciphertext); + expect(decrypted).toEqual(op); + }); +}); + +describe('encryptPresence / decryptPresence', () => { + test('round-trip', async () => { + const { presenceKey } = await deriveRoomKeys(TEST_SECRET); + const presence: PresenceState = { + user: { id: 'user-1', name: 'swift-falcon-tater', color: '#ff0000' }, + cursor: { blockId: 'block-3', x: 100, y: 200, coordinateSpace: 'block' }, + activeAnnotationId: 'ann-5', + idle: false, + }; + + const ciphertext = await encryptPresence(presenceKey, presence); + const decrypted = await decryptPresence(presenceKey, ciphertext); + expect(decrypted).toEqual(presence); + }); +}); + +describe('encryptSnapshot / decryptSnapshot', () => { + test('round-trip with real RoomSnapshot', async () => { + const { eventKey } = await deriveRoomKeys(TEST_SECRET); + const snapshot: RoomSnapshot = { + versionId: 'v1', + planMarkdown: '# My Plan\n\nStep 1: do the thing\nStep 2: profit', + annotations: [ + { + id: 'ann-1', + blockId: 'b1', + startOffset: 0, + endOffset: 5, + type: 'COMMENT', + text: 'nice plan', + originalText: 'My Plan', + createdA: 1234567890, + author: 'alice', + }, + { + id: 'ann-2', + blockId: 'b2', + startOffset: 0, + endOffset: 13, + type: 'DELETION', + originalText: 'do the thing', + createdA: 1234567891, + }, + ], + }; + + const ciphertext = await encryptSnapshot(eventKey, snapshot); + const decrypted = await decryptSnapshot(eventKey, ciphertext); + expect(decrypted).toEqual(snapshot); + expect(decrypted.versionId).toBe('v1'); + expect(decrypted.annotations.length).toBe(2); + }); +}); diff --git a/packages/shared/collab/crypto.ts b/packages/shared/collab/crypto.ts new file mode 100644 index 00000000..f1c44bab --- /dev/null +++ b/packages/shared/collab/crypto.ts @@ -0,0 +1,299 @@ +/** + * Plannotator Live Rooms — cryptographic primitives. + * + * HKDF key derivation, HMAC verifier/proof generation, and AES-256-GCM + * encrypt/decrypt for event, presence, and snapshot channels. + * + * Uses only Web Crypto API (crypto.subtle) — works in browsers, Bun, + * and Cloudflare Workers. + * + * Protocol decisions: + * - HKDF uses SHA-256 with a zero-filled 32-byte salt (standard when + * no application-specific salt is provided). + * - HMAC input concatenation uses null byte (\0) separators between + * components to prevent ambiguity. See specs/v1.md. + * - AES-GCM uses a 12-byte random IV prepended to ciphertext. + */ + +import { bytesToBase64url, base64urlToBytes } from './encoding'; +import { canonicalJson } from './canonical-json'; +import { ROOM_SECRET_LENGTH_BYTES } from './constants'; +import type { AdminCommand, PresenceState, RoomClientOp, RoomSnapshot } from './types'; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const HKDF_SALT = new Uint8Array(32); // zero-filled, per protocol spec +const IV_LENGTH = 12; + +const LABELS = { + auth: 'plannotator:v1:room-auth', + event: 'plannotator:v1:event', + presence: 'plannotator:v1:presence', + admin: 'plannotator:v1:room-admin', + roomVerifier: 'plannotator:v1:room-verifier:', + adminVerifier: 'plannotator:v1:admin-verifier:', + authProof: 'plannotator:v1:auth-proof', + adminProof: 'plannotator:v1:admin-proof', +} as const; + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); + +/** Copy a Uint8Array view into an exact ArrayBuffer for Web Crypto APIs. */ +function toArrayBuffer(bytes: Uint8Array): ArrayBuffer { + const copy = new Uint8Array(bytes.byteLength); + copy.set(bytes); + return copy.buffer; +} + +/** Import raw secret bytes as HKDF key material. */ +async function importKeyMaterial(secret: Uint8Array): Promise { + return crypto.subtle.importKey('raw', toArrayBuffer(secret), 'HKDF', false, ['deriveKey']); +} + +/** Derive an HMAC-SHA-256 key via HKDF. */ +async function deriveHmacKey(material: CryptoKey, info: string): Promise { + return crypto.subtle.deriveKey( + { name: 'HKDF', hash: 'SHA-256', salt: HKDF_SALT, info: encoder.encode(info) }, + material, + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['sign', 'verify'], + ); +} + +/** Derive an AES-256-GCM key via HKDF. */ +async function deriveAesKey(material: CryptoKey, info: string): Promise { + return crypto.subtle.deriveKey( + { name: 'HKDF', hash: 'SHA-256', salt: HKDF_SALT, info: encoder.encode(info) }, + material, + { name: 'AES-GCM', length: 256 }, + false, + ['encrypt', 'decrypt'], + ); +} + +/** + * Concatenate string components with null byte separators. + * Returns UTF-8 encoded bytes for HMAC input. + */ +function concatComponents(...components: string[]): Uint8Array { + return encoder.encode(components.join('\0')); +} + +/** Import a base64url-encoded verifier as an HMAC signing key. */ +async function importVerifierAsKey(verifierB64: string): Promise { + const bytes = base64urlToBytes(verifierB64); + return crypto.subtle.importKey( + 'raw', + toArrayBuffer(bytes), + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['sign', 'verify'], + ); +} + +/** HMAC-SHA-256 sign and return base64url. */ +async function hmacSign(key: CryptoKey, data: Uint8Array): Promise { + const sig = await crypto.subtle.sign('HMAC', key, toArrayBuffer(data)); + return bytesToBase64url(new Uint8Array(sig)); +} + +/** HMAC-SHA-256 verify. */ +async function hmacVerify(key: CryptoKey, data: Uint8Array, signature: string): Promise { + const sigBytes = base64urlToBytes(signature); + return crypto.subtle.verify('HMAC', key, toArrayBuffer(sigBytes), toArrayBuffer(data)); +} + +// --------------------------------------------------------------------------- +// Key Derivation +// --------------------------------------------------------------------------- + +/** Derive all room keys from a room secret. */ +export async function deriveRoomKeys(roomSecret: Uint8Array): Promise<{ + authKey: CryptoKey; + eventKey: CryptoKey; + presenceKey: CryptoKey; +}> { + if (roomSecret.length !== ROOM_SECRET_LENGTH_BYTES) { + throw new Error(`Invalid room secret: expected ${ROOM_SECRET_LENGTH_BYTES} bytes`); + } + const material = await importKeyMaterial(roomSecret); + const [authKey, eventKey, presenceKey] = await Promise.all([ + deriveHmacKey(material, LABELS.auth), + deriveAesKey(material, LABELS.event), + deriveAesKey(material, LABELS.presence), + ]); + return { authKey, eventKey, presenceKey }; +} + +/** Derive the admin HMAC key from an admin secret. */ +export async function deriveAdminKey(adminSecret: Uint8Array): Promise { + if (adminSecret.length !== ROOM_SECRET_LENGTH_BYTES) { + throw new Error(`Invalid admin secret: expected ${ROOM_SECRET_LENGTH_BYTES} bytes`); + } + const material = await importKeyMaterial(adminSecret); + return deriveHmacKey(material, LABELS.admin); +} + +// --------------------------------------------------------------------------- +// Verifiers +// --------------------------------------------------------------------------- + +/** Compute roomVerifier = HMAC(authKey, "plannotator:v1:room-verifier:" \0 roomId) */ +export async function computeRoomVerifier(authKey: CryptoKey, roomId: string): Promise { + return hmacSign(authKey, concatComponents(LABELS.roomVerifier, roomId)); +} + +/** Compute adminVerifier = HMAC(adminKey, "plannotator:v1:admin-verifier:" \0 roomId) */ +export async function computeAdminVerifier(adminKey: CryptoKey, roomId: string): Promise { + return hmacSign(adminKey, concatComponents(LABELS.adminVerifier, roomId)); +} + +// --------------------------------------------------------------------------- +// Auth Proofs +// --------------------------------------------------------------------------- + +/** Compute auth proof for WebSocket connection. */ +export async function computeAuthProof( + roomVerifier: string, + roomId: string, + clientId: string, + challengeId: string, + nonce: string, +): Promise { + const key = await importVerifierAsKey(roomVerifier); + return hmacSign(key, concatComponents(LABELS.authProof, roomId, clientId, challengeId, nonce)); +} + +/** Verify an auth proof against the stored room verifier. */ +export async function verifyAuthProof( + roomVerifier: string, + roomId: string, + clientId: string, + challengeId: string, + nonce: string, + proof: string, +): Promise { + try { + const key = await importVerifierAsKey(roomVerifier); + return await hmacVerify(key, concatComponents(LABELS.authProof, roomId, clientId, challengeId, nonce), proof); + } catch { + return false; + } +} + +// --------------------------------------------------------------------------- +// Admin Proofs +// --------------------------------------------------------------------------- + +/** Compute admin proof for an admin command. */ +export async function computeAdminProof( + adminVerifier: string, + roomId: string, + clientId: string, + challengeId: string, + nonce: string, + command: AdminCommand, +): Promise { + const key = await importVerifierAsKey(adminVerifier); + const data = concatComponents( + LABELS.adminProof, roomId, clientId, challengeId, nonce, canonicalJson(command), + ); + return hmacSign(key, data); +} + +/** Verify an admin command proof. */ +export async function verifyAdminProof( + adminVerifier: string, + roomId: string, + clientId: string, + challengeId: string, + nonce: string, + command: AdminCommand, + proof: string, +): Promise { + try { + const key = await importVerifierAsKey(adminVerifier); + const data = concatComponents( + LABELS.adminProof, roomId, clientId, challengeId, nonce, canonicalJson(command), + ); + return await hmacVerify(key, data, proof); + } catch { + return false; + } +} + +// --------------------------------------------------------------------------- +// AES-256-GCM Encrypt / Decrypt +// --------------------------------------------------------------------------- + +/** Encrypt plaintext with AES-256-GCM. Returns base64url(IV || ciphertext+tag). */ +export async function encryptPayload(key: CryptoKey, plaintext: string): Promise { + const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH)); + const encrypted = await crypto.subtle.encrypt( + { name: 'AES-GCM', iv }, + key, + encoder.encode(plaintext), + ); + const combined = new Uint8Array(iv.length + encrypted.byteLength); + combined.set(iv, 0); + combined.set(new Uint8Array(encrypted), iv.length); + return bytesToBase64url(combined); +} + +/** Decrypt base64url(IV || ciphertext+tag) with AES-256-GCM. Returns plaintext string. */ +export async function decryptPayload(key: CryptoKey, ciphertext: string): Promise { + const combined = base64urlToBytes(ciphertext); + const iv = combined.slice(0, IV_LENGTH); + const encrypted = combined.slice(IV_LENGTH); + const decrypted = await crypto.subtle.decrypt( + { name: 'AES-GCM', iv }, + key, + encrypted, + ); + return decoder.decode(decrypted); +} + +// --------------------------------------------------------------------------- +// Channel convenience wrappers +// --------------------------------------------------------------------------- + +/** Encrypt a RoomClientOp for the event channel. */ +export async function encryptEventOp(eventKey: CryptoKey, op: RoomClientOp): Promise { + return encryptPayload(eventKey, JSON.stringify(op)); +} + +/** Decrypt an event channel ciphertext. */ +export async function decryptEventPayload(eventKey: CryptoKey, ciphertext: string): Promise { + const plaintext = await decryptPayload(eventKey, ciphertext); + return JSON.parse(plaintext); +} + +/** Encrypt a PresenceState for the presence channel. */ +export async function encryptPresence(presenceKey: CryptoKey, presence: PresenceState): Promise { + return encryptPayload(presenceKey, JSON.stringify(presence)); +} + +/** Decrypt a presence channel ciphertext. */ +export async function decryptPresence(presenceKey: CryptoKey, ciphertext: string): Promise { + const plaintext = await decryptPayload(presenceKey, ciphertext); + return JSON.parse(plaintext) as PresenceState; +} + +/** Encrypt a RoomSnapshot with the event key. */ +export async function encryptSnapshot(eventKey: CryptoKey, snapshot: RoomSnapshot): Promise { + return encryptPayload(eventKey, JSON.stringify(snapshot)); +} + +/** Decrypt a snapshot ciphertext. */ +export async function decryptSnapshot(eventKey: CryptoKey, ciphertext: string): Promise { + const plaintext = await decryptPayload(eventKey, ciphertext); + return JSON.parse(plaintext) as RoomSnapshot; +} diff --git a/packages/shared/collab/encoding.test.ts b/packages/shared/collab/encoding.test.ts new file mode 100644 index 00000000..7848c31e --- /dev/null +++ b/packages/shared/collab/encoding.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, test } from 'bun:test'; +import { bytesToBase64url, base64urlToBytes } from './encoding'; + +describe('bytesToBase64url', () => { + test('encodes empty input', () => { + expect(bytesToBase64url(new Uint8Array(0))).toBe(''); + }); + + test('encodes single byte', () => { + const result = bytesToBase64url(new Uint8Array([0xff])); + expect(result).not.toContain('+'); + expect(result).not.toContain('/'); + expect(result).not.toContain('='); + }); + + test('encodes all 256 byte values', () => { + const bytes = new Uint8Array(256); + for (let i = 0; i < 256; i++) bytes[i] = i; + const encoded = bytesToBase64url(bytes); + expect(encoded).not.toContain('+'); + expect(encoded).not.toContain('/'); + expect(encoded).not.toContain('='); + }); + + test('handles large payloads (> 65K)', () => { + const bytes = new Uint8Array(70_000); + crypto.getRandomValues(bytes); + const encoded = bytesToBase64url(bytes); + expect(encoded.length).toBeGreaterThan(0); + // Round-trip + const decoded = base64urlToBytes(encoded); + expect(decoded).toEqual(bytes); + }); +}); + +describe('base64urlToBytes', () => { + test('decodes empty input', () => { + expect(base64urlToBytes('')).toEqual(new Uint8Array(0)); + }); + + test('round-trips through encode/decode', () => { + const original = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]); + const encoded = bytesToBase64url(original); + const decoded = base64urlToBytes(encoded); + expect(decoded).toEqual(original); + }); + + test('decodes valid unpadded input (length 2 mod 4 = 1 source byte)', () => { + // 1 byte -> 2 base64 chars (length % 4 === 2) + const original = new Uint8Array([42]); + const encoded = bytesToBase64url(original); + expect(encoded.length % 4).toBe(2); + expect(base64urlToBytes(encoded)).toEqual(original); + }); + + test('decodes valid unpadded input (length 3 mod 4 = 2 source bytes)', () => { + // 2 bytes -> 3 base64 chars (length % 4 === 3) + const original = new Uint8Array([42, 99]); + const encoded = bytesToBase64url(original); + expect(encoded.length % 4).toBe(3); + expect(base64urlToBytes(encoded)).toEqual(original); + }); + + test('rejects length 1 mod 4 as malformed', () => { + expect(() => base64urlToBytes('A')).toThrow('Invalid base64url'); + expect(() => base64urlToBytes('AAAAA')).toThrow('Invalid base64url'); + }); + + test('handles URL-safe characters (- and _)', () => { + // Encode bytes that produce + and / in standard base64 + const original = new Uint8Array([251, 255, 191]); + const encoded = bytesToBase64url(original); + expect(encoded).toContain('-'); + const decoded = base64urlToBytes(encoded); + expect(decoded).toEqual(original); + }); +}); diff --git a/packages/shared/collab/encoding.ts b/packages/shared/collab/encoding.ts new file mode 100644 index 00000000..2622634e --- /dev/null +++ b/packages/shared/collab/encoding.ts @@ -0,0 +1,37 @@ +/** + * Base64url encode/decode helpers. + * + * Exported for use by collab crypto, IDs, and URL modules. + * Uses only btoa/atob — portable across browsers, Bun, and Cloudflare Workers. + */ + +/** Encode a Uint8Array to a URL-safe base64 string (no padding). */ +export function bytesToBase64url(bytes: Uint8Array): string { + // Loop to avoid RangeError on large payloads (>65K args to String.fromCharCode) + let binary = ''; + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=/g, ''); +} + +/** + * Decode a URL-safe base64 string to a Uint8Array. + * + * Normalizes padding before atob for cross-runtime safety. + * Rejects strings whose length is 1 mod 4 (no valid byte count produces that length). + */ +export function base64urlToBytes(b64: string): Uint8Array { + if (b64.length % 4 === 1) { + throw new Error('Invalid base64url: length mod 4 cannot be 1'); + } + // Restore standard base64 characters + const base64 = b64.replace(/-/g, '+').replace(/_/g, '/'); + // Normalize padding + const padded = base64 + '==='.slice(0, (4 - base64.length % 4) % 4); + const binary = atob(padded); + return Uint8Array.from(binary, c => c.charCodeAt(0)); +} diff --git a/packages/shared/collab/ids.test.ts b/packages/shared/collab/ids.test.ts new file mode 100644 index 00000000..982e7f6f --- /dev/null +++ b/packages/shared/collab/ids.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, test } from 'bun:test'; +import { + generateRoomId, + generateOpId, + generateClientId, + generateRoomSecret, + generateAdminSecret, + generateNonce, + generateChallengeId, +} from './ids'; +import { base64urlToBytes } from './encoding'; + +describe('generateRoomId', () => { + test('produces at least 128 bits of entropy', () => { + const id = generateRoomId(); + const bytes = base64urlToBytes(id); + expect(bytes.length).toBeGreaterThanOrEqual(16); + }); + + test('produces unique values', () => { + const ids = new Set(Array.from({ length: 100 }, () => generateRoomId())); + expect(ids.size).toBe(100); + }); +}); + +describe('generateOpId', () => { + test('produces unique values', () => { + const ids = new Set(Array.from({ length: 100 }, () => generateOpId())); + expect(ids.size).toBe(100); + }); +}); + +describe('generateClientId', () => { + test('produces unique values', () => { + const ids = new Set(Array.from({ length: 100 }, () => generateClientId())); + expect(ids.size).toBe(100); + }); +}); + +describe('generateRoomSecret', () => { + test('returns exactly 32 bytes', () => { + expect(generateRoomSecret().length).toBe(32); + }); + + test('returns Uint8Array', () => { + expect(generateRoomSecret()).toBeInstanceOf(Uint8Array); + }); +}); + +describe('generateAdminSecret', () => { + test('returns exactly 32 bytes', () => { + expect(generateAdminSecret().length).toBe(32); + }); +}); + +describe('generateNonce', () => { + test('decodes to 32 bytes', () => { + const nonce = generateNonce(); + const bytes = base64urlToBytes(nonce); + expect(bytes.length).toBe(32); + }); +}); + +describe('generateChallengeId', () => { + test('starts with ch_ prefix', () => { + expect(generateChallengeId()).toMatch(/^ch_/); + }); + + test('produces unique values', () => { + const ids = new Set(Array.from({ length: 100 }, () => generateChallengeId())); + expect(ids.size).toBe(100); + }); +}); diff --git a/packages/shared/collab/ids.ts b/packages/shared/collab/ids.ts new file mode 100644 index 00000000..e1e33183 --- /dev/null +++ b/packages/shared/collab/ids.ts @@ -0,0 +1,47 @@ +/** + * High-entropy ID and secret generation for room protocol. + * + * All functions use crypto.getRandomValues() — portable across + * browsers, Bun, and Cloudflare Workers. + */ + +import { bytesToBase64url } from './encoding'; + +/** Generate a room ID with at least 128 bits of randomness. */ +export function generateRoomId(): string { + return bytesToBase64url(crypto.getRandomValues(new Uint8Array(16))); +} + +/** Generate a unique operation ID. */ +export function generateOpId(): string { + return bytesToBase64url(crypto.getRandomValues(new Uint8Array(16))); +} + +/** Generate a random client ID for a WebSocket connection. */ +export function generateClientId(): string { + return bytesToBase64url(crypto.getRandomValues(new Uint8Array(16))); +} + +/** + * Generate a 256-bit room secret. + * Returns raw bytes (not base64url) because deriveRoomKeys() takes bytes directly. + * The URL helper handles encoding for the fragment. + */ +export function generateRoomSecret(): Uint8Array { + return crypto.getRandomValues(new Uint8Array(32)); +} + +/** Generate a 256-bit admin secret. Returns raw bytes. */ +export function generateAdminSecret(): Uint8Array { + return crypto.getRandomValues(new Uint8Array(32)); +} + +/** Generate a random nonce for challenge-response. */ +export function generateNonce(): string { + return bytesToBase64url(crypto.getRandomValues(new Uint8Array(32))); +} + +/** Generate a challenge ID with "ch_" prefix. */ +export function generateChallengeId(): string { + return 'ch_' + bytesToBase64url(crypto.getRandomValues(new Uint8Array(16))); +} diff --git a/packages/shared/collab/index.ts b/packages/shared/collab/index.ts new file mode 100644 index 00000000..18ec0f40 --- /dev/null +++ b/packages/shared/collab/index.ts @@ -0,0 +1,18 @@ +/** + * Plannotator Live Rooms — server-safe barrel export. + * + * This is the import path for Worker and Durable Object code: + * import { ... } from '@plannotator/shared/collab' + * + * NOTE: ./url is intentionally NOT re-exported here — it is client-only. + * Browser and direct-agent clients should import from: + * import { ... } from '@plannotator/shared/collab/client' + */ + +export * from './types'; +export * from './constants'; +export * from './encoding'; +export * from './canonical-json'; +export * from './crypto'; +export * from './ids'; +export * from './strip-images'; diff --git a/packages/shared/collab/strip-images.test.ts b/packages/shared/collab/strip-images.test.ts new file mode 100644 index 00000000..b81b1d21 --- /dev/null +++ b/packages/shared/collab/strip-images.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, test } from 'bun:test'; +import { toRoomAnnotation, toRoomAnnotations } from './strip-images'; + +describe('toRoomAnnotation', () => { + test('strips images field', () => { + const annotation = { + id: 'ann-1', + blockId: 'block-1', + startOffset: 0, + endOffset: 10, + type: 'COMMENT' as const, + text: 'a comment', + originalText: 'some text', + createdA: Date.now(), + images: [{ path: '/tmp/image.png', name: 'screenshot' }], + }; + + const room = toRoomAnnotation(annotation); + expect(room.id).toBe('ann-1'); + expect(room.text).toBe('a comment'); + expect(room.originalText).toBe('some text'); + expect('images' in room).toBe(false); + }); + + test('preserves all non-image fields', () => { + const annotation = { + id: 'ann-2', + blockId: 'block-2', + startOffset: 5, + endOffset: 15, + type: 'DELETION' as const, + originalText: 'deleted text', + createdA: 1234567890, + author: 'swift-falcon-tater', + source: 'eslint', + isQuickLabel: true, + diffContext: 'added' as const, + images: [{ path: '/tmp/a.png', name: 'a' }], + }; + + const room = toRoomAnnotation(annotation); + expect(room.author).toBe('swift-falcon-tater'); + expect(room.source).toBe('eslint'); + expect(room.isQuickLabel).toBe(true); + expect(room.diffContext).toBe('added'); + }); + + test('works on annotation without images', () => { + const annotation = { + id: 'ann-3', + blockId: 'block-3', + startOffset: 0, + endOffset: 5, + type: 'GLOBAL_COMMENT' as const, + text: 'global note', + originalText: '', + createdA: Date.now(), + }; + + const room = toRoomAnnotation(annotation); + expect(room.id).toBe('ann-3'); + expect(room.text).toBe('global note'); + expect('images' in room).toBe(false); + }); + + test('serialized output has no images key', () => { + const annotation = { + id: 'ann-4', + type: 'COMMENT' as const, + images: [{ path: '/tmp/x.png', name: 'x' }], + }; + const room = toRoomAnnotation(annotation); + const json = JSON.stringify(room); + expect(json).not.toContain('images'); + }); +}); + +describe('toRoomAnnotations', () => { + test('batch strips images from all annotations', () => { + const annotations = [ + { id: '1', type: 'COMMENT' as const, images: [{ path: '/a', name: 'a' }] }, + { id: '2', type: 'DELETION' as const }, + { id: '3', type: 'COMMENT' as const, images: [{ path: '/b', name: 'b' }] }, + ]; + + const rooms = toRoomAnnotations(annotations); + expect(rooms.length).toBe(3); + for (const r of rooms) { + expect('images' in r).toBe(false); + } + expect(rooms[0].id).toBe('1'); + expect(rooms[1].id).toBe('2'); + expect(rooms[2].id).toBe('3'); + }); +}); diff --git a/packages/shared/collab/strip-images.ts b/packages/shared/collab/strip-images.ts new file mode 100644 index 00000000..ec8de088 --- /dev/null +++ b/packages/shared/collab/strip-images.ts @@ -0,0 +1,22 @@ +/** + * Image stripping for converting Annotation objects into RoomAnnotation. + * + * Uses a generic approach to avoid importing Annotation from @plannotator/ui. + * Callers in packages/ui or packages/editor pass Annotation values; this + * helper strips the images field. + */ + +/** Strip the images field from an annotation-like object. */ +export function toRoomAnnotation( + annotation: T, +): Omit { + const { images: _, ...rest } = annotation; + return rest; +} + +/** Batch conversion. */ +export function toRoomAnnotations( + annotations: T[], +): Omit[] { + return annotations.map(toRoomAnnotation); +} diff --git a/packages/shared/collab/types.ts b/packages/shared/collab/types.ts new file mode 100644 index 00000000..90cd9f61 --- /dev/null +++ b/packages/shared/collab/types.ts @@ -0,0 +1,226 @@ +/** + * Plannotator Live Rooms — canonical protocol types. + * + * RoomAnnotation is a structural copy of the Annotation type from + * packages/ui/types.ts with the `images` field excluded (V1 rooms + * do not support image attachments). If Annotation gains new fields, + * they must be manually added here when they should be part of the + * room protocol. + * + * RoomState is intentionally NOT defined here — it contains server-only + * fields (roomVerifier, adminVerifier, event log) and belongs in + * apps/room-service (Slice 2). + */ + +// --------------------------------------------------------------------------- +// Room Annotation +// --------------------------------------------------------------------------- + +/** Annotation type values matching AnnotationType enum in packages/ui/types.ts */ +export type RoomAnnotationType = 'DELETION' | 'COMMENT' | 'GLOBAL_COMMENT'; + +/** + * Room-safe annotation. Structurally matches Annotation from packages/ui/types.ts + * minus the images field. V1 rooms do not support image attachments. + */ +export interface RoomAnnotation { + id: string; + blockId: string; + startOffset: number; + endOffset: number; + type: RoomAnnotationType; + text?: string; + originalText: string; + createdA: number; + author?: string; + source?: string; + isQuickLabel?: boolean; + quickLabelTip?: string; + diffContext?: 'added' | 'removed' | 'modified'; + startMeta?: { parentTagName: string; parentIndex: number; textOffset: number }; + endMeta?: { parentTagName: string; parentIndex: number; textOffset: number }; + images?: never; +} + +// --------------------------------------------------------------------------- +// Presence +// --------------------------------------------------------------------------- + +export interface CursorState { + blockId?: string; + x: number; + y: number; + coordinateSpace: 'block' | 'document' | 'viewport'; +} + +export interface PresenceState { + user: { id: string; name: string; color: string }; + cursor: CursorState | null; + activeAnnotationId?: string | null; + idle?: boolean; +} + +// --------------------------------------------------------------------------- +// Server Envelope +// --------------------------------------------------------------------------- + +/** + * Server-visible message wrapper. The DO can read clientId, opId, and channel + * but cannot read the encrypted ciphertext. + * + * clientId is random per WebSocket connection — not a stable user identity. + * Stable identity lives inside the encrypted PresenceState.user.id. + */ +export interface ServerEnvelope { + clientId: string; + opId: string; + channel: 'event' | 'presence'; + ciphertext: string; +} + +// --------------------------------------------------------------------------- +// Client Operations (encrypted inside envelope ciphertext) +// --------------------------------------------------------------------------- + +export type RoomClientOp = + | { type: 'annotation.add'; annotations: RoomAnnotation[] } + | { type: 'annotation.update'; id: string; patch: Partial } + | { type: 'annotation.remove'; ids: string[] } + | { type: 'annotation.clear'; source?: string } + | { type: 'presence.update'; presence: PresenceState }; + +// --------------------------------------------------------------------------- +// Server Events (decrypted by client from envelope ciphertext) +// --------------------------------------------------------------------------- + +export type RoomServerEvent = + | { type: 'snapshot'; payload: RoomSnapshot; snapshotSeq: number } + | { type: 'annotation.add'; annotations: RoomAnnotation[] } + | { type: 'annotation.update'; id: string; patch: Partial } + | { type: 'annotation.remove'; ids: string[] } + | { type: 'annotation.clear'; source?: string } + | { type: 'presence.update'; clientId: string; presence: PresenceState }; + +// --------------------------------------------------------------------------- +// Snapshot +// --------------------------------------------------------------------------- + +export interface RoomSnapshot { + versionId: 'v1'; + planMarkdown: string; + annotations: RoomAnnotation[]; +} + +// --------------------------------------------------------------------------- +// Transport Messages (server-to-client, pre-decryption) +// --------------------------------------------------------------------------- + +export type RoomTransportMessage = + | { type: 'room.snapshot'; snapshotSeq: number; snapshotCiphertext: string } + | { type: 'room.event'; seq: number; receivedAt: number; envelope: ServerEnvelope } + | { type: 'room.presence'; envelope: ServerEnvelope } + | { type: 'room.status'; status: RoomStatus }; + +// --------------------------------------------------------------------------- +// Room Status +// --------------------------------------------------------------------------- + +export type RoomStatus = 'created' | 'active' | 'locked' | 'deleted'; + +// --------------------------------------------------------------------------- +// Sequenced Envelope (for event log storage) +// --------------------------------------------------------------------------- + +export interface SequencedEnvelope { + seq: number; + receivedAt: number; + envelope: ServerEnvelope; +} + +// --------------------------------------------------------------------------- +// Auth +// --------------------------------------------------------------------------- + +export interface AuthChallenge { + type: 'auth.challenge'; + challengeId: string; + nonce: string; + expiresAt: number; +} + +export interface AuthResponse { + type: 'auth.response'; + challengeId: string; + clientId: string; + proof: string; + lastSeq?: number; +} + +export interface AuthAccepted { + type: 'auth.accepted'; + roomStatus: RoomStatus; + seq: number; + snapshotSeq?: number; + snapshotAvailable: boolean; +} + +// --------------------------------------------------------------------------- +// Admin +// --------------------------------------------------------------------------- + +export type AdminCommand = + | { type: 'room.lock'; finalSnapshotCiphertext?: string } + | { type: 'room.unlock' } + | { type: 'room.delete' }; + +export interface AdminChallengeRequest { + type: 'admin.challenge.request'; +} + +export interface AdminChallenge { + type: 'admin.challenge'; + challengeId: string; + nonce: string; + expiresAt: number; +} + +export interface AdminCommandEnvelope { + type: 'admin.command'; + challengeId: string; + clientId: string; + command: AdminCommand; + adminProof: string; +} + +// --------------------------------------------------------------------------- +// Room Creation +// --------------------------------------------------------------------------- + +export interface CreateRoomRequest { + roomId: string; + roomVerifier: string; + adminVerifier: string; + initialSnapshotCiphertext: string; + expiresInDays?: number; +} + +export interface CreateRoomResponse { + roomId: string; + status: 'active'; + seq: 0; + snapshotSeq: 0; + joinUrl: string; + websocketUrl: string; +} + +// --------------------------------------------------------------------------- +// Agent-Readable State +// --------------------------------------------------------------------------- + +export interface AgentReadableRoomState { + roomId: string; + status: RoomStatus; + versionId: 'v1'; + planMarkdown: string; + annotations: RoomAnnotation[]; +} diff --git a/packages/shared/collab/url.test.ts b/packages/shared/collab/url.test.ts new file mode 100644 index 00000000..ad8cc991 --- /dev/null +++ b/packages/shared/collab/url.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, test } from 'bun:test'; +import { parseRoomUrl, buildRoomJoinUrl } from './url'; +import { generateRoomSecret, generateRoomId } from './ids'; + +describe('parseRoomUrl', () => { + test('parses valid room URL', () => { + const secret = generateRoomSecret(); + const roomId = 'test-room-123'; + const url = buildRoomJoinUrl(roomId, secret); + + const parsed = parseRoomUrl(url); + expect(parsed).not.toBeNull(); + expect(parsed!.roomId).toBe(roomId); + expect(parsed!.roomSecret).toEqual(secret); + }); + + test('returns null for missing fragment', () => { + expect(parseRoomUrl('https://room.plannotator.ai/c/abc123')).toBeNull(); + }); + + test('returns null for missing key parameter', () => { + expect(parseRoomUrl('https://room.plannotator.ai/c/abc123#other=value')).toBeNull(); + }); + + test('returns null for wrong path', () => { + expect(parseRoomUrl('https://room.plannotator.ai/p/abc123#key=AAAA')).toBeNull(); + }); + + test('returns null for missing roomId', () => { + expect(parseRoomUrl('https://room.plannotator.ai/c/#key=AAAA')).toBeNull(); + }); + + test('returns null for empty key value', () => { + expect(parseRoomUrl('https://room.plannotator.ai/c/abc123#key=')).toBeNull(); + }); + + test('returns null for non-256-bit room secrets', () => { + expect(parseRoomUrl('https://room.plannotator.ai/c/abc123#key=AQ')).toBeNull(); + expect(parseRoomUrl('https://room.plannotator.ai/c/abc123#key=AAAA')).toBeNull(); + }); + + test('returns null for completely invalid URL', () => { + expect(parseRoomUrl('not a url')).toBeNull(); + }); + + test('returns null for empty string', () => { + expect(parseRoomUrl('')).toBeNull(); + }); +}); + +describe('buildRoomJoinUrl', () => { + test('constructs URL with default base', () => { + const secret = generateRoomSecret(); + const url = buildRoomJoinUrl('my-room', secret); + expect(url).toMatch(/^https:\/\/room\.plannotator\.ai\/c\/my-room#key=/); + }); + + test('constructs URL with custom base', () => { + const secret = generateRoomSecret(); + const url = buildRoomJoinUrl('my-room', secret, 'http://localhost:8787'); + expect(url).toMatch(/^http:\/\/localhost:8787\/c\/my-room#key=/); + }); + + test('rejects non-256-bit room secrets', () => { + expect(() => buildRoomJoinUrl('my-room', new Uint8Array(31))).toThrow('Invalid room secret'); + expect(() => buildRoomJoinUrl('my-room', new Uint8Array(33))).toThrow('Invalid room secret'); + }); +}); + +describe('round-trip', () => { + test('parse(build(id, secret)) recovers same values', () => { + const roomId = generateRoomId(); + const secret = generateRoomSecret(); + const url = buildRoomJoinUrl(roomId, secret); + const parsed = parseRoomUrl(url); + + expect(parsed).not.toBeNull(); + expect(parsed!.roomId).toBe(roomId); + expect(parsed!.roomSecret).toEqual(secret); + }); + + test('round-trip with custom base URL', () => { + const roomId = 'custom-room'; + const secret = generateRoomSecret(); + const url = buildRoomJoinUrl(roomId, secret, 'https://custom.example.com'); + const parsed = parseRoomUrl(url); + + expect(parsed).not.toBeNull(); + expect(parsed!.roomId).toBe(roomId); + expect(parsed!.roomSecret).toEqual(secret); + }); +}); diff --git a/packages/shared/collab/url.ts b/packages/shared/collab/url.ts new file mode 100644 index 00000000..3997d425 --- /dev/null +++ b/packages/shared/collab/url.ts @@ -0,0 +1,72 @@ +/** + * @module CLIENT-ONLY + * + * Room URL parsing and construction for browser and direct-agent clients. + * + * The Worker and Durable Object must NEVER import this module. + * They never receive URL fragments and must not parse full room URLs. + * They receive only roomId via /api/rooms request bodies or /ws/ + * routes, plus verifiers/proofs in request or WebSocket message bodies. + */ + +import { bytesToBase64url, base64urlToBytes } from './encoding'; +import { ROOM_SECRET_LENGTH_BYTES } from './constants'; + +const DEFAULT_BASE_URL = 'https://room.plannotator.ai'; + +export interface ParsedRoomUrl { + roomId: string; + roomSecret: Uint8Array; +} + +/** + * Parse a room join URL. Extracts roomId and roomSecret from the fragment. + * Returns null if the URL is malformed, missing a fragment, or not a valid room URL. + * + * Expected format: https://room.plannotator.ai/c/#key= + */ +export function parseRoomUrl(url: string): ParsedRoomUrl | null { + try { + const parsed = new URL(url); + + // Extract roomId from pathname /c/ + const match = parsed.pathname.match(/^\/c\/([^/]+)$/); + if (!match) return null; + + const roomId = match[1]; + if (!roomId) return null; + + // Extract key from fragment + const hash = parsed.hash.startsWith('#') ? parsed.hash.slice(1) : parsed.hash; + if (!hash) return null; + + const params = new URLSearchParams(hash); + const keyParam = params.get('key'); + if (!keyParam) return null; + + const roomSecret = base64urlToBytes(keyParam); + if (roomSecret.length !== ROOM_SECRET_LENGTH_BYTES) return null; + + return { roomId, roomSecret }; + } catch { + return null; + } +} + +/** + * Construct a room join URL with the secret in the fragment. + * + * @param roomId - The room identifier + * @param roomSecret - The 256-bit room secret (raw bytes) + * @param baseUrl - Base URL (defaults to "https://room.plannotator.ai") + */ +export function buildRoomJoinUrl( + roomId: string, + roomSecret: Uint8Array, + baseUrl: string = DEFAULT_BASE_URL, +): string { + if (roomSecret.length !== ROOM_SECRET_LENGTH_BYTES) { + throw new Error(`Invalid room secret: expected ${ROOM_SECRET_LENGTH_BYTES} bytes`); + } + return `${baseUrl}/c/${roomId}#key=${bytesToBase64url(roomSecret)}`; +} diff --git a/packages/shared/package.json b/packages/shared/package.json index a8a9389a..4b8a9b2f 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -25,7 +25,9 @@ "./improvement-hooks": "./improvement-hooks.ts", "./worktree": "./worktree.ts", "./html-to-markdown": "./html-to-markdown.ts", - "./url-to-markdown": "./url-to-markdown.ts" + "./url-to-markdown": "./url-to-markdown.ts", + "./collab": "./collab/index.ts", + "./collab/client": "./collab/client.ts" }, "dependencies": { "@joplin/turndown-plugin-gfm": "^1.0.64", diff --git a/specs/done/v1-slice1-plan.md b/specs/done/v1-slice1-plan.md new file mode 100644 index 00000000..b565e40a --- /dev/null +++ b/specs/done/v1-slice1-plan.md @@ -0,0 +1,263 @@ +# Slice 1: `packages/shared/collab` — Protocol Contract + +## Context + +This is the first implementation slice for Plannotator Live Rooms. The spec (`specs/v1.md`), PRD (`specs/v1-prd.md`), and implementation approach (`specs/v1-implementation-approach.md`) describe a zero-knowledge encrypted collaboration system backed by Cloudflare Durable Objects. Slice 1 creates the foundational `packages/shared/collab` package that every subsequent slice (room service, browser client, editor integration, agent bridge) imports from. + +No server, no UI, no React hooks — just types, crypto, and helpers with thorough tests. + +## File Structure + +Create `packages/shared/collab/` as a subdirectory: + +``` +packages/shared/collab/ + index.ts — server-safe barrel (types, crypto, ids, encoding, strip-images) + client.ts — client barrel (re-exports index + url helpers) + types.ts — all room protocol types + encoding.ts — exported base64url encode/decode + canonical-json.ts — deterministic JSON for admin proof binding + crypto.ts — HKDF, HMAC, AES-GCM + ids.ts — roomId, opId, clientId, secret generation + url.ts — client-only URL parsing (parseRoomUrl, buildRoomJoinUrl) + strip-images.ts — Annotation → RoomAnnotation conversion + + encoding.test.ts + canonical-json.test.ts + crypto.test.ts + ids.test.ts + url.test.ts + strip-images.test.ts +``` + +Add to `packages/shared/package.json` exports: +```json +"./collab": "./collab/index.ts", +"./collab/client": "./collab/client.ts" +``` + +## Files to Create + +### 1. `collab/types.ts` — Protocol Types + +All types from `specs/v1.md` Room Ops and Events section. No runtime code, no imports. + +`RoomAnnotation` must be defined structurally (not `Omit`) because `@plannotator/shared` cannot import from `@plannotator/ui` — the dependency direction is `ui → shared`. Match every field of `Annotation` from `packages/ui/types.ts:26-52` except `images`, set `images?: never`. + +Since `AnnotationType` (the enum) also lives in `@plannotator/ui`, define the `type` field as a string literal union: `type: "DELETION" | "COMMENT" | "GLOBAL_COMMENT"`. Do not use `type: string` — that would weaken validation and allow arbitrary values through room ops. + +Client/shared types to define: +- `RoomAnnotation`, `RoomSnapshot`, `RoomStatus` +- `ServerEnvelope`, `SequencedEnvelope` +- `RoomClientOp`, `RoomServerEvent`, `RoomTransportMessage` +- `PresenceState`, `CursorState` +- `AuthChallenge`, `AuthResponse`, `AuthAccepted` +- `AdminCommand`, `AdminChallengeRequest`, `AdminChallenge`, `AdminCommandEnvelope` +- `CreateRoomRequest`, `CreateRoomResponse` +- `AgentReadableRoomState` + +**Do NOT export `RoomState` from this package.** `RoomState` contains server-only fields (`roomVerifier`, `adminVerifier`, event log) that belong to the Durable Object storage layer, not the shared client contract. Define `RoomState` later in Slice 2 (`apps/room-service`) where the DO lives. The barrel `index.ts` must not re-export any server-internal storage types. + +Comment at the top referencing `packages/ui/types.ts` as the source of truth for `Annotation`, noting that new Annotation fields must be manually added to `RoomAnnotation`. + +### 2. `collab/encoding.ts` — Base64url Helpers + +Export base64url encode/decode. The existing `packages/shared/crypto.ts:81-97` has unexported local helpers — don't modify that file, create robust exports here. + +```ts +export function bytesToBase64url(bytes: Uint8Array): string +export function base64urlToBytes(b64: string): Uint8Array +``` + +Uses only `btoa`/`atob` and loop-based `String.fromCharCode` (handles payloads > 65K). + +**Improvement over existing helper:** The existing `base64urlToBytes` in `crypto.ts` does not normalize base64 padding before calling `atob`. This works in some runtimes but is not guaranteed across browser/Bun/Workers. The new `base64urlToBytes` must add `=` padding based on `length % 4` before calling `atob`: +```ts +// Normalize padding +const padded = base64 + '==='.slice(0, (4 - base64.length % 4) % 4); +``` +Add tests for valid unpadded inputs (lengths 2 and 3 mod 4), and reject length 1 mod 4 as malformed (no valid byte count produces that length). + +### 3. `collab/canonical-json.ts` — Deterministic Serialization + +Per `specs/v1.md:319`: sorted keys at every nesting level, no whitespace, UTF-8 bytes. Arrays preserve order. `undefined` fields omitted. Throws on `NaN`, `Infinity`, functions, symbols. + +```ts +export function canonicalJson(value: unknown): string +``` + +Recursive implementation: handle null, boolean, number (reject NaN/Infinity), string, arrays (recurse elements), plain objects (`Object.keys(obj).sort()`, recurse values, skip undefined). + +### 4. `collab/crypto.ts` — HKDF + HMAC + AES-GCM + +The most complex file. Imports from `./encoding.ts` and `./canonical-json.ts`. Uses only Web Crypto API (`crypto.subtle`). + +**Key derivation (HKDF):** +- `deriveRoomKeys(roomSecret: Uint8Array)` → `{ authKey, eventKey, presenceKey }` +- `deriveAdminKey(adminSecret: Uint8Array)` → `CryptoKey` +- Internal: `deriveHmacKey(material, info)` and `deriveAesKey(material, info)` +- HKDF params: SHA-256, zero-filled 32-byte salt (standard when no application salt), info from spec labels (`"plannotator:v1:room-auth"`, etc.) +- authKey/adminKey → HMAC-SHA-256 (`['sign', 'verify']`) +- eventKey/presenceKey → AES-256-GCM (`['encrypt', 'decrypt']`) + +**Verifiers (HMAC):** +- `computeRoomVerifier(authKey, roomId)` → base64url string +- `computeAdminVerifier(adminKey, roomId)` → base64url string + +**Proofs (HMAC with verifier as key):** +- `computeAuthProof(roomVerifier, roomId, clientId, challengeId, nonce)` → base64url +- `verifyAuthProof(...)` → boolean +- `computeAdminProof(adminVerifier, roomId, clientId, challengeId, nonce, command)` → base64url +- `verifyAdminProof(...)` → boolean + +**Concatenation delimiter.** The spec now specifies null byte (`\0`) separators between HMAC input components (added to `specs/v1.md` as part of this slice). Without delimiters, `roomId="ab" + clientId="cd"` would produce the same bytes as `roomId="a" + clientId="bcd"`. All HMAC inputs use: `TextEncoder.encode(comp1 + '\0' + comp2 + '\0' + ...)`. + +To use a verifier (which is HMAC output bytes) as a signing key for proofs: import via `crypto.subtle.importKey('raw', verifierBytes, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign', 'verify'])`. + +**AES-256-GCM encrypt/decrypt:** +- `encryptPayload(key, plaintext)` → base64url(IV || ciphertext+tag) +- `decryptPayload(key, ciphertext)` → plaintext string +- Follow exact pattern from `packages/shared/crypto.ts:15-77`: 12-byte random IV, prepend to ciphertext, base64url encode. + +**Channel convenience wrappers:** +- `encryptEventOp(eventKey, op)` / `decryptEventPayload(eventKey, ciphertext)` +- `encryptPresence(presenceKey, presence)` / `decryptPresence(presenceKey, ciphertext)` +- `encryptSnapshot(eventKey, snapshot)` / `decryptSnapshot(eventKey, ciphertext)` +- These `JSON.stringify` → `encryptPayload` and `decryptPayload` → `JSON.parse`. + +### 5. `collab/ids.ts` — ID and Secret Generation + +All use `crypto.getRandomValues()`. Import `bytesToBase64url` from `./encoding.ts`. + +```ts +export function generateRoomId(): string // 16 bytes (128 bits) → base64url +export function generateOpId(): string // 16 bytes → base64url +export function generateClientId(): string // 16 bytes → base64url +export function generateRoomSecret(): Uint8Array // 32 bytes raw (for key derivation) +export function generateAdminSecret(): Uint8Array // 32 bytes raw +export function generateNonce(): string // 32 bytes → base64url +export function generateChallengeId(): string // "ch_" + 16 bytes base64url +``` + +Secrets return raw `Uint8Array` (not base64url) because `deriveRoomKeys()` takes bytes directly. The URL helper handles encoding for the fragment. + +### 6. `collab/url.ts` — Client-Only URL Parsing + +Module-level JSDoc: `@module CLIENT-ONLY — The Worker and Durable Object must NEVER import this module.` + +```ts +export interface ParsedRoomUrl { roomId: string; roomSecret: Uint8Array } +export function parseRoomUrl(url: string): ParsedRoomUrl | null +export function buildRoomJoinUrl(roomId: string, roomSecret: Uint8Array, baseUrl?: string): string +``` + +- `parseRoomUrl`: uses `new URL(url)`, extracts pathname `/c/`, reads fragment for `key=`, decodes to bytes. Returns `null` on any failure. +- `buildRoomJoinUrl`: constructs `${baseUrl}/c/${roomId}#key=${bytesToBase64url(roomSecret)}`. Default baseUrl: `https://room.plannotator.ai`. +- Round-trip: `parseRoomUrl(buildRoomJoinUrl(id, secret))` must recover same id and secret bytes. + +### 7. `collab/strip-images.ts` — Image Stripping + +Generic approach (avoids importing `Annotation` from `@plannotator/ui`): + +```ts +export function toRoomAnnotation(annotation: T): Omit +export function toRoomAnnotations(annotations: T[]): Omit[] +``` + +Destructure `{ images, ...rest }`, return `rest`. The generic means it works with `Annotation` at the call site without importing it here. + +### 8. `collab/index.ts` — Server-Safe Barrel Export + +Re-exports everything **except** the client-only URL helpers. This is what the Worker and Durable Object import. + +```ts +export * from './types'; +export * from './encoding'; +export * from './canonical-json'; +export * from './crypto'; +export * from './ids'; +export * from './strip-images'; +// NOTE: ./url is intentionally NOT re-exported here — it is client-only. +// Browser and direct-agent clients should import from '@plannotator/shared/collab/client'. +``` + +### 9. `collab/client.ts` — Client Barrel Export + +Re-exports the server-safe barrel plus the client-only URL helpers. This is what browsers and direct-agent clients import. + +```ts +export * from './index'; +export * from './url'; +``` + +### 10. `packages/shared/package.json` — Add Exports + +Add to the existing exports map: +```json +"./collab": "./collab/index.ts", +"./collab/client": "./collab/client.ts" +``` + +`@plannotator/shared/collab` is the server-safe import (types, crypto, ids, encoding, image stripping). `@plannotator/shared/collab/client` adds URL parsing for browser and direct-agent use. + +## Files to Modify + +| File | Change | +|------|--------| +| `packages/shared/package.json` | Add `"./collab": "./collab/index.ts"` and `"./collab/client": "./collab/client.ts"` to exports | +| `specs/v1.md` | Add null byte delimiter specification for HMAC concatenation (already applied) | + +## Implementation Order + +1. `encoding.ts` + test — zero dependencies, foundational +2. `canonical-json.ts` + test — zero dependencies +3. `types.ts` — zero dependencies, pure types +4. `ids.ts` + test — depends on encoding +5. `crypto.ts` + test — depends on encoding, canonical-json, types +6. `url.ts` + test — depends on encoding +7. `strip-images.ts` + test — zero dependencies +8. `index.ts` server-safe barrel + `client.ts` client barrel +9. `package.json` exports + +Steps 1-3 can be done in parallel. Steps 4, 6, 7 in parallel after step 1. + +## Test Plan + +All tests use `bun:test` (`import { describe, expect, test } from "bun:test"`), matching existing patterns in `packages/shared/crypto.test.ts`. + +**encoding.test.ts:** Round-trip encode/decode, empty input, large payloads, all 256 byte values. Test `base64urlToBytes` with valid unpadded inputs (lengths 2 and 3 mod 4), and verify length 1 mod 4 is rejected as malformed. + +**canonical-json.test.ts:** Sorted keys, nested objects, arrays preserve order, undefined omitted, throws on NaN/Infinity/functions/symbols. **Known-output test vectors** — same input must always produce byte-identical output (this is security-critical for admin proof binding). + +**crypto.test.ts:** +- HKDF determinism via observable outputs: same secret + same roomId → same roomVerifier; different secrets → different verifiers; different labels → different keys (eventKey can't decrypt presenceKey ciphertext) +- Auth proof: `computeAuthProof` + `verifyAuthProof` round-trip; wrong inputs reject +- Admin proof: round-trip; wrong command rejects; proof is bound to canonicalJson(command) +- AES-GCM: encrypt/decrypt round-trip; unique ciphertext per call (fresh IV); wrong key fails; tampered ciphertext fails +- Cross-key isolation: eventKey cannot decrypt presenceKey ciphertext +- Channel wrappers: `encryptSnapshot`/`decryptSnapshot` round-trip with real `RoomSnapshot` + +**ids.test.ts:** Byte lengths (roomId ≥ 16 decoded, secrets = 32), uniqueness across calls, challengeId prefix. + +**url.test.ts:** Valid URL parses correctly; missing fragment → null; wrong path → null; empty roomId → null; round-trip `parse(build(...))` recovers same values; custom baseUrl works. + +**strip-images.test.ts:** Strips images field, preserves all other fields; annotation without images unchanged; batch works; output serializes without images key. + +## Verification + +```bash +bun test packages/shared/collab/ +``` + +All tests pass. Existing runtime behavior is unchanged. The package exports cleanly from `@plannotator/shared/collab` (server-safe) and `@plannotator/shared/collab/client` (browser/agent). + +## Protocol Decisions to Document in Code + +1. **HKDF salt**: zero-filled 32 bytes (standard when no application-specific salt) +2. **HMAC concatenation**: null byte (`\0`) separators between components to prevent ambiguity — now specified in `specs/v1.md` +3. **AES-GCM IV**: 12 bytes, random per encryption, prepended to ciphertext +4. **Base64url decoding**: normalize padding before `atob` for cross-runtime safety +5. **RoomAnnotation**: structural copy of Annotation minus images — must be manually updated when Annotation gains new fields +6. **RoomState is server-only**: defined in Slice 2's `apps/room-service`, not exported from the shared collab barrel +7. **URL parsing is client-only**: separate `client.ts` barrel; `index.ts` (server-safe) does not re-export `url.ts` +8. **RoomAnnotation.type**: string literal union `"DELETION" | "COMMENT" | "GLOBAL_COMMENT"`, not `string` From 91d732dab6f54b1685c3b2c762ecdd376089d588 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Sat, 18 Apr 2026 13:51:29 -0700 Subject: [PATCH 02/41] feat(collab): add apps/room-service Worker + Durable Object skeleton (Slice 2) Cloudflare Worker with raw Durable Object for room.plannotator.ai. Room creation, WebSocket challenge-response auth, and hibernation-safe connection state. 49 tests across validation, auth proofs, and CORS. - POST /api/rooms creates room in DO with verifiers + encrypted snapshot - GET /ws/ upgrades to WebSocket with 30s challenge-response auth - WebSocket attachments survive DO hibernation (no in-memory Maps) - RoomStatus gains "expired" as first-class terminal state with lazy enforcement - Explicit CORS policy: ALLOW_LOCALHOST_ORIGINS flag, Vary: Origin, no implicit bypass - Room ID validation: exactly 22 base64url chars (matches generateRoomId) - Verifier validation: exactly 43 base64url chars (matches HMAC-SHA-256 output) - Snapshot size limit (1.5 MB) with clear 413 response - Auth response fields validated before proof verification - markExpired() purges sensitive material with try/catch and fail-closed semantics - Smoke test script for repeatable integration testing against wrangler dev For provenance purposes, this commit was AI assisted. --- apps/room-service/core/auth.test.ts | 117 +++++++ apps/room-service/core/cors.test.ts | 98 ++++++ apps/room-service/core/cors.ts | 49 +++ apps/room-service/core/handler.ts | 142 +++++++++ apps/room-service/core/log.ts | 30 ++ apps/room-service/core/room-do.ts | 353 +++++++++++++++++++++ apps/room-service/core/types.ts | 50 +++ apps/room-service/core/validation.test.ts | 172 ++++++++++ apps/room-service/core/validation.ts | 91 ++++++ apps/room-service/package.json | 17 + apps/room-service/scripts/smoke.ts | 219 +++++++++++++ apps/room-service/targets/cloudflare.ts | 22 ++ apps/room-service/tsconfig.json | 14 + apps/room-service/wrangler.toml | 16 + package.json | 1 + packages/shared/collab/types.ts | 2 +- specs/done/v1-slice2-plan.md | 367 ++++++++++++++++++++++ 17 files changed, 1759 insertions(+), 1 deletion(-) create mode 100644 apps/room-service/core/auth.test.ts create mode 100644 apps/room-service/core/cors.test.ts create mode 100644 apps/room-service/core/cors.ts create mode 100644 apps/room-service/core/handler.ts create mode 100644 apps/room-service/core/log.ts create mode 100644 apps/room-service/core/room-do.ts create mode 100644 apps/room-service/core/types.ts create mode 100644 apps/room-service/core/validation.test.ts create mode 100644 apps/room-service/core/validation.ts create mode 100644 apps/room-service/package.json create mode 100644 apps/room-service/scripts/smoke.ts create mode 100644 apps/room-service/targets/cloudflare.ts create mode 100644 apps/room-service/tsconfig.json create mode 100644 apps/room-service/wrangler.toml create mode 100644 specs/done/v1-slice2-plan.md diff --git a/apps/room-service/core/auth.test.ts b/apps/room-service/core/auth.test.ts new file mode 100644 index 00000000..af1c4bf3 --- /dev/null +++ b/apps/room-service/core/auth.test.ts @@ -0,0 +1,117 @@ +/** + * End-to-end auth proof verification tests. + * + * These tests act as an external client: they use shared/collab/client + * helpers (deriveRoomKeys, computeAuthProof) to simulate a connecting + * browser/agent, then verify using the server-side verifyAuthProof. + * + * This proves the full auth chain: secret → keys → verifier → challenge → proof → verify. + */ + +import { describe, expect, test } from 'bun:test'; +import { + deriveRoomKeys, + computeRoomVerifier, + computeAuthProof, + verifyAuthProof, + generateNonce, + generateChallengeId, +} from '@plannotator/shared/collab/client'; + +// Stable test secrets +const ROOM_SECRET = new Uint8Array(32); +ROOM_SECRET.fill(0xab); + +const ROOM_ID = 'test-room-auth'; +const CLIENT_ID = 'client-123'; + +describe('auth proof verification (end-to-end)', () => { + test('valid proof is accepted', async () => { + // Client side: derive keys, compute verifier and proof + const { authKey } = await deriveRoomKeys(ROOM_SECRET); + const verifier = await computeRoomVerifier(authKey, ROOM_ID); + const challengeId = generateChallengeId(); + const nonce = generateNonce(); + + const proof = await computeAuthProof(verifier, ROOM_ID, CLIENT_ID, challengeId, nonce); + + // Server side: verify the proof using stored verifier + const valid = await verifyAuthProof(verifier, ROOM_ID, CLIENT_ID, challengeId, nonce, proof); + expect(valid).toBe(true); + }); + + test('wrong proof is rejected', async () => { + const { authKey } = await deriveRoomKeys(ROOM_SECRET); + const verifier = await computeRoomVerifier(authKey, ROOM_ID); + const challengeId = generateChallengeId(); + const nonce = generateNonce(); + + // Compute proof with wrong client ID + const proof = await computeAuthProof(verifier, ROOM_ID, 'wrong-client', challengeId, nonce); + + // Verify with correct client ID — should fail + const valid = await verifyAuthProof(verifier, ROOM_ID, CLIENT_ID, challengeId, nonce, proof); + expect(valid).toBe(false); + }); + + test('wrong roomId is rejected', async () => { + const { authKey } = await deriveRoomKeys(ROOM_SECRET); + const verifier = await computeRoomVerifier(authKey, ROOM_ID); + const challengeId = generateChallengeId(); + const nonce = generateNonce(); + + const proof = await computeAuthProof(verifier, ROOM_ID, CLIENT_ID, challengeId, nonce); + + // Verify with wrong roomId + const valid = await verifyAuthProof(verifier, 'wrong-room', CLIENT_ID, challengeId, nonce, proof); + expect(valid).toBe(false); + }); + + test('malformed proof returns false (does not throw)', async () => { + const { authKey } = await deriveRoomKeys(ROOM_SECRET); + const verifier = await computeRoomVerifier(authKey, ROOM_ID); + const challengeId = generateChallengeId(); + const nonce = generateNonce(); + + // Garbage proof strings + await expect(verifyAuthProof(verifier, ROOM_ID, CLIENT_ID, challengeId, nonce, 'A')) + .resolves.toBe(false); + await expect(verifyAuthProof(verifier, ROOM_ID, CLIENT_ID, challengeId, nonce, '!@#$')) + .resolves.toBe(false); + await expect(verifyAuthProof(verifier, ROOM_ID, CLIENT_ID, challengeId, nonce, '')) + .resolves.toBe(false); + }); + + test('different room secrets produce incompatible verifiers', async () => { + const secret2 = new Uint8Array(32); + secret2.fill(0xcd); + + const keys1 = await deriveRoomKeys(ROOM_SECRET); + const keys2 = await deriveRoomKeys(secret2); + + const verifier1 = await computeRoomVerifier(keys1.authKey, ROOM_ID); + const verifier2 = await computeRoomVerifier(keys2.authKey, ROOM_ID); + + const challengeId = generateChallengeId(); + const nonce = generateNonce(); + + // Proof computed with secret1's verifier + const proof = await computeAuthProof(verifier1, ROOM_ID, CLIENT_ID, challengeId, nonce); + + // Verify with secret2's verifier — should fail + const valid = await verifyAuthProof(verifier2, ROOM_ID, CLIENT_ID, challengeId, nonce, proof); + expect(valid).toBe(false); + }); +}); + +describe('challenge expiry detection', () => { + test('current timestamp is within expiry', () => { + const expiresAt = Date.now() + 30_000; + expect(Date.now() <= expiresAt).toBe(true); + }); + + test('past timestamp is expired', () => { + const expiresAt = Date.now() - 1000; + expect(Date.now() > expiresAt).toBe(true); + }); +}); diff --git a/apps/room-service/core/cors.test.ts b/apps/room-service/core/cors.test.ts new file mode 100644 index 00000000..4b21f4f7 --- /dev/null +++ b/apps/room-service/core/cors.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, test } from 'bun:test'; +import { corsHeaders, getAllowedOrigins, isLocalhostOrigin } from './cors'; + +describe('getAllowedOrigins', () => { + test('returns defaults when no env value', () => { + const origins = getAllowedOrigins(); + expect(origins).toEqual(['https://room.plannotator.ai']); + }); + + test('parses comma-separated env value', () => { + const origins = getAllowedOrigins('https://a.com, https://b.com'); + expect(origins).toEqual(['https://a.com', 'https://b.com']); + }); +}); + +describe('isLocalhostOrigin', () => { + test('matches http localhost with port', () => { + expect(isLocalhostOrigin('http://localhost:3001')).toBe(true); + expect(isLocalhostOrigin('http://localhost:57589')).toBe(true); + }); + + test('matches http localhost without port', () => { + expect(isLocalhostOrigin('http://localhost')).toBe(true); + }); + + test('matches https localhost', () => { + expect(isLocalhostOrigin('https://localhost:8443')).toBe(true); + }); + + test('matches 127.0.0.1 with port', () => { + expect(isLocalhostOrigin('http://127.0.0.1:3001')).toBe(true); + }); + + test('matches [::1] with port', () => { + expect(isLocalhostOrigin('http://[::1]:3001')).toBe(true); + }); + + test('matches 127.0.0.1 without port', () => { + expect(isLocalhostOrigin('http://127.0.0.1')).toBe(true); + }); + + test('rejects non-localhost', () => { + expect(isLocalhostOrigin('https://evil.com')).toBe(false); + expect(isLocalhostOrigin('https://localhost.evil.com')).toBe(false); + expect(isLocalhostOrigin('http://127.0.0.2:3001')).toBe(false); + }); +}); + +describe('corsHeaders', () => { + const prodOrigins = ['https://room.plannotator.ai']; + + test('allows listed production origin', () => { + const headers = corsHeaders('https://room.plannotator.ai', prodOrigins); + expect(headers['Access-Control-Allow-Origin']).toBe('https://room.plannotator.ai'); + expect(headers['Vary']).toBe('Origin'); + }); + + test('localhost allowed when flag is true', () => { + const headers = corsHeaders('http://localhost:57589', prodOrigins, true); + expect(headers['Access-Control-Allow-Origin']).toBe('http://localhost:57589'); + expect(headers['Vary']).toBe('Origin'); + }); + + test('localhost rejected when flag is false', () => { + const headers = corsHeaders('http://localhost:57589', prodOrigins, false); + expect(headers).toEqual({}); + }); + + test('localhost rejected when flag is not provided', () => { + const headers = corsHeaders('http://localhost:57589', prodOrigins); + expect(headers).toEqual({}); + }); + + test('rejects unlisted non-localhost origin', () => { + const headers = corsHeaders('https://evil.example', prodOrigins, true); + expect(headers).toEqual({}); + }); + + test('allows any origin with wildcard', () => { + const headers = corsHeaders('https://anything.com', ['*']); + expect(headers['Access-Control-Allow-Origin']).toBe('https://anything.com'); + expect(headers['Vary']).toBe('Origin'); + }); + + test('returns empty for no origin match', () => { + const headers = corsHeaders('', prodOrigins); + expect(headers).toEqual({}); + }); + + test('all allowed responses include Vary: Origin', () => { + const h1 = corsHeaders('https://room.plannotator.ai', prodOrigins); + const h2 = corsHeaders('http://localhost:3001', prodOrigins, true); + const h3 = corsHeaders('https://x.com', ['*']); + expect(h1['Vary']).toBe('Origin'); + expect(h2['Vary']).toBe('Origin'); + expect(h3['Vary']).toBe('Origin'); + }); +}); diff --git a/apps/room-service/core/cors.ts b/apps/room-service/core/cors.ts new file mode 100644 index 00000000..7fb99612 --- /dev/null +++ b/apps/room-service/core/cors.ts @@ -0,0 +1,49 @@ +/** + * CORS handling for room.plannotator.ai. + * + * Localhost origins are allowed only when ALLOW_LOCALHOST_ORIGINS is explicitly + * set to "true". This is intentional product behavior: Plannotator runs locally + * on unpredictable ports and needs to call room.plannotator.ai/api/rooms when + * the creator starts a live room. The room service still stores only ciphertext + * and verifiers — room content access depends on the URL fragment secret. + */ + +const BASE_CORS_HEADERS = { + 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type', + 'Access-Control-Max-Age': '86400', +}; + +/** Matches localhost, 127.0.0.1, and [::1] with optional port. */ +const LOOPBACK_RE = /^https?:\/\/(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/; + +export function getAllowedOrigins(envValue?: string): string[] { + if (envValue) { + return envValue.split(',').map((o) => o.trim()); + } + return ['https://room.plannotator.ai']; +} + +export function isLocalhostOrigin(origin: string): boolean { + return LOOPBACK_RE.test(origin); +} + +export function corsHeaders( + requestOrigin: string, + allowedOrigins: string[], + allowLocalhostOrigins: boolean = false, +): Record { + const allowed = + allowedOrigins.includes(requestOrigin) || + allowedOrigins.includes('*') || + (allowLocalhostOrigins && isLocalhostOrigin(requestOrigin)); + + if (allowed) { + return { + ...BASE_CORS_HEADERS, + 'Access-Control-Allow-Origin': requestOrigin, + 'Vary': 'Origin', + }; + } + return {}; +} diff --git a/apps/room-service/core/handler.ts b/apps/room-service/core/handler.ts new file mode 100644 index 00000000..68f275b1 --- /dev/null +++ b/apps/room-service/core/handler.ts @@ -0,0 +1,142 @@ +/** + * HTTP route dispatch for room.plannotator.ai. + * + * Routes requests to the appropriate Durable Object or returns + * static responses. Does NOT apply CORS to WebSocket upgrades. + */ + +import type { Env } from './types'; +import { validateCreateRoomRequest, isValidationError } from './validation'; +import { safeLog } from './log'; + +const ROOM_PATH_RE = /^\/c\/([^/]+)$/; +const WS_PATH_RE = /^\/ws\/([^/]+)$/; + +export async function handleRequest( + request: Request, + env: Env, + cors: Record, +): Promise { + const url = new URL(request.url); + const { pathname } = url; + const method = request.method; + + // CORS preflight + if (method === 'OPTIONS') { + return new Response(null, { status: 204, headers: cors }); + } + + // Health check + if (pathname === '/health' && method === 'GET') { + return Response.json({ ok: true }, { headers: cors }); + } + + // Room SPA shell placeholder (Slice 5 serves the real editor bundle) + const roomMatch = pathname.match(ROOM_PATH_RE); + if (roomMatch && method === 'GET') { + const roomId = roomMatch[1]; + return new Response( + `Plannotator Room

Room: ${escapeHtml(roomId)}

`, + { status: 200, headers: { ...cors, 'Content-Type': 'text/html; charset=utf-8' } }, + ); + } + + // Assets placeholder — intentionally deferred to Slice 5 + if (pathname.startsWith('/assets/') && method === 'GET') { + return Response.json( + { error: 'Static assets not yet available' }, + { status: 404, headers: cors }, + ); + } + + // Room creation + if (pathname === '/api/rooms' && method === 'POST') { + return handleCreateRoom(request, env, cors); + } + + // WebSocket upgrade + const wsMatch = pathname.match(WS_PATH_RE); + if (wsMatch && method === 'GET') { + return handleWebSocket(request, env, wsMatch[1], cors); + } + + // 404 + return Response.json( + { error: 'Not found. Valid paths: GET /health, GET /c/:id, POST /api/rooms, GET /ws/:id' }, + { status: 404, headers: cors }, + ); +} + +// --------------------------------------------------------------------------- +// Room Creation +// --------------------------------------------------------------------------- + +async function handleCreateRoom( + request: Request, + env: Env, + cors: Record, +): Promise { + let body: unknown; + try { + body = await request.json(); + } catch { + return Response.json({ error: 'Invalid JSON body' }, { status: 400, headers: cors }); + } + + const result = validateCreateRoomRequest(body); + if (isValidationError(result)) { + return Response.json({ error: result.error }, { status: result.status, headers: cors }); + } + + safeLog('handler:create-room', { roomId: result.roomId }); + + // Forward to the Durable Object + const id = env.ROOM.idFromName(result.roomId); + const stub = env.ROOM.get(id); + const doResponse = await stub.fetch( + new Request('http://do/create', { + method: 'POST', + body: JSON.stringify(result), + headers: { 'Content-Type': 'application/json' }, + }), + ); + + // Re-wrap DO response with CORS headers + const responseBody = await doResponse.text(); + return new Response(responseBody, { + status: doResponse.status, + headers: { ...cors, 'Content-Type': 'application/json' }, + }); +} + +// --------------------------------------------------------------------------- +// WebSocket Upgrade +// --------------------------------------------------------------------------- + +async function handleWebSocket( + request: Request, + env: Env, + roomId: string, + cors: Record, +): Promise { + // Verify WebSocket upgrade header + if (request.headers.get('Upgrade') !== 'websocket') { + return Response.json( + { error: 'Expected WebSocket upgrade' }, + { status: 426, headers: cors }, + ); + } + + // Forward to the Durable Object — no CORS on WebSocket upgrade + const id = env.ROOM.idFromName(roomId); + const stub = env.ROOM.get(id); + return stub.fetch(request); +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function escapeHtml(str: string): string { + return str.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +} diff --git a/apps/room-service/core/log.ts b/apps/room-service/core/log.ts new file mode 100644 index 00000000..09e82e90 --- /dev/null +++ b/apps/room-service/core/log.ts @@ -0,0 +1,30 @@ +/** + * Redaction-aware logging for the room service. + * + * Per specs/v1.md: "Redact proofs, verifiers, ciphertext, and message bodies from logs." + */ + +const REDACTED_KEYS = new Set([ + 'roomVerifier', + 'adminVerifier', + 'proof', + 'adminProof', + 'ciphertext', + 'initialSnapshotCiphertext', + 'snapshotCiphertext', + 'nonce', +]); + +/** Shallow-clone an object, replacing sensitive field values with "[REDACTED]". */ +export function redactForLog(obj: Record): Record { + const result: Record = {}; + for (const [key, value] of Object.entries(obj)) { + result[key] = REDACTED_KEYS.has(key) ? '[REDACTED]' : value; + } + return result; +} + +/** Log with sensitive fields redacted. */ +export function safeLog(label: string, obj: Record): void { + console.log(label, redactForLog(obj)); +} diff --git a/apps/room-service/core/room-do.ts b/apps/room-service/core/room-do.ts new file mode 100644 index 00000000..176f05e0 --- /dev/null +++ b/apps/room-service/core/room-do.ts @@ -0,0 +1,353 @@ +/** + * Plannotator Room Durable Object. + * + * Uses Cloudflare Workers WebSocket Hibernation API. + * All per-connection state lives in WebSocket attachments + * (survives DO hibernation). + * + * Slice 2 scope: room creation + WebSocket challenge-response auth. + * No event sequencing, replay, presence relay, or admin commands. + */ + +import type { + AuthChallenge, + AuthResponse, + AuthAccepted, + CreateRoomRequest, + CreateRoomResponse, +} from '@plannotator/shared/collab'; +import { verifyAuthProof, generateChallengeId, generateNonce } from '@plannotator/shared/collab'; +import { DurableObject } from 'cloudflare:workers'; +import type { Env, RoomDurableState, WebSocketAttachment } from './types'; +import { clampExpiryDays, hasRoomExpired } from './validation'; +import { safeLog } from './log'; + +const CHALLENGE_TTL_MS = 30_000; // 30 seconds + +// WebSocket close codes +const WS_CLOSE_AUTH_REQUIRED = 4001; +const WS_CLOSE_UNKNOWN_CHALLENGE = 4002; +const WS_CLOSE_CHALLENGE_EXPIRED = 4003; +const WS_CLOSE_INVALID_PROOF = 4004; +const WS_CLOSE_PROTOCOL_ERROR = 4005; +const WS_CLOSE_ROOM_UNAVAILABLE = 4006; + +export class RoomDurableObject extends DurableObject { + async fetch(request: Request): Promise { + const url = new URL(request.url); + + if (url.pathname === '/create' && request.method === 'POST') { + return this.handleCreate(request); + } + + if (request.headers.get('Upgrade') === 'websocket') { + return this.handleWebSocketUpgrade(request); + } + + return Response.json({ error: 'Not found' }, { status: 404 }); + } + + // --------------------------------------------------------------------------- + // Room Creation + // --------------------------------------------------------------------------- + + private async handleCreate(request: Request): Promise { + let body: CreateRoomRequest; + try { + body = await request.json() as CreateRoomRequest; + } catch { + return Response.json({ error: 'Invalid JSON' }, { status: 400 }); + } + + // Check for existing room + const existing = await this.ctx.storage.get('room'); + if (existing?.status === 'deleted') { + return Response.json({ error: 'Room deleted' }, { status: 410 }); + } + if (existing?.status === 'expired' || (existing && hasRoomExpired(existing.expiresAt))) { + await this.markExpired(existing); + return Response.json({ error: 'Room expired' }, { status: 410 }); + } + if (existing) { + return Response.json({ error: 'Room already exists' }, { status: 409 }); + } + + const expiryDays = clampExpiryDays(body.expiresInDays); + + const state: RoomDurableState = { + roomId: body.roomId, + status: 'active', + roomVerifier: body.roomVerifier, + adminVerifier: body.adminVerifier, + seq: 0, + snapshotCiphertext: body.initialSnapshotCiphertext, + snapshotSeq: 0, + eventLog: [], + expiresAt: Date.now() + expiryDays * 24 * 60 * 60 * 1000, + }; + + try { + await this.ctx.storage.put('room', state); + } catch (e) { + safeLog('room:create-storage-error', { roomId: body.roomId, error: String(e) }); + return Response.json({ error: 'Failed to store room state' }, { status: 507 }); + } + + // Build URLs without secrets — use URL parser to handle trailing slashes/paths safely + const base = new URL(this.env.BASE_URL || 'https://room.plannotator.ai'); + const wsScheme = base.protocol === 'https:' ? 'wss:' : 'ws:'; + + const response: CreateRoomResponse = { + roomId: body.roomId, + status: 'active', + seq: 0, + snapshotSeq: 0, + joinUrl: `${base.origin}/c/${body.roomId}`, + websocketUrl: `${wsScheme}//${base.host}/ws/${body.roomId}`, + }; + + safeLog('room:created', { roomId: body.roomId, expiryDays }); + return Response.json(response, { status: 201 }); + } + + // --------------------------------------------------------------------------- + // WebSocket Upgrade + // --------------------------------------------------------------------------- + + private async handleWebSocketUpgrade(_request: Request): Promise { + // Load room state to check existence + const roomState = await this.ctx.storage.get('room'); + if (!roomState) { + return Response.json({ error: 'Room not found' }, { status: 404 }); + } + if (roomState.status === 'deleted') { + return Response.json({ error: 'Room deleted' }, { status: 410 }); + } + if (roomState.status === 'expired' || hasRoomExpired(roomState.expiresAt)) { + await this.markExpired(roomState); + return Response.json({ error: 'Room expired' }, { status: 410 }); + } + + // Create WebSocket pair + const pair = new WebSocketPair(); + const [client, server] = Object.values(pair); + + // Generate challenge + const challengeId = generateChallengeId(); + const nonce = generateNonce(); + const expiresAt = Date.now() + CHALLENGE_TTL_MS; + + // Accept with hibernation API + this.ctx.acceptWebSocket(server); + + // Store pre-auth state in WebSocket attachment (survives hibernation) + const attachment: WebSocketAttachment = { + authenticated: false, + roomId: roomState.roomId, + challengeId, + nonce, + expiresAt, + }; + server.serializeAttachment(attachment); + + // Send challenge + const challenge: AuthChallenge = { + type: 'auth.challenge', + challengeId, + nonce, + expiresAt, + }; + server.send(JSON.stringify(challenge)); + + safeLog('ws:challenge-sent', { roomId: roomState.roomId, challengeId }); + + return new Response(null, { status: 101, webSocket: client }); + } + + // --------------------------------------------------------------------------- + // WebSocket Message Handler (Hibernation API) + // --------------------------------------------------------------------------- + + async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise { + const meta = ws.deserializeAttachment() as WebSocketAttachment | null; + if (!meta) { + ws.close(WS_CLOSE_AUTH_REQUIRED, 'No connection state'); + return; + } + + let msg: { type?: string; [key: string]: unknown }; + try { + const raw = typeof message === 'string' ? message : new TextDecoder().decode(message); + msg = JSON.parse(raw); + } catch { + ws.close(WS_CLOSE_PROTOCOL_ERROR, 'Invalid message format'); + return; + } + + // Pre-auth: only accept auth.response + if (!meta.authenticated) { + if (msg.type !== 'auth.response') { + ws.close(WS_CLOSE_AUTH_REQUIRED, 'Authentication required'); + return; + } + // Validate auth.response fields before trusting them + if ( + typeof msg.challengeId !== 'string' || !msg.challengeId || + typeof msg.clientId !== 'string' || !msg.clientId || + typeof msg.proof !== 'string' || !msg.proof + ) { + ws.close(WS_CLOSE_PROTOCOL_ERROR, 'Malformed auth response'); + return; + } + const authResponse: AuthResponse = { + type: 'auth.response', + challengeId: msg.challengeId as string, + clientId: msg.clientId as string, + proof: msg.proof as string, + lastSeq: typeof msg.lastSeq === 'number' ? msg.lastSeq : undefined, + }; + await this.handleAuthResponse(ws, meta, authResponse); + return; + } + + // Post-auth: Slice 2 ignores all messages (Slice 3 adds sequencing) + } + + private async handleAuthResponse( + ws: WebSocket, + meta: Extract, + authResponse: AuthResponse, + ): Promise { + // Verify challenge ID matches + if (authResponse.challengeId !== meta.challengeId) { + safeLog('ws:auth-rejected', { reason: 'unknown-challenge', roomId: meta.roomId }); + ws.close(WS_CLOSE_UNKNOWN_CHALLENGE, 'Unknown challenge'); + return; + } + + // Check expiry + if (Date.now() > meta.expiresAt) { + safeLog('ws:auth-rejected', { reason: 'expired', roomId: meta.roomId }); + ws.close(WS_CLOSE_CHALLENGE_EXPIRED, 'Challenge expired'); + return; + } + + // Load room state for verifier + const roomState = await this.ctx.storage.get('room'); + if (!roomState) { + ws.close(WS_CLOSE_ROOM_UNAVAILABLE, 'Room unavailable'); + return; + } + if (roomState.status === 'deleted') { + ws.close(WS_CLOSE_ROOM_UNAVAILABLE, 'Room deleted'); + return; + } + if (roomState.status === 'expired' || hasRoomExpired(roomState.expiresAt)) { + await this.markExpired(roomState, ws); + ws.close(WS_CLOSE_ROOM_UNAVAILABLE, 'Room expired'); + return; + } + + // Verify proof + const valid = await verifyAuthProof( + roomState.roomVerifier, + meta.roomId, + authResponse.clientId, + meta.challengeId, + meta.nonce, + authResponse.proof, + ); + + if (!valid) { + safeLog('ws:auth-rejected', { reason: 'invalid-proof', roomId: meta.roomId }); + ws.close(WS_CLOSE_INVALID_PROOF, 'Invalid proof'); + return; + } + + // Auth successful — update attachment to authenticated state + const authenticatedMeta: WebSocketAttachment = { + authenticated: true, + roomId: meta.roomId, + clientId: authResponse.clientId, + authenticatedAt: Date.now(), + }; + ws.serializeAttachment(authenticatedMeta); + + // Send auth.accepted + const accepted: AuthAccepted = { + type: 'auth.accepted', + roomStatus: roomState.status, + seq: roomState.seq, + snapshotSeq: roomState.snapshotSeq, + snapshotAvailable: !!roomState.snapshotCiphertext, + }; + ws.send(JSON.stringify(accepted)); + + safeLog('ws:authenticated', { roomId: meta.roomId, clientId: authResponse.clientId }); + } + + /** + * Transition room to expired status and purge sensitive material. + * Returns true if the tombstone was written, false if storage failed. + * Callers should still return 410/close even on false — fail closed. + */ + private async markExpired(roomState: RoomDurableState, except?: WebSocket): Promise { + if (roomState.status === 'expired') { + return true; + } + + // Destructure out sensitive material — don't use delete on typed objects + const { + snapshotCiphertext: _scrubCiphertext, + snapshotSeq: _scrubSeq, + ...rest + } = roomState; + + const expiredState: RoomDurableState = { + ...rest, + status: 'expired', + roomVerifier: '', + adminVerifier: '', + eventLog: [], + expiredAt: Date.now(), + }; + + try { + await this.ctx.storage.put('room', expiredState); + } catch (e) { + safeLog('room:expire-storage-error', { roomId: roomState.roomId, error: String(e) }); + this.closeRoomSockets('Room expiry failed', except); + return false; + } + + this.closeRoomSockets('Room expired', except); + safeLog('room:expired', { roomId: roomState.roomId }); + return true; + } + + /** Close all accepted WebSockets, optionally skipping one (e.g., the caller's socket). */ + private closeRoomSockets(reason: string, except?: WebSocket): void { + for (const socket of this.ctx.getWebSockets()) { + if (socket !== except) { + socket.close(WS_CLOSE_ROOM_UNAVAILABLE, reason); + } + } + } + + // --------------------------------------------------------------------------- + // WebSocket Lifecycle (Hibernation API) + // --------------------------------------------------------------------------- + + async webSocketClose(ws: WebSocket, code: number, _reason: string, _wasClean: boolean): Promise { + const meta = ws.deserializeAttachment() as WebSocketAttachment | null; + const roomId = meta?.roomId ?? 'unknown'; + const clientId = meta?.authenticated ? meta.clientId : 'unauthenticated'; + safeLog('ws:closed', { roomId, clientId, code }); + } + + async webSocketError(ws: WebSocket, error: unknown): Promise { + const meta = ws.deserializeAttachment() as WebSocketAttachment | null; + const roomId = meta?.roomId ?? 'unknown'; + safeLog('ws:error', { roomId, error: String(error) }); + } +} diff --git a/apps/room-service/core/types.ts b/apps/room-service/core/types.ts new file mode 100644 index 00000000..8a11125e --- /dev/null +++ b/apps/room-service/core/types.ts @@ -0,0 +1,50 @@ +/** + * Server-only types for the room-service Durable Object. + * + * RoomDurableState is the persistent room record stored in DO storage. + * WebSocketAttachment is serialized per-connection metadata that survives + * DO hibernation via serializeAttachment/deserializeAttachment. + */ + +import type { RoomStatus, SequencedEnvelope } from '@plannotator/shared/collab'; + +// --------------------------------------------------------------------------- +// Worker Environment +// --------------------------------------------------------------------------- + +/** Cloudflare Worker environment bindings. */ +export interface Env { + ROOM: DurableObjectNamespace; + ALLOWED_ORIGINS?: string; + ALLOW_LOCALHOST_ORIGINS?: string; + BASE_URL?: string; +} + +/** Durable state stored in DO storage under key 'room'. */ +export interface RoomDurableState { + /** Stored at creation — DO can't reverse idFromName(). */ + roomId: string; + status: RoomStatus; + roomVerifier: string; + adminVerifier: string; + seq: number; + snapshotCiphertext?: string; + snapshotSeq?: number; + /** Empty in Slice 2 — populated by Slice 3 event sequencing. */ + eventLog: SequencedEnvelope[]; + lockedAt?: number; + deletedAt?: number; + expiredAt?: number; + expiresAt: number; +} + +/** + * WebSocket attachment — survives hibernation via serializeAttachment/deserializeAttachment. + * + * Pre-auth: holds pending challenge state so the DO can verify after waking. + * Post-auth: holds authenticated connection metadata. + * Both variants carry roomId so webSocketMessage() can access it without a storage read. + */ +export type WebSocketAttachment = + | { authenticated: false; roomId: string; challengeId: string; nonce: string; expiresAt: number } + | { authenticated: true; roomId: string; clientId: string; authenticatedAt: number }; diff --git a/apps/room-service/core/validation.test.ts b/apps/room-service/core/validation.test.ts new file mode 100644 index 00000000..e7da5fc2 --- /dev/null +++ b/apps/room-service/core/validation.test.ts @@ -0,0 +1,172 @@ +import { describe, expect, test } from 'bun:test'; +import { validateCreateRoomRequest, isValidationError, clampExpiryDays, hasRoomExpired } from './validation'; + +describe('validateCreateRoomRequest', () => { + // 22-char base64url room ID (matches generateRoomId() output: 16 random bytes) + const validRoomId = 'ABCDEFGHIJKLMNOPQRSTUv'; + // 43-char base64url verifiers (matches HMAC-SHA-256 output: 32 bytes) + const validVerifier = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopq'; + const validAdminVerifier = 'abcdefghijklmnopqrstuvwxyz0123456789_-ABCDE'; + const validBody = { + roomId: validRoomId, + roomVerifier: validVerifier, + adminVerifier: validAdminVerifier, + initialSnapshotCiphertext: 'encrypted-snapshot-data', + }; + + test('accepts valid request', () => { + const result = validateCreateRoomRequest(validBody); + expect(isValidationError(result)).toBe(false); + if (!isValidationError(result)) { + expect(result.roomId).toBe(validRoomId); + expect(result.roomVerifier).toBe(validVerifier); + expect(result.adminVerifier).toBe(validAdminVerifier); + expect(result.initialSnapshotCiphertext).toBe('encrypted-snapshot-data'); + } + }); + + test('accepts request with expiresInDays', () => { + const result = validateCreateRoomRequest({ ...validBody, expiresInDays: 7 }); + expect(isValidationError(result)).toBe(false); + if (!isValidationError(result)) { + expect(result.expiresInDays).toBe(7); + } + }); + + test('rejects null body', () => { + const result = validateCreateRoomRequest(null); + expect(isValidationError(result)).toBe(true); + if (isValidationError(result)) { + expect(result.status).toBe(400); + } + }); + + test('rejects non-object body', () => { + const result = validateCreateRoomRequest('not an object'); + expect(isValidationError(result)).toBe(true); + }); + + test('rejects missing roomId', () => { + const { roomId: _, ...body } = validBody; + const result = validateCreateRoomRequest(body); + expect(isValidationError(result)).toBe(true); + if (isValidationError(result)) { + expect(result.error).toContain('roomId'); + } + }); + + test('rejects empty roomId', () => { + const result = validateCreateRoomRequest({ ...validBody, roomId: '' }); + expect(isValidationError(result)).toBe(true); + }); + + test('rejects missing roomVerifier', () => { + const { roomVerifier: _, ...body } = validBody; + const result = validateCreateRoomRequest(body); + expect(isValidationError(result)).toBe(true); + if (isValidationError(result)) { + expect(result.error).toContain('roomVerifier'); + } + }); + + test('rejects missing adminVerifier', () => { + const { adminVerifier: _, ...body } = validBody; + const result = validateCreateRoomRequest(body); + expect(isValidationError(result)).toBe(true); + if (isValidationError(result)) { + expect(result.error).toContain('adminVerifier'); + } + }); + + test('rejects malformed roomVerifier (wrong length)', () => { + expect(isValidationError(validateCreateRoomRequest({ ...validBody, roomVerifier: 'too-short' }))).toBe(true); + expect(isValidationError(validateCreateRoomRequest({ ...validBody, roomVerifier: 'a'.repeat(44) }))).toBe(true); + }); + + test('rejects malformed adminVerifier (wrong length)', () => { + expect(isValidationError(validateCreateRoomRequest({ ...validBody, adminVerifier: 'too-short' }))).toBe(true); + }); + + test('rejects verifier with invalid characters (exactly 43 chars, bad final char)', () => { + // 26 + 16 + 1 = 43 chars, only the / is invalid + expect(isValidationError(validateCreateRoomRequest({ ...validBody, roomVerifier: 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnop/' }))).toBe(true); + }); + + test('rejects missing initialSnapshotCiphertext', () => { + const { initialSnapshotCiphertext: _, ...body } = validBody; + const result = validateCreateRoomRequest(body); + expect(isValidationError(result)).toBe(true); + if (isValidationError(result)) { + expect(result.error).toContain('initialSnapshotCiphertext'); + } + }); + + test('rejects oversized initialSnapshotCiphertext', () => { + const result = validateCreateRoomRequest({ + ...validBody, + initialSnapshotCiphertext: 'x'.repeat(1_500_001), + }); + expect(isValidationError(result)).toBe(true); + if (isValidationError(result)) { + expect(result.status).toBe(413); + } + }); + + test('rejects roomId with invalid characters (exactly 22 chars, bad final char)', () => { + expect(isValidationError(validateCreateRoomRequest({ ...validBody, roomId: 'ABCDEFGHIJKLMNOPQRSTU/' }))).toBe(true); + expect(isValidationError(validateCreateRoomRequest({ ...validBody, roomId: 'ABCDEFGHIJKLMNOPQRSTU?' }))).toBe(true); + expect(isValidationError(validateCreateRoomRequest({ ...validBody, roomId: 'ABCDEFGHIJKLMNOPQRSTU ' }))).toBe(true); + }); + + test('rejects roomId that is not exactly 22 chars', () => { + expect(isValidationError(validateCreateRoomRequest({ ...validBody, roomId: 'ABCDEFGHIJKLMNOPQRSTUvW' }))).toBe(true); // 23 chars + expect(isValidationError(validateCreateRoomRequest({ ...validBody, roomId: 'ABCDEFGHIJKLMNOPQRSTu' }))).toBe(true); // 21 chars + expect(isValidationError(validateCreateRoomRequest({ ...validBody, roomId: 'short' }))).toBe(true); + }); + + test('accepts exactly 22 base64url chars', () => { + expect(isValidationError(validateCreateRoomRequest({ ...validBody, roomId: 'ABCDEFGHIJKLMNOPQRSTUv' }))).toBe(false); + expect(isValidationError(validateCreateRoomRequest({ ...validBody, roomId: 'abcdefghijklmnopqrstuv' }))).toBe(false); + expect(isValidationError(validateCreateRoomRequest({ ...validBody, roomId: '0123456789_-ABCDEFGHIJ' }))).toBe(false); + }); +}); + +describe('clampExpiryDays', () => { + test('defaults to 30', () => { + expect(clampExpiryDays(undefined)).toBe(30); + }); + + test('clamps 0 to 1', () => { + expect(clampExpiryDays(0)).toBe(1); + }); + + test('clamps negative to 1', () => { + expect(clampExpiryDays(-5)).toBe(1); + }); + + test('clamps 100 to 30', () => { + expect(clampExpiryDays(100)).toBe(30); + }); + + test('passes through valid value', () => { + expect(clampExpiryDays(7)).toBe(7); + }); + + test('floors fractional days', () => { + expect(clampExpiryDays(7.9)).toBe(7); + }); +}); + +describe('hasRoomExpired', () => { + test('returns false before expiry', () => { + expect(hasRoomExpired(2_000, 1_999)).toBe(false); + }); + + test('returns false at exact expiry timestamp', () => { + expect(hasRoomExpired(2_000, 2_000)).toBe(false); + }); + + test('returns true after expiry', () => { + expect(hasRoomExpired(2_000, 2_001)).toBe(true); + }); +}); diff --git a/apps/room-service/core/validation.ts b/apps/room-service/core/validation.ts new file mode 100644 index 00000000..39274f37 --- /dev/null +++ b/apps/room-service/core/validation.ts @@ -0,0 +1,91 @@ +/** + * Request body validation — pure functions, no Cloudflare APIs. + * Fully testable with bun:test. + */ + +import type { CreateRoomRequest } from '@plannotator/shared/collab'; + +export interface ValidationError { + error: string; + status: number; +} + +const MIN_EXPIRY_DAYS = 1; +const MAX_EXPIRY_DAYS = 30; +const DEFAULT_EXPIRY_DAYS = 30; +const MAX_SNAPSHOT_CIPHERTEXT_LENGTH = 1_500_000; // ~1.5 MB + +/** Clamp expiry days to [1, 30], default 30. */ +export function clampExpiryDays(days: number | undefined): number { + if (days === undefined || days === null) return DEFAULT_EXPIRY_DAYS; + return Math.max(MIN_EXPIRY_DAYS, Math.min(MAX_EXPIRY_DAYS, Math.floor(days))); +} + +/** True when a room is beyond its fixed retention deadline. */ +export function hasRoomExpired(expiresAt: number, now: number = Date.now()): boolean { + return now > expiresAt; +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.length > 0; +} + +/** + * Room IDs are generated from 16 random bytes and base64url-encoded without padding. + * That yields 22 URL-safe characters and 128 bits of entropy. + */ +const ROOM_ID_RE = /^[A-Za-z0-9_-]{22}$/; + +/** + * HMAC-SHA-256 output is 32 bytes, which base64url-encodes to 43 chars without padding. + * Verifiers must match this exact shape. + */ +const VERIFIER_RE = /^[A-Za-z0-9_-]{43}$/; + +/** Validate a POST /api/rooms request body. */ +export function validateCreateRoomRequest( + body: unknown, +): CreateRoomRequest | ValidationError { + if (!body || typeof body !== 'object') { + return { error: 'Request body must be a JSON object', status: 400 }; + } + + const obj = body as Record; + + if (!isNonEmptyString(obj.roomId)) { + return { error: 'Missing or empty "roomId"', status: 400 }; + } + + if (!ROOM_ID_RE.test(obj.roomId)) { + return { error: '"roomId" must be exactly 22 base64url characters', status: 400 }; + } + + if (!isNonEmptyString(obj.roomVerifier) || !VERIFIER_RE.test(obj.roomVerifier)) { + return { error: '"roomVerifier" must be a 43-char base64url HMAC-SHA-256 verifier', status: 400 }; + } + + if (!isNonEmptyString(obj.adminVerifier) || !VERIFIER_RE.test(obj.adminVerifier)) { + return { error: '"adminVerifier" must be a 43-char base64url HMAC-SHA-256 verifier', status: 400 }; + } + + if (!isNonEmptyString(obj.initialSnapshotCiphertext)) { + return { error: 'Missing or empty "initialSnapshotCiphertext"', status: 400 }; + } + + if (obj.initialSnapshotCiphertext.length > MAX_SNAPSHOT_CIPHERTEXT_LENGTH) { + return { error: `"initialSnapshotCiphertext" exceeds max size (${Math.round(MAX_SNAPSHOT_CIPHERTEXT_LENGTH / 1024)} KB)`, status: 413 }; + } + + return { + roomId: obj.roomId, + roomVerifier: obj.roomVerifier, + adminVerifier: obj.adminVerifier, + initialSnapshotCiphertext: obj.initialSnapshotCiphertext, + expiresInDays: typeof obj.expiresInDays === 'number' ? obj.expiresInDays : undefined, + }; +} + +/** Type guard: is this a ValidationError (not a valid request)? */ +export function isValidationError(result: CreateRoomRequest | ValidationError): result is ValidationError { + return 'error' in result; +} diff --git a/apps/room-service/package.json b/apps/room-service/package.json new file mode 100644 index 00000000..f2f75099 --- /dev/null +++ b/apps/room-service/package.json @@ -0,0 +1,17 @@ +{ + "name": "@plannotator/room-service", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "wrangler dev", + "deploy": "wrangler deploy", + "test": "bun test" + }, + "dependencies": { + "@plannotator/shared": "workspace:*" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20241218.0", + "wrangler": "^3.99.0" + } +} diff --git a/apps/room-service/scripts/smoke.ts b/apps/room-service/scripts/smoke.ts new file mode 100644 index 00000000..b7eeb819 --- /dev/null +++ b/apps/room-service/scripts/smoke.ts @@ -0,0 +1,219 @@ +/** + * Smoke test for room-service against a running wrangler dev instance. + * + * Usage: + * cd apps/room-service && wrangler dev # in one terminal + * bun run scripts/smoke.ts # in another terminal + * + * This acts as an external client: it imports from @plannotator/shared/collab/client + * to simulate browser/agent auth flows. Server runtime code must NOT do this. + * + * Exits 0 on success, non-zero on failure. + */ + +import { + deriveRoomKeys, + deriveAdminKey, + computeRoomVerifier, + computeAdminVerifier, + computeAuthProof, + encryptSnapshot, + generateRoomId, + generateRoomSecret, + generateAdminSecret, + generateClientId, +} from '@plannotator/shared/collab/client'; + +import type { + CreateRoomRequest, + CreateRoomResponse, + AuthChallenge, + AuthAccepted, + RoomSnapshot, +} from '@plannotator/shared/collab'; + +const BASE_URL = process.env.SMOKE_BASE_URL || 'http://localhost:8787'; +const WS_BASE = BASE_URL.replace(/^http/, 'ws'); + +let passed = 0; +let failed = 0; + +function assert(condition: boolean, label: string): void { + if (condition) { + passed++; + console.log(` PASS: ${label}`); + } else { + failed++; + console.error(` FAIL: ${label}`); + } +} + +async function run(): Promise { + console.log(`\nSmoke testing room-service at ${BASE_URL}\n`); + + // ----------------------------------------------------------------------- + // 1. Health check + // ----------------------------------------------------------------------- + console.log('1. Health check'); + const healthRes = await fetch(`${BASE_URL}/health`); + assert(healthRes.ok, 'GET /health returns 200'); + const healthBody = await healthRes.json() as { ok: boolean }; + assert(healthBody.ok === true, 'Response body is { ok: true }'); + + // ----------------------------------------------------------------------- + // 2. Create a room + // ----------------------------------------------------------------------- + console.log('\n2. Room creation'); + const roomId = generateRoomId(); + const roomSecret = generateRoomSecret(); + const adminSecret = generateAdminSecret(); + + const { authKey, eventKey } = await deriveRoomKeys(roomSecret); + const adminKey = await deriveAdminKey(adminSecret); + + const roomVerifier = await computeRoomVerifier(authKey, roomId); + const adminVerifier = await computeAdminVerifier(adminKey, roomId); + + const snapshot: RoomSnapshot = { + versionId: 'v1', + planMarkdown: '# Smoke Test Plan\n\nThis is a test.', + annotations: [], + }; + const snapshotCiphertext = await encryptSnapshot(eventKey, snapshot); + + const createBody: CreateRoomRequest = { + roomId, + roomVerifier, + adminVerifier, + initialSnapshotCiphertext: snapshotCiphertext, + }; + + const createRes = await fetch(`${BASE_URL}/api/rooms`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(createBody), + }); + assert(createRes.status === 201, 'POST /api/rooms returns 201'); + + const createResponseBody = await createRes.json() as CreateRoomResponse; + assert(createResponseBody.roomId === roomId, 'Response contains roomId'); + assert(createResponseBody.status === 'active', 'Status is active'); + assert(createResponseBody.seq === 0, 'seq is 0'); + assert(!createResponseBody.joinUrl.includes('#'), 'joinUrl has no fragment'); + assert(!createResponseBody.websocketUrl.includes('?'), 'websocketUrl has no query params'); + + // ----------------------------------------------------------------------- + // 3. Duplicate room creation → 409 + // ----------------------------------------------------------------------- + console.log('\n3. Duplicate room creation'); + const dupRes = await fetch(`${BASE_URL}/api/rooms`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(createBody), + }); + assert(dupRes.status === 409, 'Duplicate POST /api/rooms returns 409'); + + // ----------------------------------------------------------------------- + // 4. WebSocket auth — valid proof + // ----------------------------------------------------------------------- + console.log('\n4. WebSocket auth (valid proof)'); + const validAuth = await testWebSocketAuth(roomId, roomVerifier, true); + assert(validAuth, 'Valid proof → auth.accepted'); + + // ----------------------------------------------------------------------- + // 5. WebSocket auth — invalid proof + // ----------------------------------------------------------------------- + console.log('\n5. WebSocket auth (invalid proof)'); + const invalidAuth = await testWebSocketAuth(roomId, roomVerifier, false); + assert(!invalidAuth, 'Invalid proof → connection closed'); + + // ----------------------------------------------------------------------- + // Summary + // ----------------------------------------------------------------------- + console.log(`\n${'='.repeat(40)}`); + console.log(`Passed: ${passed}, Failed: ${failed}`); + if (failed > 0) { + process.exit(1); + } +} + +async function testWebSocketAuth( + roomId: string, + roomVerifier: string, + useValidProof: boolean, +): Promise { + return new Promise((resolve) => { + const ws = new WebSocket(`${WS_BASE}/ws/${roomId}`); + let resolved = false; + + const timeout = setTimeout(() => { + if (!resolved) { + resolved = true; + ws.close(); + resolve(false); + } + }, 10_000); + + ws.onmessage = async (event) => { + try { + const msg = JSON.parse(String(event.data)); + + if (msg.type === 'auth.challenge') { + const clientId = generateClientId(); + let proof: string; + + if (useValidProof) { + proof = await computeAuthProof( + roomVerifier, + roomId, + clientId, + msg.challengeId, + msg.nonce, + ); + } else { + proof = 'invalid-proof-garbage'; + } + + ws.send(JSON.stringify({ + type: 'auth.response', + challengeId: msg.challengeId, + clientId, + proof, + })); + } + + if (msg.type === 'auth.accepted') { + if (!resolved) { + resolved = true; + clearTimeout(timeout); + ws.close(); + resolve(true); + } + } + } catch (e) { + console.error(' WebSocket message error:', e); + } + }; + + ws.onclose = () => { + if (!resolved) { + resolved = true; + clearTimeout(timeout); + resolve(false); + } + }; + + ws.onerror = () => { + if (!resolved) { + resolved = true; + clearTimeout(timeout); + resolve(false); + } + }; + }); +} + +run().catch((err) => { + console.error('Smoke test failed:', err); + process.exit(1); +}); diff --git a/apps/room-service/targets/cloudflare.ts b/apps/room-service/targets/cloudflare.ts new file mode 100644 index 00000000..2d01efeb --- /dev/null +++ b/apps/room-service/targets/cloudflare.ts @@ -0,0 +1,22 @@ +/** + * Cloudflare Worker entrypoint for room.plannotator.ai. + * + * Routes HTTP requests and WebSocket upgrades to the handler. + * Re-exports the Durable Object class for wrangler discovery. + */ + +import { handleRequest } from '../core/handler'; +import { corsHeaders, getAllowedOrigins } from '../core/cors'; +import type { Env } from '../core/types'; + +export default { + async fetch(request: Request, env: Env): Promise { + const origin = request.headers.get('Origin') ?? ''; + const allowed = getAllowedOrigins(env.ALLOWED_ORIGINS); + const allowLocalhost = env.ALLOW_LOCALHOST_ORIGINS === 'true'; + const cors = corsHeaders(origin, allowed, allowLocalhost); + return handleRequest(request, env, cors); + }, +}; + +export { RoomDurableObject } from '../core/room-do'; diff --git a/apps/room-service/tsconfig.json b/apps/room-service/tsconfig.json new file mode 100644 index 00000000..9ce4372e --- /dev/null +++ b/apps/room-service/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "allowImportingTsExtensions": true, + "isolatedModules": true, + "types": ["@cloudflare/workers-types"] + }, + "exclude": ["**/*.test.ts", "scripts/**"] +} diff --git a/apps/room-service/wrangler.toml b/apps/room-service/wrangler.toml new file mode 100644 index 00000000..3f8fbf7b --- /dev/null +++ b/apps/room-service/wrangler.toml @@ -0,0 +1,16 @@ +name = "plannotator-room" +main = "targets/cloudflare.ts" +compatibility_date = "2024-12-01" + +[[durable_objects.bindings]] +name = "ROOM" +class_name = "RoomDurableObject" + +[[migrations]] +tag = "v1" +new_sqlite_classes = ["RoomDurableObject"] + +[vars] +ALLOWED_ORIGINS = "https://room.plannotator.ai" +ALLOW_LOCALHOST_ORIGINS = "true" +BASE_URL = "https://room.plannotator.ai" diff --git a/package.json b/package.json index 8b565a3c..92d9b8e3 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "dev:portal": "bun run --cwd apps/portal dev", "dev:marketing": "bun run --cwd apps/marketing dev", "dev:review": "bun run --cwd apps/review dev", + "dev:room": "bun run --cwd apps/room-service dev", "build:hook": "bun run --cwd apps/hook build", "build:portal": "bun run --cwd apps/portal build", "build:marketing": "bun run --cwd apps/marketing build", diff --git a/packages/shared/collab/types.ts b/packages/shared/collab/types.ts index 90cd9f61..46fbab38 100644 --- a/packages/shared/collab/types.ts +++ b/packages/shared/collab/types.ts @@ -125,7 +125,7 @@ export type RoomTransportMessage = // Room Status // --------------------------------------------------------------------------- -export type RoomStatus = 'created' | 'active' | 'locked' | 'deleted'; +export type RoomStatus = 'created' | 'active' | 'locked' | 'deleted' | 'expired'; // --------------------------------------------------------------------------- // Sequenced Envelope (for event log storage) diff --git a/specs/done/v1-slice2-plan.md b/specs/done/v1-slice2-plan.md new file mode 100644 index 00000000..893cce51 --- /dev/null +++ b/specs/done/v1-slice2-plan.md @@ -0,0 +1,367 @@ +# Slice 2: `apps/room-service` — Worker + Durable Object Skeleton + +## Context + +Slice 1 created `packages/shared/collab` with protocol types, crypto helpers, and ID generators. Slice 2 builds the Cloudflare Worker + Durable Object that uses those helpers to create rooms and authenticate WebSocket connections. This is the service skeleton — no event sequencing, replay, presence relay, or admin commands. + +## File Structure + +``` +apps/room-service/ + targets/cloudflare.ts — Worker entry: Env, fetch handler, DO re-export + core/handler.ts — HTTP route dispatch + core/room-do.ts — Durable Object class (WebSocket hibernation API) + core/types.ts — Server-only types (RoomDurableState, WebSocketAttachment) + core/cors.ts — CORS (adapted from paste-service) + core/log.ts — Redaction-aware logging + core/validation.ts — Request body validation (pure, testable) + wrangler.toml + package.json + tsconfig.json + + core/validation.test.ts — Body validation tests + core/auth.test.ts — Auth proof round-trip tests (uses shared/collab crypto) + scripts/smoke.ts — Repeatable smoke test against wrangler dev (create room + WebSocket auth) +``` + +## Import Boundary + +**Server runtime files** (`targets/` and `core/`) import ONLY from `@plannotator/shared/collab` (the server-safe barrel). Never from `@plannotator/shared/collab/client` or `collab/url`. + +Server runtime imports: +- **Types:** `RoomStatus`, `SequencedEnvelope`, `CreateRoomRequest`, `CreateRoomResponse`, `AuthChallenge`, `AuthResponse`, `AuthAccepted` +- **Crypto:** `verifyAuthProof` +- **IDs:** `generateChallengeId`, `generateNonce` + +**Test and smoke files** (`core/*.test.ts`, `scripts/smoke.ts`) act as external clients and may import from `@plannotator/shared/collab/client` — including `deriveRoomKeys`, `computeAuthProof`, `parseRoomUrl`, etc. — to simulate browser/agent auth flows. This is the same distinction as a real client connecting to the server. + +## Files to Create + +### 1. `package.json` + +```json +{ + "name": "@plannotator/room-service", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "wrangler dev", + "deploy": "wrangler deploy", + "test": "bun test" + }, + "dependencies": { + "@plannotator/shared": "workspace:*" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20241218.0", + "wrangler": "^3.99.0" + } +} +``` + +Explicit workspace dependency on `@plannotator/shared` — keeps the package graph honest for wrangler bundling, CI, and tooling. + +### 2. `tsconfig.json` + +```json +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "allowImportingTsExtensions": true, + "isolatedModules": true, + "types": ["@cloudflare/workers-types"] + }, + "exclude": ["**/*.test.ts"] +} +``` + +Note `types: ["@cloudflare/workers-types"]` instead of `"node"` — this is a Cloudflare Worker, not Node/Bun runtime. + +### 3. `wrangler.toml` + +```toml +name = "plannotator-room" +main = "targets/cloudflare.ts" +compatibility_date = "2024-12-01" + +[[durable_objects.bindings]] +name = "ROOM" +class_name = "RoomDurableObject" + +[[migrations]] +tag = "v1" +new_sqlite_classes = ["RoomDurableObject"] + +[vars] +ALLOWED_ORIGINS = "https://room.plannotator.ai,http://localhost:3001" +BASE_URL = "https://room.plannotator.ai" +``` + +DO binding named `ROOM`. Worker addresses rooms via `env.ROOM.idFromName(roomId)`. No KV — DO storage is sufficient. + +### 4. `core/types.ts` — Server-Only Types + +```ts +import type { RoomStatus, SequencedEnvelope } from '@plannotator/shared/collab'; + +/** Durable state stored in DO storage under key 'room'. */ +export interface RoomDurableState { + roomId: string; // stored at creation — DO can't reverse idFromName() + status: RoomStatus; + roomVerifier: string; + adminVerifier: string; + seq: number; + snapshotCiphertext?: string; + snapshotSeq?: number; + eventLog: SequencedEnvelope[]; // empty in Slice 2 + lockedAt?: number; + deletedAt?: number; + expiredAt?: number; + expiresAt: number; +} + +/** + * WebSocket attachment — survives hibernation via serializeAttachment/deserializeAttachment. + * Pre-auth: holds pending challenge state so the DO can verify after waking from hibernation. + * Post-auth: holds authenticated connection metadata. + */ +export type WebSocketAttachment = + | { authenticated: false; roomId: string; challengeId: string; nonce: string; expiresAt: number } + | { authenticated: true; roomId: string; clientId: string; authenticatedAt: number }; +``` + +### 5. `core/cors.ts` + +Adapted from `apps/paste-service/core/cors.ts:1-27`. Change default origins to `https://room.plannotator.ai`. Same `getAllowedOrigins()` / `corsHeaders()` API. Identical localhost regex. + +### 6. `core/log.ts` — Redaction + +```ts +const REDACTED_KEYS = new Set([ + 'roomVerifier', 'adminVerifier', 'proof', 'adminProof', + 'ciphertext', 'initialSnapshotCiphertext', 'snapshotCiphertext', 'nonce', +]); + +export function redactForLog(obj: Record): Record +export function safeLog(label: string, obj: Record): void +``` + +`redactForLog` shallow-clones and replaces values of sensitive keys with `"[REDACTED]"`. `safeLog` calls `console.log(label, redactForLog(obj))`. + +### 7. `core/validation.ts` — Request Validation (Pure, Testable) + +Extract validation as pure functions — no Cloudflare APIs, fully testable with `bun:test`. + +```ts +export interface ValidationError { error: string; status: number } + +export function validateCreateRoomRequest(body: unknown): CreateRoomRequest | ValidationError +export function clampExpiryDays(days: number | undefined): number // clamps to [1, 30], default 30 +``` + +`validateCreateRoomRequest` checks: body is object, `roomId` is non-empty string, `roomVerifier` is non-empty string, `adminVerifier` is non-empty string, `initialSnapshotCiphertext` is non-empty string. Returns the typed request or a `ValidationError`. + +### 8. `core/handler.ts` — HTTP Route Dispatch + +Receives `(request: Request, env: Env, cors: Record)`. Pattern matches: + +| Method | Path | Action | +|--------|------|--------| +| `OPTIONS` | `*` | 204 with CORS | +| `GET` | `/health` | `{ ok: true }` | +| `GET` | `/c/` | Minimal HTML placeholder (text/html) | +| `GET` | `/assets/*` | 404 — intentionally deferred to Slice 5 (editor bundle) | +| `POST` | `/api/rooms` | Validate body → forward to DO → return response | +| `GET` | `/ws/` | Check `Upgrade: websocket` header → forward to DO | +| `*` | `*` | 404 | + +**Room creation flow:** +1. Parse JSON body, validate with `validateCreateRoomRequest()` +2. Get DO stub: `env.ROOM.get(env.ROOM.idFromName(body.roomId))` +3. Forward: `stub.fetch(new Request('http://do/create', { method: 'POST', body: JSON.stringify(body) }))` +4. Return DO response with CORS headers + +**WebSocket flow:** +1. Extract roomId from `/ws/` path +2. Check `Upgrade: websocket` header — 426 if missing +3. Get DO stub, forward the original request: `stub.fetch(request)` +4. Return DO response (101 Upgrade) — no CORS needed for WebSocket + +The handler does NOT apply CORS to WebSocket upgrade responses (browsers don't send CORS preflight for WebSocket). + +### 9. `targets/cloudflare.ts` — Worker Entry + +Follows paste-service pattern exactly: + +```ts +import { handleRequest } from '../core/handler'; +import { corsHeaders, getAllowedOrigins } from '../core/cors'; + +export interface Env { + ROOM: DurableObjectNamespace; + ALLOWED_ORIGINS?: string; + BASE_URL?: string; +} + +export default { + async fetch(request: Request, env: Env): Promise { + const origin = request.headers.get('Origin') ?? ''; + const allowed = getAllowedOrigins(env.ALLOWED_ORIGINS); + const cors = corsHeaders(origin, allowed); + return handleRequest(request, env, cors); + }, +}; + +export { RoomDurableObject } from '../core/room-do'; +``` + +The DO class must be re-exported at the top level for wrangler to discover it. + +### 10. `core/room-do.ts` — Durable Object + +The most complex file. Uses Cloudflare Workers Hibernation API. + +**Class structure:** +```ts +export class RoomDurableObject extends DurableObject { + async fetch(request: Request): Promise { ... } + async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise { ... } + async webSocketClose(ws: WebSocket): void { ... } + async webSocketError(ws: WebSocket): void { ... } +} +``` + +No in-memory Map for challenge state. All per-connection state lives in the WebSocket attachment (`serializeAttachment` / `deserializeAttachment`), which survives hibernation. + +**`fetch` routes:** + +`POST http://do/create` — Room creation: +1. Parse body as `CreateRoomRequest` +2. Check existing: `await this.ctx.storage.get('room')` +3. If exists and active/locked → 409 Conflict; if deleted/expired → 410 Gone +4. Build `RoomDurableState` with `status: 'active'`, `seq: 0`, `snapshotSeq: 0`, clamped expiry +5. Store: `await this.ctx.storage.put('room', state)` +6. Build `joinUrl` and `websocketUrl` from `BASE_URL` env var (no fragment, no query auth) +7. Return `CreateRoomResponse` + +WebSocket upgrade (any request with `Upgrade: websocket`): +1. Load room state — 404 if missing, 410 if deleted or expired +2. Create `new WebSocketPair()` +3. Generate challenge: `{ challengeId, nonce, expiresAt: Date.now() + 30_000 }` +4. Accept server socket with hibernation: `this.ctx.acceptWebSocket(pair[1])` +5. Store pre-auth challenge state in WebSocket attachment (includes roomId for hibernation recovery): + `pair[1].serializeAttachment({ authenticated: false, roomId, challengeId, nonce, expiresAt })` +6. Send `AuthChallenge` on server socket +7. Return `new Response(null, { status: 101, webSocket: pair[0] })` + +**`webSocketMessage` handler:** + +1. Read attachment: `const meta = ws.deserializeAttachment() as WebSocketAttachment` +2. Parse message as JSON +3. If `meta.authenticated === false` and `type === 'auth.response'`: + - Check `authResponse.challengeId === meta.challengeId` — if not → close with 4002 + - Check `Date.now() <= meta.expiresAt` — if expired → close with 4003 + - Load room state; reject deleted or expired rooms before proof verification + - Verify proof with `verifyAuthProof(roomState.roomVerifier, meta.roomId, authResponse.clientId, meta.challengeId, meta.nonce, authResponse.proof)` + - If invalid → close with 4004 + - If valid → update attachment to authenticated: `ws.serializeAttachment({ authenticated: true, roomId: meta.roomId, clientId: authResponse.clientId, authenticatedAt: Date.now() })`, send `AuthAccepted` +4. If `meta.authenticated === false` and not `auth.response` → close with 4001 +5. If `meta.authenticated === true` but non-auth message → ignore in Slice 2 (Slice 3 adds sequencing) + +**roomId recovery:** The DO can't reverse `idFromName()`. `roomId` is stored in two places: +- **`RoomDurableState.roomId`** — set at creation from the `CreateRoomRequest` body. Source of truth. +- **`WebSocketAttachment.roomId`** — copied from durable state during WebSocket upgrade. Available in `webSocketMessage()` after hibernation without a storage read. + +For the WebSocket upgrade `fetch`, the DO reads `roomId` from `RoomDurableState` (which it loads anyway to check room existence). For `webSocketMessage()`, it reads from the attachment — no request URL parsing needed. + +**Hibernation safety:** All per-connection state lives in the WebSocket attachment via `serializeAttachment()` / `deserializeAttachment()`. Pre-auth connections store `{ authenticated: false, roomId, challengeId, nonce, expiresAt }`. Post-auth connections store `{ authenticated: true, roomId, clientId, authenticatedAt }`. If the DO hibernates mid-challenge, it wakes and reads the challenge + roomId from the attachment — no in-memory Map needed. This is the standard Cloudflare hibernation pattern. + +**WebSocket close codes:** +- 4001: Authentication required (message before auth) +- 4002: Unknown challenge ID +- 4003: Challenge expired +- 4004: Invalid proof +- 4005: Protocol error +- 4006: Room unavailable + +### 11. `core/validation.test.ts` + +Tests for `validateCreateRoomRequest`: +- Valid request accepted +- Missing `roomId` → error +- Empty `roomId` → error +- Missing `roomVerifier` → error +- Missing `initialSnapshotCiphertext` → error +- Non-object body → error +- `expiresInDays` clamped: 0 → 1, 100 → 30, undefined → 30 + +### 12. `core/auth.test.ts` + +End-to-end auth proof tests using `@plannotator/shared/collab` crypto: +- Generate room secret → derive keys → compute verifier → compute proof → verify proof (proves the server-side verification path works) +- Wrong proof → `verifyAuthProof` returns false +- Wrong roomId → returns false +- Malformed proof string → returns false (not throws) +- Challenge expiry detection (pure timestamp comparison) + +These tests run with `bun:test` since `verifyAuthProof` uses only Web Crypto (available in Bun). + +## Files to Modify + +| File | Change | +|------|--------| +| Root `package.json` | Add `"dev:room": "bun run --cwd apps/room-service dev"` script | + +## Implementation Order + +1. `package.json`, `tsconfig.json`, `wrangler.toml` — scaffolding +2. `core/types.ts` — needed by everything +3. `core/log.ts` — standalone +4. `core/cors.ts` — adapted from paste-service +5. `core/validation.ts` + test — pure functions, testable first +6. `core/room-do.ts` — depends on types, log, validation, shared/collab +7. `core/handler.ts` — depends on cors, room-do (via Env) +8. `targets/cloudflare.ts` — depends on handler, cors, room-do +9. `core/auth.test.ts` — integration test for auth proof flow +10. `scripts/smoke.ts` — repeatable integration test against `wrangler dev` + +## Verification + +```bash +bun test apps/room-service/ +``` + +Unit tests pass for validation and auth proof verification. + +For integration testing (Worker + DO together): +```bash +cd apps/room-service && wrangler dev +# In another terminal: +bun run scripts/smoke.ts +``` + +`scripts/smoke.ts` is a repeatable smoke test that runs against a live `wrangler dev` instance. It uses `@plannotator/shared/collab/client` (client-safe — this is a test client, not server code) to: +- `POST /api/rooms` → verify fragmentless `joinUrl` and `websocketUrl` +- `POST /api/rooms` with same roomId → verify 409 +- Open WebSocket to `/ws/` → receive `AuthChallenge` +- Compute valid proof using shared/collab crypto → verify `AuthAccepted` +- Open WebSocket with wrong proof → verify close with error code +- `GET /health` → verify `{ ok: true }` + +The smoke script exits 0 on success, non-zero on failure. This makes the verification gate repeatable without a full test framework for Cloudflare Workers. + +## What This Slice Does NOT Do + +- Event sequencing or `seq` increment +- Event log storage or replay +- Snapshot delivery after auth +- Presence relay +- Admin challenge-response or lock/unlock/delete +- Room expiry cleanup (alarm-based) +- Editor bundle serving +- Local SSE bridge or direct-agent client From b673ec41d9378684b8a097c411a8dd2b7bb0d29c Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Sat, 18 Apr 2026 15:03:51 -0700 Subject: [PATCH 03/41] =?UTF-8?q?feat(collab):=20durable=20room=20engine?= =?UTF-8?q?=20=E2=80=94=20event=20sequencing,=20admin,=20lifecycle=20(Slic?= =?UTF-8?q?e=203)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Post-auth WebSocket behavior for Plannotator Live Rooms. Two clients can now exchange encrypted annotations in real time, reconnect with replay, and creator can lock/unlock/delete via challenge-response. - Event sequencing: server-assigned seq, per-key storage (event:NNNNNNNNNN), atomic metadata+event write, broadcast to all including sender for lastSeq tracking - Presence relay: broadcast to others only, no storage, no seq - Reconnect replay: snapshot + incremental based on lastSeq, handles fresh join, future claims, and (future) compaction boundary - Admin challenge-response: fresh per-command challenges stored in hibernation-safe WebSocket attachment, verifyAdminProof with canonicalJson command binding, clientId spoofing rejection, lifecycle enforcement - Lock/unlock/delete: room.status broadcast, locked rooms reject events with room.error but keep socket open, delete purges sensitive material and event keys with fail-closed tombstone write - Per-channel size limits: event 512 KB, presence 8 KB, snapshot 1.5 MB - Batch event-key purge (128 per call) and lazy expiry cleanup - RoomStatus.expired enforced across auth, event, admin, and upgrade paths - Live smoke script: 20 integration checks verified against wrangler dev For provenance purposes, this commit was AI assisted. --- .gitignore | 3 + apps/room-service/core/room-do.ts | 540 +++++++++++++++++++-- apps/room-service/core/room-engine.test.ts | 214 ++++++++ apps/room-service/core/types.ts | 23 +- apps/room-service/core/validation.ts | 96 +++- apps/room-service/package.json | 2 +- apps/room-service/scripts/smoke.ts | 366 ++++++++++---- packages/shared/collab/types.ts | 5 +- specs/done/v1-slice3-plan.md | 291 +++++++++++ 9 files changed, 1399 insertions(+), 141 deletions(-) create mode 100644 apps/room-service/core/room-engine.test.ts create mode 100644 specs/done/v1-slice3-plan.md diff --git a/.gitignore b/.gitignore index 76aab802..d208a52d 100644 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,6 @@ opencode.json plannotator-local # Local research/reference docs (not for repo) /reference/ + +# Cloudflare Wrangler local state (Miniflare SQLite, caches) +.wrangler/ diff --git a/apps/room-service/core/room-do.ts b/apps/room-service/core/room-do.ts index 176f05e0..2810815b 100644 --- a/apps/room-service/core/room-do.ts +++ b/apps/room-service/core/room-do.ts @@ -5,24 +5,35 @@ * All per-connection state lives in WebSocket attachments * (survives DO hibernation). * - * Slice 2 scope: room creation + WebSocket challenge-response auth. - * No event sequencing, replay, presence relay, or admin commands. + * Implements: room creation, WebSocket auth, event sequencing, + * presence relay, reconnect replay, admin commands, lifecycle enforcement. + * + * Zero-knowledge: stores/relays ciphertext only. Never needs roomSecret, + * eventKey, presenceKey, or plaintext content. */ import type { AuthChallenge, AuthResponse, AuthAccepted, + AdminChallenge, + AdminCommandEnvelope, CreateRoomRequest, CreateRoomResponse, + ServerEnvelope, + SequencedEnvelope, + RoomTransportMessage, } from '@plannotator/shared/collab'; -import { verifyAuthProof, generateChallengeId, generateNonce } from '@plannotator/shared/collab'; +import { verifyAuthProof, verifyAdminProof, generateChallengeId, generateNonce } from '@plannotator/shared/collab'; import { DurableObject } from 'cloudflare:workers'; import type { Env, RoomDurableState, WebSocketAttachment } from './types'; -import { clampExpiryDays, hasRoomExpired } from './validation'; +import { clampExpiryDays, hasRoomExpired, validateServerEnvelope, validateAdminCommandEnvelope, isValidationError } from './validation'; +import type { ValidationError } from './validation'; import { safeLog } from './log'; -const CHALLENGE_TTL_MS = 30_000; // 30 seconds +const CHALLENGE_TTL_MS = 30_000; +const ADMIN_CHALLENGE_TTL_MS = 30_000; +const DELETE_BATCH_SIZE = 128; // Cloudflare DO storage.delete() max keys per call // WebSocket close codes const WS_CLOSE_AUTH_REQUIRED = 4001; @@ -32,6 +43,11 @@ const WS_CLOSE_INVALID_PROOF = 4004; const WS_CLOSE_PROTOCOL_ERROR = 4005; const WS_CLOSE_ROOM_UNAVAILABLE = 4006; +/** Zero-pad a seq number to 10 digits for lexicographic storage ordering. */ +function padSeq(seq: number): string { + return String(seq).padStart(10, '0'); +} + export class RoomDurableObject extends DurableObject { async fetch(request: Request): Promise { const url = new URL(request.url); @@ -59,7 +75,6 @@ export class RoomDurableObject extends DurableObject { return Response.json({ error: 'Invalid JSON' }, { status: 400 }); } - // Check for existing room const existing = await this.ctx.storage.get('room'); if (existing?.status === 'deleted') { return Response.json({ error: 'Room deleted' }, { status: 410 }); @@ -80,9 +95,9 @@ export class RoomDurableObject extends DurableObject { roomVerifier: body.roomVerifier, adminVerifier: body.adminVerifier, seq: 0, + earliestRetainedSeq: 1, snapshotCiphertext: body.initialSnapshotCiphertext, snapshotSeq: 0, - eventLog: [], expiresAt: Date.now() + expiryDays * 24 * 60 * 60 * 1000, }; @@ -93,7 +108,6 @@ export class RoomDurableObject extends DurableObject { return Response.json({ error: 'Failed to store room state' }, { status: 507 }); } - // Build URLs without secrets — use URL parser to handle trailing slashes/paths safely const base = new URL(this.env.BASE_URL || 'https://room.plannotator.ai'); const wsScheme = base.protocol === 'https:' ? 'wss:' : 'ws:'; @@ -115,7 +129,6 @@ export class RoomDurableObject extends DurableObject { // --------------------------------------------------------------------------- private async handleWebSocketUpgrade(_request: Request): Promise { - // Load room state to check existence const roomState = await this.ctx.storage.get('room'); if (!roomState) { return Response.json({ error: 'Room not found' }, { status: 404 }); @@ -128,19 +141,15 @@ export class RoomDurableObject extends DurableObject { return Response.json({ error: 'Room expired' }, { status: 410 }); } - // Create WebSocket pair const pair = new WebSocketPair(); const [client, server] = Object.values(pair); - // Generate challenge const challengeId = generateChallengeId(); const nonce = generateNonce(); const expiresAt = Date.now() + CHALLENGE_TTL_MS; - // Accept with hibernation API this.ctx.acceptWebSocket(server); - // Store pre-auth state in WebSocket attachment (survives hibernation) const attachment: WebSocketAttachment = { authenticated: false, roomId: roomState.roomId, @@ -150,7 +159,6 @@ export class RoomDurableObject extends DurableObject { }; server.serializeAttachment(attachment); - // Send challenge const challenge: AuthChallenge = { type: 'auth.challenge', challengeId, @@ -160,7 +168,6 @@ export class RoomDurableObject extends DurableObject { server.send(JSON.stringify(challenge)); safeLog('ws:challenge-sent', { roomId: roomState.roomId, challengeId }); - return new Response(null, { status: 101, webSocket: client }); } @@ -175,7 +182,7 @@ export class RoomDurableObject extends DurableObject { return; } - let msg: { type?: string; [key: string]: unknown }; + let msg: Record; try { const raw = typeof message === 'string' ? message : new TextDecoder().decode(message); msg = JSON.parse(raw); @@ -190,7 +197,6 @@ export class RoomDurableObject extends DurableObject { ws.close(WS_CLOSE_AUTH_REQUIRED, 'Authentication required'); return; } - // Validate auth.response fields before trusting them if ( typeof msg.challengeId !== 'string' || !msg.challengeId || typeof msg.clientId !== 'string' || !msg.clientId || @@ -199,40 +205,178 @@ export class RoomDurableObject extends DurableObject { ws.close(WS_CLOSE_PROTOCOL_ERROR, 'Malformed auth response'); return; } + // Validate lastSeq as non-negative integer if provided + let lastSeq: number | undefined; + if (msg.lastSeq !== undefined) { + if (typeof msg.lastSeq !== 'number' || !Number.isInteger(msg.lastSeq) || msg.lastSeq < 0) { + ws.close(WS_CLOSE_PROTOCOL_ERROR, 'lastSeq must be a non-negative integer'); + return; + } + lastSeq = msg.lastSeq; + } const authResponse: AuthResponse = { type: 'auth.response', challengeId: msg.challengeId as string, clientId: msg.clientId as string, proof: msg.proof as string, - lastSeq: typeof msg.lastSeq === 'number' ? msg.lastSeq : undefined, + lastSeq, }; await this.handleAuthResponse(ws, meta, authResponse); return; } - // Post-auth: Slice 2 ignores all messages (Slice 3 adds sequencing) + // Post-auth: dispatch by message type + await this.handlePostAuthMessage(ws, meta, msg); } + // --------------------------------------------------------------------------- + // Post-Auth Message Dispatch + // --------------------------------------------------------------------------- + + private async handlePostAuthMessage( + ws: WebSocket, + meta: Extract, + msg: Record, + ): Promise { + // Admin challenge request + if (msg.type === 'admin.challenge.request') { + await this.handleAdminChallengeRequest(ws, meta); + return; + } + + // Admin command + if (msg.type === 'admin.command') { + await this.handleAdminCommand(ws, meta, msg); + return; + } + + // ServerEnvelope — detect via channel field (no type field) + if (typeof msg.channel === 'string' && (msg.channel === 'event' || msg.channel === 'presence')) { + await this.handleServerEnvelope(ws, meta, msg); + return; + } + + ws.close(WS_CLOSE_PROTOCOL_ERROR, 'Unknown message type'); + } + + // --------------------------------------------------------------------------- + // Lifecycle Check (shared by event, presence, admin paths) + // --------------------------------------------------------------------------- + + /** + * Check room lifecycle state. Returns roomState if usable, or null if terminal. + * Closes the socket and handles expiry transition for terminal rooms. + */ + private async checkRoomLifecycle( + ws: WebSocket, + roomId: string, + ): Promise { + const roomState = await this.ctx.storage.get('room'); + if (!roomState) { + ws.close(WS_CLOSE_ROOM_UNAVAILABLE, 'Room unavailable'); + return null; + } + if (roomState.status === 'deleted') { + ws.close(WS_CLOSE_ROOM_UNAVAILABLE, 'Room deleted'); + return null; + } + if (roomState.status === 'expired') { + ws.close(WS_CLOSE_ROOM_UNAVAILABLE, 'Room expired'); + return null; + } + // Lazy expiry: active/locked room past retention deadline + if (hasRoomExpired(roomState.expiresAt)) { + await this.markExpired(roomState, ws); + ws.close(WS_CLOSE_ROOM_UNAVAILABLE, 'Room expired'); + return null; + } + return roomState; + } + + // --------------------------------------------------------------------------- + // Event Sequencing & Presence Relay + // --------------------------------------------------------------------------- + + private async handleServerEnvelope( + ws: WebSocket, + meta: Extract, + msg: Record, + ): Promise { + const validated = validateServerEnvelope(msg); + if (isValidationError(validated)) { + this.sendError(ws, 'validation_error', (validated as ValidationError).error); + return; + } + const envelope: ServerEnvelope = { + ...validated as ServerEnvelope, + clientId: meta.clientId, // Override — prevent spoofing + }; + + const roomState = await this.checkRoomLifecycle(ws, meta.roomId); + if (!roomState) return; + + if (envelope.channel === 'event') { + // Locked rooms reject event mutations (annotation ops) + if (roomState.status === 'locked') { + this.sendError(ws, 'room_locked', 'Room is locked — annotation operations are not allowed'); + return; + } + + // Sequence the event + roomState.seq++; + const sequenced: SequencedEnvelope = { + seq: roomState.seq, + receivedAt: Date.now(), + envelope, + }; + + // Atomic write: event key + room metadata in one put + await this.ctx.storage.put({ + [`event:${padSeq(roomState.seq)}`]: sequenced, + 'room': roomState, + } as Record); + + // Broadcast to ALL (including sender for lastSeq advancement) + const transport: RoomTransportMessage = { + type: 'room.event', + seq: sequenced.seq, + receivedAt: sequenced.receivedAt, + envelope: sequenced.envelope, + }; + this.broadcastToAll(transport); + + safeLog('room:event-sequenced', { roomId: roomState.roomId, seq: roomState.seq, clientId: meta.clientId }); + } else { + // Presence — allowed in active and locked rooms + const transport: RoomTransportMessage = { + type: 'room.presence', + envelope, + }; + this.broadcastToOthers(ws, transport); + } + } + + // --------------------------------------------------------------------------- + // Auth Response + Reconnect Replay + // --------------------------------------------------------------------------- + private async handleAuthResponse( ws: WebSocket, meta: Extract, authResponse: AuthResponse, ): Promise { - // Verify challenge ID matches if (authResponse.challengeId !== meta.challengeId) { safeLog('ws:auth-rejected', { reason: 'unknown-challenge', roomId: meta.roomId }); ws.close(WS_CLOSE_UNKNOWN_CHALLENGE, 'Unknown challenge'); return; } - // Check expiry if (Date.now() > meta.expiresAt) { safeLog('ws:auth-rejected', { reason: 'expired', roomId: meta.roomId }); ws.close(WS_CLOSE_CHALLENGE_EXPIRED, 'Challenge expired'); return; } - // Load room state for verifier const roomState = await this.ctx.storage.get('room'); if (!roomState) { ws.close(WS_CLOSE_ROOM_UNAVAILABLE, 'Room unavailable'); @@ -248,7 +392,6 @@ export class RoomDurableObject extends DurableObject { return; } - // Verify proof const valid = await verifyAuthProof( roomState.roomVerifier, meta.roomId, @@ -264,7 +407,7 @@ export class RoomDurableObject extends DurableObject { return; } - // Auth successful — update attachment to authenticated state + // Auth successful — update attachment const authenticatedMeta: WebSocketAttachment = { authenticated: true, roomId: meta.roomId, @@ -283,20 +426,321 @@ export class RoomDurableObject extends DurableObject { }; ws.send(JSON.stringify(accepted)); - safeLog('ws:authenticated', { roomId: meta.roomId, clientId: authResponse.clientId }); + // Reconnect replay + await this.replayEvents(ws, roomState, authResponse.lastSeq); + + safeLog('ws:authenticated', { roomId: meta.roomId, clientId: authResponse.clientId, lastSeq: authResponse.lastSeq }); + } + + private async replayEvents( + ws: WebSocket, + roomState: RoomDurableState, + lastSeq: number | undefined, + ): Promise { + // Determine replay strategy + let sendSnapshot = false; + let replayFrom: number; + + if (lastSeq === undefined) { + // Fresh join — send snapshot + all events + sendSnapshot = true; + replayFrom = (roomState.snapshotSeq ?? 0) + 1; + } else if (lastSeq > roomState.seq) { + // Future claim — anomaly, fall back to snapshot + sendSnapshot = true; + replayFrom = (roomState.snapshotSeq ?? 0) + 1; + safeLog('ws:replay-anomaly', { roomId: roomState.roomId, lastSeq, currentSeq: roomState.seq }); + } else if (lastSeq === roomState.seq) { + // Fully caught up — still send snapshot if seq is 0 (fresh room, no events yet) + if (roomState.seq === 0 && roomState.snapshotCiphertext) { + const snapshotMsg: RoomTransportMessage = { + type: 'room.snapshot', + snapshotSeq: roomState.snapshotSeq ?? 0, + snapshotCiphertext: roomState.snapshotCiphertext, + }; + ws.send(JSON.stringify(snapshotMsg)); + } + return; + } else { + // Check if we can replay incrementally + const nextNeededSeq = lastSeq + 1; + // In V1 earliestRetainedSeq stays 1 because there is no compaction. + // This branch becomes active once future compaction advances it. + if (nextNeededSeq < roomState.earliestRetainedSeq) { + // Too old — need snapshot fallback + sendSnapshot = true; + replayFrom = (roomState.snapshotSeq ?? 0) + 1; + } else { + // Can replay from retained log + replayFrom = nextNeededSeq; + } + } + + // Send snapshot if needed + if (sendSnapshot && roomState.snapshotCiphertext) { + const snapshotMsg: RoomTransportMessage = { + type: 'room.snapshot', + snapshotSeq: roomState.snapshotSeq ?? 0, + snapshotCiphertext: roomState.snapshotCiphertext, + }; + ws.send(JSON.stringify(snapshotMsg)); + } + + // Replay events from storage (if any exist) + if (roomState.seq > 0 && replayFrom <= roomState.seq) { + const startKey = `event:${padSeq(replayFrom)}`; + const events = await this.ctx.storage.list({ + prefix: 'event:', + start: startKey, + }); + for (const [, sequenced] of events) { + const transport: RoomTransportMessage = { + type: 'room.event', + seq: sequenced.seq, + receivedAt: sequenced.receivedAt, + envelope: sequenced.envelope, + }; + ws.send(JSON.stringify(transport)); + } + } + } + + // --------------------------------------------------------------------------- + // Admin Challenge-Response + // --------------------------------------------------------------------------- + + private async handleAdminChallengeRequest( + ws: WebSocket, + meta: Extract, + ): Promise { + // Lifecycle check — reject for terminal rooms + const roomState = await this.checkRoomLifecycle(ws, meta.roomId); + if (!roomState) return; + + const challengeId = generateChallengeId(); + const nonce = generateNonce(); + const expiresAt = Date.now() + ADMIN_CHALLENGE_TTL_MS; + + // Store in attachment (survives hibernation) + const updatedMeta: WebSocketAttachment = { + ...meta, + pendingAdminChallenge: { challengeId, nonce, expiresAt }, + }; + ws.serializeAttachment(updatedMeta); + + const challenge: AdminChallenge = { + type: 'admin.challenge', + challengeId, + nonce, + expiresAt, + }; + ws.send(JSON.stringify(challenge)); + + safeLog('admin:challenge-sent', { roomId: meta.roomId, clientId: meta.clientId, challengeId }); } + private async handleAdminCommand( + ws: WebSocket, + meta: Extract, + msg: Record, + ): Promise { + const validated = validateAdminCommandEnvelope(msg); + if (isValidationError(validated)) { + this.sendError(ws, 'validation_error', (validated as ValidationError).error); + return; + } + const cmdEnvelope = validated as AdminCommandEnvelope; + + // Reject cross-connection clientId spoofing + if (cmdEnvelope.clientId !== meta.clientId) { + this.sendError(ws, 'client_id_mismatch', 'clientId does not match authenticated connection'); + return; + } + + // Check pending admin challenge + if (!meta.pendingAdminChallenge) { + this.sendError(ws, 'no_admin_challenge', 'Request an admin challenge first'); + return; + } + if (cmdEnvelope.challengeId !== meta.pendingAdminChallenge.challengeId) { + this.sendError(ws, 'unknown_admin_challenge', 'Challenge ID does not match'); + return; + } + + // Save challenge data before clearing + const { challengeId, nonce, expiresAt } = meta.pendingAdminChallenge; + + // Clear challenge from attachment (single-use) — serialize immediately + const { pendingAdminChallenge: _, ...cleanMeta } = meta; + ws.serializeAttachment(cleanMeta); + + // Check expiry + if (Date.now() > expiresAt) { + this.sendError(ws, 'admin_challenge_expired', 'Admin challenge expired'); + return; + } + + // Lifecycle check — reject for terminal rooms + const roomState = await this.checkRoomLifecycle(ws, meta.roomId); + if (!roomState) return; + + // Verify admin proof + const valid = await verifyAdminProof( + roomState.adminVerifier, + meta.roomId, + meta.clientId, + challengeId, + nonce, + cmdEnvelope.command, + cmdEnvelope.adminProof, + ); + + if (!valid) { + safeLog('admin:proof-rejected', { roomId: meta.roomId, clientId: meta.clientId }); + this.sendError(ws, 'invalid_admin_proof', 'Admin proof verification failed'); + return; + } + + // Apply command + switch (cmdEnvelope.command.type) { + case 'room.lock': + await this.applyLock(ws, roomState, cmdEnvelope.command); + break; + case 'room.unlock': + await this.applyUnlock(ws, roomState); + break; + case 'room.delete': + await this.applyDelete(ws, roomState); + break; + } + } + + // --------------------------------------------------------------------------- + // Admin Command Execution + // --------------------------------------------------------------------------- + + private async applyLock( + ws: WebSocket, + roomState: RoomDurableState, + command: Extract, + ): Promise { + if (roomState.status !== 'active') { + this.sendError(ws, 'invalid_state', `Cannot lock room in "${roomState.status}" state`); + return; + } + + // Store final snapshot if provided + if (command.finalSnapshotCiphertext && command.finalSnapshotAtSeq !== undefined) { + const atSeq = command.finalSnapshotAtSeq; + if (atSeq > roomState.seq || atSeq < (roomState.snapshotSeq ?? 0)) { + this.sendError(ws, 'invalid_snapshot_seq', `finalSnapshotAtSeq must be between ${roomState.snapshotSeq ?? 0} and ${roomState.seq}`); + return; + } + roomState.snapshotCiphertext = command.finalSnapshotCiphertext; + roomState.snapshotSeq = atSeq; + } + + roomState.status = 'locked'; + roomState.lockedAt = Date.now(); + await this.ctx.storage.put('room', roomState); + + this.broadcastToAll({ type: 'room.status', status: 'locked' }); + safeLog('admin:room-locked', { roomId: roomState.roomId }); + } + + private async applyUnlock( + ws: WebSocket, + roomState: RoomDurableState, + ): Promise { + if (roomState.status !== 'locked') { + this.sendError(ws, 'invalid_state', `Cannot unlock room in "${roomState.status}" state`); + return; + } + + roomState.status = 'active'; + roomState.lockedAt = undefined; + await this.ctx.storage.put('room', roomState); + + this.broadcastToAll({ type: 'room.status', status: 'active' }); + safeLog('admin:room-unlocked', { roomId: roomState.roomId }); + } + + private async applyDelete( + ws: WebSocket, + roomState: RoomDurableState, + ): Promise { + if (roomState.status === 'deleted' || roomState.status === 'expired') { + this.sendError(ws, 'invalid_state', 'Room is already in a terminal state'); + return; + } + + // Write tombstone first — even if event purge fails, room is marked deleted + const { + snapshotCiphertext: _s, + snapshotSeq: _ss, + ...rest + } = roomState; + + const deletedState: RoomDurableState = { + ...rest, + status: 'deleted', + roomVerifier: '', + adminVerifier: '', + deletedAt: Date.now(), + }; + + // Write tombstone first — critical path + try { + await this.ctx.storage.put('room', deletedState); + } catch (e) { + safeLog('room:delete-storage-error', { roomId: roomState.roomId, error: String(e) }); + this.sendError(ws, 'delete_failed', 'Failed to delete room'); + this.closeRoomSockets('Room delete failed'); + return; + } + + // Purge event keys (best-effort after tombstone) + try { + await this.purgeEventKeys(); + } catch (e) { + safeLog('room:delete-purge-error', { roomId: roomState.roomId, error: String(e) }); + } + + this.broadcastToAll({ type: 'room.status', status: 'deleted' }); + this.closeRoomSockets('Room deleted'); + safeLog('admin:room-deleted', { roomId: roomState.roomId }); + } + + // --------------------------------------------------------------------------- + // Storage Helpers + // --------------------------------------------------------------------------- + + /** Delete all event keys from storage in batches of DELETE_BATCH_SIZE. */ + private async purgeEventKeys(): Promise { + const events = await this.ctx.storage.list({ prefix: 'event:' }); + if (events.size === 0) return; + + const keys = [...events.keys()]; + for (let i = 0; i < keys.length; i += DELETE_BATCH_SIZE) { + const batch = keys.slice(i, i + DELETE_BATCH_SIZE); + await this.ctx.storage.delete(batch); + } + } + + // --------------------------------------------------------------------------- + // Expiry + Cleanup + // --------------------------------------------------------------------------- + /** * Transition room to expired status and purge sensitive material. + * Writes tombstone first, then purges event keys (best-effort). * Returns true if the tombstone was written, false if storage failed. - * Callers should still return 410/close even on false — fail closed. */ private async markExpired(roomState: RoomDurableState, except?: WebSocket): Promise { - if (roomState.status === 'expired') { + if (roomState.status === 'expired' || roomState.status === 'deleted') { return true; } - // Destructure out sensitive material — don't use delete on typed objects const { snapshotCiphertext: _scrubCiphertext, snapshotSeq: _scrubSeq, @@ -308,10 +752,10 @@ export class RoomDurableObject extends DurableObject { status: 'expired', roomVerifier: '', adminVerifier: '', - eventLog: [], expiredAt: Date.now(), }; + // Write tombstone first — critical path try { await this.ctx.storage.put('room', expiredState); } catch (e) { @@ -320,12 +764,48 @@ export class RoomDurableObject extends DurableObject { return false; } + // Purge event keys (best-effort after tombstone is written) + try { + await this.purgeEventKeys(); + } catch (e) { + safeLog('room:expire-purge-error', { roomId: roomState.roomId, error: String(e) }); + } + this.closeRoomSockets('Room expired', except); safeLog('room:expired', { roomId: roomState.roomId }); return true; } - /** Close all accepted WebSockets, optionally skipping one (e.g., the caller's socket). */ + // --------------------------------------------------------------------------- + // Broadcast Helpers + // --------------------------------------------------------------------------- + + private broadcastToAll(message: RoomTransportMessage): void { + const json = JSON.stringify(message); + for (const socket of this.ctx.getWebSockets()) { + const att = socket.deserializeAttachment() as WebSocketAttachment | null; + if (att?.authenticated) { + try { socket.send(json); } catch { /* socket may have closed */ } + } + } + } + + private broadcastToOthers(exclude: WebSocket, message: RoomTransportMessage): void { + const json = JSON.stringify(message); + for (const socket of this.ctx.getWebSockets()) { + if (socket === exclude) continue; + const att = socket.deserializeAttachment() as WebSocketAttachment | null; + if (att?.authenticated) { + try { socket.send(json); } catch { /* socket may have closed */ } + } + } + } + + private sendError(ws: WebSocket, code: string, message: string): void { + const error: RoomTransportMessage = { type: 'room.error', code, message }; + try { ws.send(JSON.stringify(error)); } catch { /* socket may have closed */ } + } + private closeRoomSockets(reason: string, except?: WebSocket): void { for (const socket of this.ctx.getWebSockets()) { if (socket !== except) { diff --git a/apps/room-service/core/room-engine.test.ts b/apps/room-service/core/room-engine.test.ts new file mode 100644 index 00000000..30d81bd0 --- /dev/null +++ b/apps/room-service/core/room-engine.test.ts @@ -0,0 +1,214 @@ +/** + * Slice 3 engine tests — validation, admin proofs, and lifecycle helpers. + * + * Tests act as external clients and import from @plannotator/shared/collab/client. + */ + +import { describe, expect, test } from 'bun:test'; +import { + validateServerEnvelope, + validateAdminCommandEnvelope, + isValidationError, +} from './validation'; +import type { ValidationError } from './validation'; +import { + deriveAdminKey, + computeAdminVerifier, + computeAdminProof, + verifyAdminProof, + generateChallengeId, + generateNonce, +} from '@plannotator/shared/collab/client'; +import type { AdminCommand } from '@plannotator/shared/collab'; + +// --------------------------------------------------------------------------- +// validateServerEnvelope +// --------------------------------------------------------------------------- + +describe('validateServerEnvelope', () => { + const validEvent = { + clientId: 'client-1', + opId: 'op-abc', + channel: 'event', + ciphertext: 'encrypted-data', + }; + + test('accepts valid event envelope', () => { + const result = validateServerEnvelope(validEvent); + expect(isValidationError(result)).toBe(false); + }); + + test('accepts valid presence envelope', () => { + const result = validateServerEnvelope({ ...validEvent, channel: 'presence' }); + expect(isValidationError(result)).toBe(false); + }); + + test('rejects missing clientId', () => { + const { clientId: _, ...rest } = validEvent; + expect(isValidationError(validateServerEnvelope(rest))).toBe(true); + }); + + test('rejects missing opId', () => { + const { opId: _, ...rest } = validEvent; + expect(isValidationError(validateServerEnvelope(rest))).toBe(true); + }); + + test('rejects invalid channel', () => { + expect(isValidationError(validateServerEnvelope({ ...validEvent, channel: 'invalid' }))).toBe(true); + }); + + test('rejects missing ciphertext', () => { + const { ciphertext: _, ...rest } = validEvent; + expect(isValidationError(validateServerEnvelope(rest))).toBe(true); + }); + + test('rejects oversized event ciphertext (> 512 KB)', () => { + const result = validateServerEnvelope({ ...validEvent, ciphertext: 'x'.repeat(512_001) }); + expect(isValidationError(result)).toBe(true); + expect((result as ValidationError).status).toBe(413); + }); + + test('rejects oversized presence ciphertext (> 8 KB)', () => { + const result = validateServerEnvelope({ ...validEvent, channel: 'presence', ciphertext: 'x'.repeat(8_193) }); + expect(isValidationError(result)).toBe(true); + expect((result as ValidationError).status).toBe(413); + }); +}); + +// --------------------------------------------------------------------------- +// validateAdminCommandEnvelope +// --------------------------------------------------------------------------- + +describe('validateAdminCommandEnvelope', () => { + const validLock = { + type: 'admin.command', + challengeId: 'ch_abc', + clientId: 'client-1', + command: { type: 'room.lock' }, + adminProof: 'proof-data', + }; + + test('accepts valid lock command', () => { + const result = validateAdminCommandEnvelope(validLock); + expect(isValidationError(result)).toBe(false); + }); + + test('accepts valid unlock command', () => { + const result = validateAdminCommandEnvelope({ ...validLock, command: { type: 'room.unlock' } }); + expect(isValidationError(result)).toBe(false); + }); + + test('accepts valid delete command', () => { + const result = validateAdminCommandEnvelope({ ...validLock, command: { type: 'room.delete' } }); + expect(isValidationError(result)).toBe(false); + }); + + test('accepts lock with snapshot pair', () => { + const result = validateAdminCommandEnvelope({ + ...validLock, + command: { type: 'room.lock', finalSnapshotCiphertext: 'encrypted', finalSnapshotAtSeq: 5 }, + }); + expect(isValidationError(result)).toBe(false); + }); + + test('rejects lock with ciphertext but no atSeq', () => { + const result = validateAdminCommandEnvelope({ + ...validLock, + command: { type: 'room.lock', finalSnapshotCiphertext: 'encrypted' }, + }); + expect(isValidationError(result)).toBe(true); + expect((result as ValidationError).error).toContain('both present or both absent'); + }); + + test('rejects lock with atSeq but no ciphertext', () => { + const result = validateAdminCommandEnvelope({ + ...validLock, + command: { type: 'room.lock', finalSnapshotAtSeq: 5 }, + }); + expect(isValidationError(result)).toBe(true); + }); + + test('rejects oversized snapshot in lock', () => { + const result = validateAdminCommandEnvelope({ + ...validLock, + command: { type: 'room.lock', finalSnapshotCiphertext: 'x'.repeat(1_500_001), finalSnapshotAtSeq: 5 }, + }); + expect(isValidationError(result)).toBe(true); + expect((result as ValidationError).status).toBe(413); + }); + + test('rejects unknown command type', () => { + expect(isValidationError(validateAdminCommandEnvelope({ ...validLock, command: { type: 'room.explode' } }))).toBe(true); + }); + + test('rejects missing challengeId', () => { + const { challengeId: _, ...rest } = validLock; + expect(isValidationError(validateAdminCommandEnvelope(rest))).toBe(true); + }); + + test('rejects missing adminProof', () => { + const { adminProof: _, ...rest } = validLock; + expect(isValidationError(validateAdminCommandEnvelope(rest))).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// Admin Proof Round-Trip +// --------------------------------------------------------------------------- + +const ADMIN_SECRET = new Uint8Array(32); +ADMIN_SECRET.fill(0xcd); +const ROOM_ID = 'test-room-admin-proof'; + +describe('admin proof verification (end-to-end)', () => { + test('valid admin proof is accepted', async () => { + const adminKey = await deriveAdminKey(ADMIN_SECRET); + const verifier = await computeAdminVerifier(adminKey, ROOM_ID); + const challengeId = generateChallengeId(); + const nonce = generateNonce(); + const command: AdminCommand = { type: 'room.lock' }; + + const proof = await computeAdminProof(verifier, ROOM_ID, 'client-1', challengeId, nonce, command); + const valid = await verifyAdminProof(verifier, ROOM_ID, 'client-1', challengeId, nonce, command, proof); + expect(valid).toBe(true); + }); + + test('wrong proof is rejected', async () => { + const adminKey = await deriveAdminKey(ADMIN_SECRET); + const verifier = await computeAdminVerifier(adminKey, ROOM_ID); + const challengeId = generateChallengeId(); + const nonce = generateNonce(); + const command: AdminCommand = { type: 'room.lock' }; + + const valid = await verifyAdminProof(verifier, ROOM_ID, 'client-1', challengeId, nonce, command, 'garbage-proof'); + expect(valid).toBe(false); + }); + + test('lock proof cannot verify as delete (command binding via canonicalJson)', async () => { + const adminKey = await deriveAdminKey(ADMIN_SECRET); + const verifier = await computeAdminVerifier(adminKey, ROOM_ID); + const challengeId = generateChallengeId(); + const nonce = generateNonce(); + + const lockCommand: AdminCommand = { type: 'room.lock' }; + const deleteCommand: AdminCommand = { type: 'room.delete' }; + + const proof = await computeAdminProof(verifier, ROOM_ID, 'client-1', challengeId, nonce, lockCommand); + const valid = await verifyAdminProof(verifier, ROOM_ID, 'client-1', challengeId, nonce, deleteCommand, proof); + expect(valid).toBe(false); + }); + + test('lock proof with snapshot is bound to snapshot content', async () => { + const adminKey = await deriveAdminKey(ADMIN_SECRET); + const verifier = await computeAdminVerifier(adminKey, ROOM_ID); + const challengeId = generateChallengeId(); + const nonce = generateNonce(); + + const cmd1: AdminCommand = { type: 'room.lock', finalSnapshotCiphertext: 'aaa', finalSnapshotAtSeq: 5 }; + const cmd2: AdminCommand = { type: 'room.lock', finalSnapshotCiphertext: 'bbb', finalSnapshotAtSeq: 5 }; + + const proof = await computeAdminProof(verifier, ROOM_ID, 'client-1', challengeId, nonce, cmd1); + const valid = await verifyAdminProof(verifier, ROOM_ID, 'client-1', challengeId, nonce, cmd2, proof); + expect(valid).toBe(false); + }); +}); diff --git a/apps/room-service/core/types.ts b/apps/room-service/core/types.ts index 8a11125e..4c06dcae 100644 --- a/apps/room-service/core/types.ts +++ b/apps/room-service/core/types.ts @@ -6,7 +6,7 @@ * DO hibernation via serializeAttachment/deserializeAttachment. */ -import type { RoomStatus, SequencedEnvelope } from '@plannotator/shared/collab'; +import type { RoomStatus } from '@plannotator/shared/collab'; // --------------------------------------------------------------------------- // Worker Environment @@ -20,7 +20,12 @@ export interface Env { BASE_URL?: string; } -/** Durable state stored in DO storage under key 'room'. */ +/** + * Durable state stored in DO storage under key 'room'. + * + * Events are NOT stored in this record — they use separate per-event keys + * ('event:0000000001', etc.) to stay within DO per-value size limits. + */ export interface RoomDurableState { /** Stored at creation — DO can't reverse idFromName(). */ roomId: string; @@ -28,10 +33,10 @@ export interface RoomDurableState { roomVerifier: string; adminVerifier: string; seq: number; + /** Oldest event seq still in storage. Initialized to 1 at creation. */ + earliestRetainedSeq: number; snapshotCiphertext?: string; snapshotSeq?: number; - /** Empty in Slice 2 — populated by Slice 3 event sequencing. */ - eventLog: SequencedEnvelope[]; lockedAt?: number; deletedAt?: number; expiredAt?: number; @@ -42,9 +47,15 @@ export interface RoomDurableState { * WebSocket attachment — survives hibernation via serializeAttachment/deserializeAttachment. * * Pre-auth: holds pending challenge state so the DO can verify after waking. - * Post-auth: holds authenticated connection metadata. + * Post-auth: holds authenticated connection metadata + optional pending admin challenge. * Both variants carry roomId so webSocketMessage() can access it without a storage read. */ export type WebSocketAttachment = | { authenticated: false; roomId: string; challengeId: string; nonce: string; expiresAt: number } - | { authenticated: true; roomId: string; clientId: string; authenticatedAt: number }; + | { + authenticated: true; + roomId: string; + clientId: string; + authenticatedAt: number; + pendingAdminChallenge?: { challengeId: string; nonce: string; expiresAt: number }; + }; diff --git a/apps/room-service/core/validation.ts b/apps/room-service/core/validation.ts index 39274f37..99cb8e38 100644 --- a/apps/room-service/core/validation.ts +++ b/apps/room-service/core/validation.ts @@ -3,7 +3,7 @@ * Fully testable with bun:test. */ -import type { CreateRoomRequest } from '@plannotator/shared/collab'; +import type { CreateRoomRequest, ServerEnvelope, AdminCommandEnvelope } from '@plannotator/shared/collab'; export interface ValidationError { error: string; @@ -14,6 +14,8 @@ const MIN_EXPIRY_DAYS = 1; const MAX_EXPIRY_DAYS = 30; const DEFAULT_EXPIRY_DAYS = 30; const MAX_SNAPSHOT_CIPHERTEXT_LENGTH = 1_500_000; // ~1.5 MB +const MAX_EVENT_CIPHERTEXT_LENGTH = 512_000; // ~512 KB per event +const MAX_PRESENCE_CIPHERTEXT_LENGTH = 8_192; // ~8 KB per presence update /** Clamp expiry days to [1, 30], default 30. */ export function clampExpiryDays(days: number | undefined): number { @@ -85,7 +87,93 @@ export function validateCreateRoomRequest( }; } -/** Type guard: is this a ValidationError (not a valid request)? */ -export function isValidationError(result: CreateRoomRequest | ValidationError): result is ValidationError { - return 'error' in result; +/** Type guard: is the result a ValidationError? Works with any validated union. */ +export function isValidationError(result: T | ValidationError): result is ValidationError { + return typeof result === 'object' && result !== null && 'error' in result; +} + +// --------------------------------------------------------------------------- +// Post-Auth Message Validation (Slice 3) +// --------------------------------------------------------------------------- + +const VALID_CHANNELS = new Set(['event', 'presence']); +const VALID_ADMIN_COMMANDS = new Set(['room.lock', 'room.unlock', 'room.delete']); + +/** Validate a ServerEnvelope from an authenticated WebSocket message. */ +export function validateServerEnvelope( + msg: Record, +): ServerEnvelope | ValidationError { + if (!isNonEmptyString(msg.clientId)) { + return { error: 'Missing or empty "clientId"', status: 400 }; + } + if (!isNonEmptyString(msg.opId)) { + return { error: 'Missing or empty "opId"', status: 400 }; + } + if (!isNonEmptyString(msg.channel) || !VALID_CHANNELS.has(msg.channel)) { + return { error: '"channel" must be "event" or "presence"', status: 400 }; + } + if (!isNonEmptyString(msg.ciphertext)) { + return { error: 'Missing or empty "ciphertext"', status: 400 }; + } + + const maxSize = msg.channel === 'presence' + ? MAX_PRESENCE_CIPHERTEXT_LENGTH + : MAX_EVENT_CIPHERTEXT_LENGTH; + if (msg.ciphertext.length > maxSize) { + return { error: `Ciphertext exceeds max size for ${msg.channel} (${Math.round(maxSize / 1024)} KB)`, status: 413 }; + } + + return { + clientId: msg.clientId, + opId: msg.opId, + channel: msg.channel as 'event' | 'presence', + ciphertext: msg.ciphertext, + }; +} + +/** Validate an AdminCommandEnvelope from an authenticated WebSocket message. */ +export function validateAdminCommandEnvelope( + msg: Record, +): AdminCommandEnvelope | ValidationError { + if (!isNonEmptyString(msg.challengeId)) { + return { error: 'Missing or empty "challengeId"', status: 400 }; + } + if (!isNonEmptyString(msg.clientId)) { + return { error: 'Missing or empty "clientId"', status: 400 }; + } + if (!isNonEmptyString(msg.adminProof)) { + return { error: 'Missing or empty "adminProof"', status: 400 }; + } + + if (!msg.command || typeof msg.command !== 'object') { + return { error: 'Missing or invalid "command"', status: 400 }; + } + + const cmd = msg.command as Record; + if (!isNonEmptyString(cmd.type) || !VALID_ADMIN_COMMANDS.has(cmd.type)) { + return { error: `Unknown command type: ${String(cmd.type)}`, status: 400 }; + } + + // Validate room.lock snapshot pair — both present or both absent + if (cmd.type === 'room.lock') { + const hasCiphertext = isNonEmptyString(cmd.finalSnapshotCiphertext); + const hasAtSeq = typeof cmd.finalSnapshotAtSeq === 'number'; + if (hasCiphertext !== hasAtSeq) { + return { error: '"finalSnapshotCiphertext" and "finalSnapshotAtSeq" must be both present or both absent', status: 400 }; + } + if (hasCiphertext && (cmd.finalSnapshotCiphertext as string).length > MAX_SNAPSHOT_CIPHERTEXT_LENGTH) { + return { error: `"finalSnapshotCiphertext" exceeds max size (${Math.round(MAX_SNAPSHOT_CIPHERTEXT_LENGTH / 1024)} KB)`, status: 413 }; + } + if (hasAtSeq && ((cmd.finalSnapshotAtSeq as number) < 0 || !Number.isInteger(cmd.finalSnapshotAtSeq))) { + return { error: '"finalSnapshotAtSeq" must be a non-negative integer', status: 400 }; + } + } + + return { + type: 'admin.command', + challengeId: msg.challengeId, + clientId: msg.clientId, + command: msg.command as AdminCommandEnvelope['command'], + adminProof: msg.adminProof, + }; } diff --git a/apps/room-service/package.json b/apps/room-service/package.json index f2f75099..854f254c 100644 --- a/apps/room-service/package.json +++ b/apps/room-service/package.json @@ -12,6 +12,6 @@ }, "devDependencies": { "@cloudflare/workers-types": "^4.20241218.0", - "wrangler": "^3.99.0" + "wrangler": "^4.80.0" } } diff --git a/apps/room-service/scripts/smoke.ts b/apps/room-service/scripts/smoke.ts index b7eeb819..a40e2296 100644 --- a/apps/room-service/scripts/smoke.ts +++ b/apps/room-service/scripts/smoke.ts @@ -17,19 +17,23 @@ import { computeRoomVerifier, computeAdminVerifier, computeAuthProof, + computeAdminProof, encryptSnapshot, + encryptPayload, generateRoomId, generateRoomSecret, generateAdminSecret, generateClientId, + generateOpId, } from '@plannotator/shared/collab/client'; import type { CreateRoomRequest, CreateRoomResponse, - AuthChallenge, - AuthAccepted, + AdminChallenge, + AdminCommand, RoomSnapshot, + RoomTransportMessage, } from '@plannotator/shared/collab'; const BASE_URL = process.env.SMOKE_BASE_URL || 'http://localhost:8787'; @@ -48,6 +52,71 @@ function assert(condition: boolean, label: string): void { } } +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Messages received on the socket — includes transport messages and admin challenges. */ +type SmokeMessage = RoomTransportMessage | AdminChallenge; + +interface AuthedSocket { + ws: WebSocket; + clientId: string; + messages: SmokeMessage[]; + closed: boolean; +} + +/** Connect, authenticate, and return a ready socket that collects messages. */ +async function connectAndAuth( + roomId: string, + roomVerifier: string, + lastSeq?: number, +): Promise { + return new Promise((resolve, reject) => { + const ws = new WebSocket(`${WS_BASE}/ws/${roomId}`); + const clientId = generateClientId(); + const result: AuthedSocket = { ws, clientId, messages: [], closed: false }; + let authed = false; + + const timeout = setTimeout(() => { + if (!authed) { ws.close(); reject(new Error('Auth timeout')); } + }, 10_000); + + ws.onmessage = async (event) => { + const msg = JSON.parse(String(event.data)); + + if (!authed && msg.type === 'auth.challenge') { + const proof = await computeAuthProof(roomVerifier, roomId, clientId, msg.challengeId, msg.nonce); + ws.send(JSON.stringify({ type: 'auth.response', challengeId: msg.challengeId, clientId, proof, lastSeq })); + return; + } + + if (!authed && msg.type === 'auth.accepted') { + authed = true; + clearTimeout(timeout); + // Collect subsequent messages + ws.onmessage = (e) => { + result.messages.push(JSON.parse(String(e.data))); + }; + resolve(result); + return; + } + }; + + ws.onclose = () => { result.closed = true; }; + ws.onerror = () => { if (!authed) reject(new Error('WebSocket error')); }; + }); +} + +/** Wait briefly for messages to arrive. */ +function wait(ms: number): Promise { + return new Promise(r => setTimeout(r, ms)); +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + async function run(): Promise { console.log(`\nSmoke testing room-service at ${BASE_URL}\n`); @@ -57,8 +126,6 @@ async function run(): Promise { console.log('1. Health check'); const healthRes = await fetch(`${BASE_URL}/health`); assert(healthRes.ok, 'GET /health returns 200'); - const healthBody = await healthRes.json() as { ok: boolean }; - assert(healthBody.ok === true, 'Response body is { ok: true }'); // ----------------------------------------------------------------------- // 2. Create a room @@ -74,11 +141,7 @@ async function run(): Promise { const roomVerifier = await computeRoomVerifier(authKey, roomId); const adminVerifier = await computeAdminVerifier(adminKey, roomId); - const snapshot: RoomSnapshot = { - versionId: 'v1', - planMarkdown: '# Smoke Test Plan\n\nThis is a test.', - annotations: [], - }; + const snapshot: RoomSnapshot = { versionId: 'v1', planMarkdown: '# Smoke Test', annotations: [] }; const snapshotCiphertext = await encryptSnapshot(eventKey, snapshot); const createBody: CreateRoomRequest = { @@ -96,11 +159,7 @@ async function run(): Promise { assert(createRes.status === 201, 'POST /api/rooms returns 201'); const createResponseBody = await createRes.json() as CreateRoomResponse; - assert(createResponseBody.roomId === roomId, 'Response contains roomId'); - assert(createResponseBody.status === 'active', 'Status is active'); - assert(createResponseBody.seq === 0, 'seq is 0'); assert(!createResponseBody.joinUrl.includes('#'), 'joinUrl has no fragment'); - assert(!createResponseBody.websocketUrl.includes('?'), 'websocketUrl has no query params'); // ----------------------------------------------------------------------- // 3. Duplicate room creation → 409 @@ -111,106 +170,217 @@ async function run(): Promise { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(createBody), }); - assert(dupRes.status === 409, 'Duplicate POST /api/rooms returns 409'); + assert(dupRes.status === 409, 'Duplicate returns 409'); // ----------------------------------------------------------------------- - // 4. WebSocket auth — valid proof + // 4. Fresh join receives snapshot // ----------------------------------------------------------------------- - console.log('\n4. WebSocket auth (valid proof)'); - const validAuth = await testWebSocketAuth(roomId, roomVerifier, true); - assert(validAuth, 'Valid proof → auth.accepted'); + console.log('\n4. Fresh join receives snapshot'); + const client1 = await connectAndAuth(roomId, roomVerifier); + await wait(200); + const snapshots1 = client1.messages.filter(m => m.type === 'room.snapshot'); + assert(snapshots1.length === 1, 'Client1 received room.snapshot on join'); // ----------------------------------------------------------------------- - // 5. WebSocket auth — invalid proof + // 5. Two clients — event echo + broadcast // ----------------------------------------------------------------------- - console.log('\n5. WebSocket auth (invalid proof)'); - const invalidAuth = await testWebSocketAuth(roomId, roomVerifier, false); - assert(!invalidAuth, 'Invalid proof → connection closed'); + console.log('\n5. Event sequencing + echo'); + const client2 = await connectAndAuth(roomId, roomVerifier); + await wait(200); + // Clear join messages + client1.messages.length = 0; + client2.messages.length = 0; + + // Client1 sends an event + const eventCiphertext = await encryptPayload(eventKey, JSON.stringify({ type: 'annotation.add', annotations: [] })); + client1.ws.send(JSON.stringify({ + clientId: client1.clientId, + opId: generateOpId(), + channel: 'event', + ciphertext: eventCiphertext, + })); + await wait(500); + + const client1Events = client1.messages.filter(m => m.type === 'room.event'); + const client2Events = client2.messages.filter(m => m.type === 'room.event'); + assert(client1Events.length === 1, 'Sender receives echo (room.event)'); + assert(client2Events.length === 1, 'Other client receives room.event'); // ----------------------------------------------------------------------- - // Summary + // 6. Presence relay — others only // ----------------------------------------------------------------------- - console.log(`\n${'='.repeat(40)}`); - console.log(`Passed: ${passed}, Failed: ${failed}`); - if (failed > 0) { - process.exit(1); + console.log('\n6. Presence relay'); + client1.messages.length = 0; + client2.messages.length = 0; + + const presenceCiphertext = await encryptPayload(eventKey, '{}'); + client1.ws.send(JSON.stringify({ + clientId: client1.clientId, + opId: generateOpId(), + channel: 'presence', + ciphertext: presenceCiphertext, + })); + await wait(300); + + const client1Presence = client1.messages.filter(m => m.type === 'room.presence'); + const client2Presence = client2.messages.filter(m => m.type === 'room.presence'); + assert(client1Presence.length === 0, 'Sender does NOT receive own presence'); + assert(client2Presence.length === 1, 'Other client receives room.presence'); + + // ----------------------------------------------------------------------- + // 7. Reconnect replay + // ----------------------------------------------------------------------- + console.log('\n7. Reconnect replay'); + client2.ws.close(); + await wait(200); + + // Client1 sends another event while client2 is disconnected + client1.ws.send(JSON.stringify({ + clientId: client1.clientId, + opId: generateOpId(), + channel: 'event', + ciphertext: eventCiphertext, + })); + await wait(300); + + // Client2 reconnects with lastSeq from the first event (seq 1) + const client2b = await connectAndAuth(roomId, roomVerifier, 1); + await wait(500); + + const replayedEvents = client2b.messages.filter(m => m.type === 'room.event'); + assert(replayedEvents.length === 1, 'Reconnect replayed 1 missed event (seq 2)'); + if (replayedEvents.length > 0 && replayedEvents[0].type === 'room.event') { + assert(replayedEvents[0].seq === 2, 'Replayed event has seq 2'); } -} -async function testWebSocketAuth( - roomId: string, - roomVerifier: string, - useValidProof: boolean, -): Promise { - return new Promise((resolve) => { - const ws = new WebSocket(`${WS_BASE}/ws/${roomId}`); - let resolved = false; + // ----------------------------------------------------------------------- + // 8. Admin lock + // ----------------------------------------------------------------------- + console.log('\n8. Admin lock'); + client1.messages.length = 0; + client2b.messages.length = 0; - const timeout = setTimeout(() => { - if (!resolved) { - resolved = true; - ws.close(); - resolve(false); - } - }, 10_000); + // Request admin challenge + client1.ws.send(JSON.stringify({ type: 'admin.challenge.request' })); + await wait(300); + const adminChallengeMsg = client1.messages.find(m => m.type === 'admin.challenge') as AdminChallenge | undefined; + assert(!!adminChallengeMsg, 'Received admin.challenge'); - ws.onmessage = async (event) => { - try { - const msg = JSON.parse(String(event.data)); - - if (msg.type === 'auth.challenge') { - const clientId = generateClientId(); - let proof: string; - - if (useValidProof) { - proof = await computeAuthProof( - roomVerifier, - roomId, - clientId, - msg.challengeId, - msg.nonce, - ); - } else { - proof = 'invalid-proof-garbage'; - } - - ws.send(JSON.stringify({ - type: 'auth.response', - challengeId: msg.challengeId, - clientId, - proof, - })); - } - - if (msg.type === 'auth.accepted') { - if (!resolved) { - resolved = true; - clearTimeout(timeout); - ws.close(); - resolve(true); - } - } - } catch (e) { - console.error(' WebSocket message error:', e); - } - }; + if (adminChallengeMsg) { + const lockCmd: AdminCommand = { type: 'room.lock' }; + const adminProof = await computeAdminProof( + adminVerifier, roomId, client1.clientId, + adminChallengeMsg.challengeId, adminChallengeMsg.nonce, lockCmd, + ); + client1.ws.send(JSON.stringify({ + type: 'admin.command', + challengeId: adminChallengeMsg.challengeId, + clientId: client1.clientId, + command: lockCmd, + adminProof, + })); + await wait(300); - ws.onclose = () => { - if (!resolved) { - resolved = true; - clearTimeout(timeout); - resolve(false); - } - }; + const statusMsgs1 = client1.messages.filter(m => m.type === 'room.status'); + const statusMsgs2 = client2b.messages.filter(m => m.type === 'room.status'); + assert(statusMsgs1.some(m => m.type === 'room.status' && m.status === 'locked'), 'Client1 receives room.status: locked'); + assert(statusMsgs2.some(m => m.type === 'room.status' && m.status === 'locked'), 'Client2 receives room.status: locked'); + } - ws.onerror = () => { - if (!resolved) { - resolved = true; - clearTimeout(timeout); - resolve(false); - } - }; - }); + // ----------------------------------------------------------------------- + // 9. Locked room rejects events + // ----------------------------------------------------------------------- + console.log('\n9. Locked room rejects events'); + client1.messages.length = 0; + + client1.ws.send(JSON.stringify({ + clientId: client1.clientId, + opId: generateOpId(), + channel: 'event', + ciphertext: eventCiphertext, + })); + await wait(300); + + const errorMsgs = client1.messages.filter(m => m.type === 'room.error'); + assert(errorMsgs.length > 0, 'Locked room sends room.error for event'); + assert(!client1.closed, 'Socket stays open after room.error'); + + // ----------------------------------------------------------------------- + // 10. Admin unlock + // ----------------------------------------------------------------------- + console.log('\n10. Admin unlock'); + client1.messages.length = 0; + client2b.messages.length = 0; + + client1.ws.send(JSON.stringify({ type: 'admin.challenge.request' })); + await wait(300); + const unlockChallenge = client1.messages.find(m => m.type === 'admin.challenge') as AdminChallenge | undefined; + + if (unlockChallenge) { + const unlockCmd: AdminCommand = { type: 'room.unlock' }; + const unlockProof = await computeAdminProof( + adminVerifier, roomId, client1.clientId, + unlockChallenge.challengeId, unlockChallenge.nonce, unlockCmd, + ); + client1.ws.send(JSON.stringify({ + type: 'admin.command', + challengeId: unlockChallenge.challengeId, + clientId: client1.clientId, + command: unlockCmd, + adminProof: unlockProof, + })); + await wait(300); + + assert(client1.messages.some(m => m.type === 'room.status' && m.status === 'active'), 'Unlock broadcasts room.status: active'); + } + + // ----------------------------------------------------------------------- + // 11. Admin delete + // ----------------------------------------------------------------------- + console.log('\n11. Admin delete'); + client1.messages.length = 0; + + client1.ws.send(JSON.stringify({ type: 'admin.challenge.request' })); + await wait(300); + const deleteChallenge = client1.messages.find(m => m.type === 'admin.challenge') as AdminChallenge | undefined; + + if (deleteChallenge) { + const deleteCmd: AdminCommand = { type: 'room.delete' }; + const deleteProof = await computeAdminProof( + adminVerifier, roomId, client1.clientId, + deleteChallenge.challengeId, deleteChallenge.nonce, deleteCmd, + ); + client1.ws.send(JSON.stringify({ + type: 'admin.command', + challengeId: deleteChallenge.challengeId, + clientId: client1.clientId, + command: deleteCmd, + adminProof: deleteProof, + })); + await wait(500); + + assert(client1.closed, 'Client1 socket closed after delete'); + assert(client2b.closed, 'Client2 socket closed after delete'); + } + + // ----------------------------------------------------------------------- + // 12. Deleted room rejects new joins + // ----------------------------------------------------------------------- + console.log('\n12. Deleted room rejects new joins'); + try { + const client3 = await connectAndAuth(roomId, roomVerifier); + client3.ws.close(); + assert(false, 'Should not authenticate to deleted room'); + } catch { + assert(true, 'Deleted room rejects new WebSocket join'); + } + + // ----------------------------------------------------------------------- + // Summary + // ----------------------------------------------------------------------- + console.log(`\n${'='.repeat(40)}`); + console.log(`Passed: ${passed}, Failed: ${failed}`); + process.exit(failed > 0 ? 1 : 0); } run().catch((err) => { diff --git a/packages/shared/collab/types.ts b/packages/shared/collab/types.ts index 46fbab38..a1807465 100644 --- a/packages/shared/collab/types.ts +++ b/packages/shared/collab/types.ts @@ -119,7 +119,8 @@ export type RoomTransportMessage = | { type: 'room.snapshot'; snapshotSeq: number; snapshotCiphertext: string } | { type: 'room.event'; seq: number; receivedAt: number; envelope: ServerEnvelope } | { type: 'room.presence'; envelope: ServerEnvelope } - | { type: 'room.status'; status: RoomStatus }; + | { type: 'room.status'; status: RoomStatus } + | { type: 'room.error'; code: string; message: string }; // --------------------------------------------------------------------------- // Room Status @@ -169,7 +170,7 @@ export interface AuthAccepted { // --------------------------------------------------------------------------- export type AdminCommand = - | { type: 'room.lock'; finalSnapshotCiphertext?: string } + | { type: 'room.lock'; finalSnapshotCiphertext?: string; finalSnapshotAtSeq?: number } | { type: 'room.unlock' } | { type: 'room.delete' }; diff --git a/specs/done/v1-slice3-plan.md b/specs/done/v1-slice3-plan.md new file mode 100644 index 00000000..66b810a3 --- /dev/null +++ b/specs/done/v1-slice3-plan.md @@ -0,0 +1,291 @@ +# Slice 3: Durable Room Engine + +## Context + +Slices 1-2 created the collab protocol contract and the room-service skeleton (room creation + WebSocket auth). Slice 3 fills in the actual room behavior: event sequencing, presence relay, reconnect replay, admin commands, and lifecycle enforcement. After Slice 3, two test clients can create a room, exchange encrypted annotations in real time, lock/unlock/delete, and reconnect with catch-up replay. + +**Zero-knowledge constraint:** The DO stores ciphertext only and cannot decrypt events or build snapshots. Snapshots enter the DO only via room creation (`initialSnapshotCiphertext`) and `room.lock` (`finalSnapshotCiphertext`). Standalone admin-authenticated snapshot upload is deferred. + +## Storage Layout + +Events are stored as **separate per-event keys**, not as an array in the room state. This is critical because SQLite-backed DO storage has a 2 MB combined key+value limit per entry. A single event ciphertext can be up to 512 KB, so storing even a few events in one JSON value would exceed limits. + +**Key layout:** +- `room` → room metadata only (status, verifiers, seq, snapshotSeq, snapshot ciphertext, timestamps). No event array. +- `event:0000000001` → individual `SequencedEnvelope` (10-digit zero-padded seq for lexicographic ordering) +- `event:0000000002` → individual `SequencedEnvelope` +- etc. + +`RoomDurableState` changes from Slice 2: **remove `eventLog: SequencedEnvelope[]`**, add `earliestRetainedSeq: number` to track the oldest event still in storage. + +For replay: use `ctx.storage.list({ prefix: 'event:', start: 'event:...' })` to efficiently read a range. +For purge (delete/expire): iterate and delete all event keys from `earliestRetainedSeq` through `seq`. + +## Snapshot and Compaction Policy (V1) + +For V1, **no standalone snapshot upload or compaction during active rooms**. Snapshots exist from two sources: +1. Room creation — `initialSnapshotCiphertext` with `snapshotSeq: 0` +2. Room lock — optional `finalSnapshotCiphertext` with `finalSnapshotAtSeq` + +The event log grows during active rooms. This is acceptable because: +- Rooms expire after 30 days max +- A typical review session produces 10-50 annotation events +- Each event is stored as a separate key (no single-value size pressure) +- SQLite-backed DO storage has ample total capacity for this + +Reconnect uses the latest available snapshot plus retained events after `snapshotSeq`. + +Admin-authenticated snapshot upload with challenge-response is future scope. `SnapshotUpload` and `CompactionHint` types are deferred. + +## What Changes + +### `packages/shared/collab/types.ts` — Type Changes + +**Extend `AdminCommand` lock variant:** +```ts +| { type: 'room.lock'; finalSnapshotCiphertext?: string; finalSnapshotAtSeq?: number } +``` +If the admin provides a final snapshot when locking, `finalSnapshotAtSeq` must be provided and valid (`<= current seq`, `>= existing snapshotSeq`). The DO stores it as the new `snapshotCiphertext` and `snapshotSeq`. + +**Add error transport type:** +```ts +interface RoomErrorMessage { + type: 'room.error'; + code: string; + message: string; +} +``` + +**Extend `RoomTransportMessage`:** +```ts +| { type: 'room.error'; code: string; message: string } +``` + +### `apps/room-service/core/types.ts` — Storage + Attachment Changes + +**Remove `eventLog` from `RoomDurableState`**, add `earliestRetainedSeq`: +```ts +interface RoomDurableState { + roomId: string; + status: RoomStatus; + roomVerifier: string; + adminVerifier: string; + seq: number; + earliestRetainedSeq: number; // oldest event seq still in storage; initialized to 1 at creation + snapshotCiphertext?: string; + snapshotSeq?: number; + // eventLog REMOVED — events stored as separate 'event:NNNNNNNNNN' keys + lockedAt?: number; + deletedAt?: number; + expiredAt?: number; + expiresAt: number; +} +``` + +**`earliestRetainedSeq` lifecycle:** Initialized to `1` at room creation (no events yet, first event will be seq 1). While no events exist (`seq === 0`), replay detects the empty log and sends the snapshot. Once events are stored, `earliestRetainedSeq` tracks the oldest key. For V1 (no compaction), it stays at `1` — future compaction would advance it. +``` + +**Extend post-auth `WebSocketAttachment`:** +```ts +{ + authenticated: true; + roomId: string; + clientId: string; + authenticatedAt: number; + pendingAdminChallenge?: { challengeId: string; nonce: string; expiresAt: number }; +} +``` + +Every code path that generates, consumes, or clears a `pendingAdminChallenge` must call `ws.serializeAttachment()` to persist across hibernation. + +### `apps/room-service/core/validation.ts` — New Validators + +Add pure validation functions: + +- **`validateServerEnvelope(msg)`** — validates clientId, opId, channel (`"event"` | `"presence"`), ciphertext. Size limits: + - Event ciphertext: max 512 KB (generous for a single annotation op) + - Presence ciphertext: max 8 KB (cursor position + user info is small) +- **`validateAdminCommandEnvelope(msg)`** — validates type, challengeId, clientId, adminProof, command shape. For `room.lock`: `finalSnapshotCiphertext` and `finalSnapshotAtSeq` must be either both present or both absent. If ciphertext present: validates ≤1.5 MB. If `atSeq` present: validates non-negative integer. Rejects if only one of the pair is provided. + +### `apps/room-service/core/room-do.ts` — The Core Engine + +#### Post-Auth Message Dispatch + +Replace the Slice 2 stub with a type dispatcher. `ServerEnvelope` has no `type` field — detect via `channel` field. Other messages have `type`: + +``` +if msg.type === 'admin.challenge.request' → handleAdminChallengeRequest +if msg.type === 'admin.command' → handleAdminCommand +if msg.channel ('event'|'presence') → handleServerEnvelope +else → close with protocol error +``` + +#### Event Sequencing (`channel: "event"`) + +1. Load room state, check `active` (locked → send `room.error` but don't close; deleted/expired → close) +2. **Override `envelope.clientId` with authenticated `meta.clientId`** — prevents spoofing +3. Increment `roomState.seq`, create `SequencedEnvelope` +4. **Store full `SequencedEnvelope` as separate key:** `ctx.storage.put('event:' + padSeq(seq), sequencedEnvelope)` — the value includes `seq`, `receivedAt`, and `envelope` so replay can reconstruct `room.event` transport messages without deriving fields from the key. Update room metadata (`seq`). +5. **Broadcast `room.event` to ALL authenticated sockets including sender** — the sender needs the echo to advance its `lastSeq` and confirm `opId`. The client uses `opId` matching to detect its own events as server-confirmed. + +#### Presence Relay (`channel: "presence"`) + +1. Check `active` or `locked` (presence allowed in locked rooms per spec) +2. Override `envelope.clientId` with authenticated `meta.clientId` +3. **Broadcast `room.presence` to all OTHER authenticated sockets** — presence is volatile, sender doesn't need an echo +4. No storage write, no seq + +#### Reconnect Replay (after auth.accepted) + +Immediately after sending `auth.accepted`: + +- If `lastSeq === roomState.seq`: no replay needed (fully caught up) +- If `lastSeq > roomState.seq`: invalid — client claims a future position. Fall back to snapshot replay (same as "too old" path). Log the anomaly. +- If `lastSeq` provided and `lastSeq >= roomState.earliestRetainedSeq`: read events from storage via `ctx.storage.list({ prefix: 'event:', start: padSeq(lastSeq + 1) })`, send each as `room.event` +- If `lastSeq` not provided or `lastSeq < earliestRetainedSeq`: send latest snapshot as `room.snapshot`, then replay all retained events after `snapshotSeq` via `ctx.storage.list()` + +#### Admin Challenge-Response + +1. `admin.challenge.request` → generate fresh challenge, store in attachment's `pendingAdminChallenge` (serialize attachment), send `AdminChallenge` +2. `admin.command` → validate `msg.clientId === meta.clientId` (reject cross-connection spoofing), save challenge data to local variable, clear from attachment (serialize), verify proof with `verifyAdminProof()` using stored `adminVerifier` and `meta.clientId` (not `msg.clientId`), apply command + +Every attachment mutation serializes immediately. + +Command execution: + +**`room.lock`:** +- Check status is `active` +- If `finalSnapshotCiphertext` + `finalSnapshotAtSeq` provided: validate `atSeq <= roomState.seq` and `atSeq >= (roomState.snapshotSeq ?? 0)`, store as new snapshot +- Set `status: 'locked'`, `lockedAt: Date.now()` +- Persist, broadcast `room.status { status: 'locked' }` to all sockets + +**`room.unlock`:** +- Check status is `locked` +- Set `status: 'active'`, clear `lockedAt` +- Persist, broadcast `room.status { status: 'active' }` to all sockets + +**`room.delete`:** +- Check status is `active` or `locked` +- Purge sensitive material: blank verifiers, clear snapshot, delete all `event:*` keys via `ctx.storage.list({ prefix: 'event:' })` + `ctx.storage.delete()` +- Set `status: 'deleted'`, `deletedAt: Date.now()` +- Persist room metadata, broadcast `room.status { status: 'deleted' }`, close all sockets + +#### Broadcast Helpers + +```ts +private broadcastToAll(message: RoomTransportMessage): void +private broadcastToOthers(exclude: WebSocket, message: RoomTransportMessage): void +``` + +Both iterate `ctx.getWebSockets()`, skip unauthenticated sockets. + +#### Lifecycle Enforcement Matrix + +| Message | `active` | `locked` | `deleted`/`expired` | +|---------|----------|----------|---------------------| +| Event envelope | Sequence + broadcast to all | Send `room.error` (don't close) | Close socket | +| Presence envelope | Broadcast to others | Broadcast to others | Close socket | +| admin.challenge.request | Accept | Accept | Close socket | +| admin lock | Accept | Reject (already locked) | Close socket | +| admin unlock | Reject (not locked) | Accept | Close socket | +| admin delete | Accept | Accept | Reject (terminal) | + +`room.error` messages for recoverable errors (locked room, invalid admin state) keep the socket open for presence/admin operations. + +#### Per-Message Size Limits + +| Message Type | Max Ciphertext | Rationale | +|---|---|---| +| Event envelope | 512 KB | Single annotation op | +| Presence envelope | 8 KB | Cursor + user info | +| Lock finalSnapshotCiphertext | 1.5 MB | Full room snapshot | + +## New Test File + +### `apps/room-service/core/room-engine.test.ts` + +Imports from `@plannotator/shared/collab/client` (acting as external client). + +**Validation tests:** +- `validateServerEnvelope` — valid event/presence, missing fields, wrong channel, event oversized (>512 KB), presence oversized (>8 KB) +- `validateAdminCommandEnvelope` — valid lock/unlock/delete, missing fields, unknown command, lock with oversized snapshot, lock with finalSnapshotAtSeq validation + +**Admin proof round-trip:** +- Full chain: admin secret → key → verifier → proof → verify +- Wrong proof rejected +- Lock proof can't verify as delete (canonicalJson command binding) + +### `scripts/smoke.ts` — Required Extensions + +**Not optional.** Slice 3's acceptance criteria are WebSocket behaviors that need integration testing: +- Two authenticated clients: send event envelope → both receive `room.event` (including sender echo) +- Send presence → only OTHER client receives `room.presence` +- Admin lock → both clients receive `room.status { locked }` +- Locked room rejects event → sender receives `room.error` +- Admin unlock → both receive `room.status { active }` +- Admin delete → both receive `room.status { deleted }`, sockets close +- Reconnect: client 2 disconnects, client 1 sends events, client 2 reconnects with `lastSeq` → replayed events arrive + +## Files to Modify + +| File | Change | +|------|--------| +| `packages/shared/collab/types.ts` | Add `finalSnapshotAtSeq` to lock command, add `RoomErrorMessage`, extend `RoomTransportMessage` | +| `apps/room-service/core/types.ts` | Add `pendingAdminChallenge?` to post-auth attachment | +| `apps/room-service/core/validation.ts` | Add `validateServerEnvelope`, `validateAdminCommandEnvelope`, size limit constants | +| `apps/room-service/core/room-do.ts` | Implement post-auth handlers, reconnect, admin flow, lifecycle enforcement | +| `apps/room-service/scripts/smoke.ts` | Add event/presence/admin/reconnect integration tests | + +## Files to Create + +| File | Purpose | +|------|---------| +| `apps/room-service/core/room-engine.test.ts` | Validation + admin proof + unit tests | + +## Implementation Order + +1. `packages/shared/collab/types.ts` — type changes +2. `apps/room-service/core/types.ts` — attachment extension +3. `apps/room-service/core/validation.ts` — new validators + constants +4. `apps/room-service/core/room-do.ts`: + a. New imports, constants, broadcast helpers + b. Reconnect replay in `handleAuthResponse` + c. Post-auth message dispatch + d. `handleServerEnvelope` (event sequencing + presence relay) + e. `handleAdminChallengeRequest` + f. `handleAdminCommand` (lock/unlock/delete) +5. `apps/room-service/core/room-engine.test.ts` +6. `apps/room-service/scripts/smoke.ts` extensions + +## Verification + +```bash +bun test apps/room-service/ && bun test packages/shared/collab/ +bunx tsc --noEmit -p apps/room-service/tsconfig.json +bunx tsc --noEmit -p packages/shared/tsconfig.json +``` + +Integration testing (required): +```bash +cd apps/room-service && wrangler dev +# In another terminal: +bun run scripts/smoke.ts +``` + +Smoke test verifies: +- Two clients exchange encrypted events in real time (both receive including sender) +- Presence broadcasts to others only +- Admin lock/unlock/delete with challenge-response +- Locked room rejects events, allows presence +- Reconnect replays correct events from lastSeq +- Deleted room closes sockets and rejects new joins with 410 + +Expired room behavior is verified via `hasRoomExpired` unit tests and the lazy expiry enforcement already in the DO (tested in Slice 2). The smoke test cannot naturally create an expired room since minimum expiry is 1 day. + +## What This Slice Does NOT Do + +- Standalone snapshot upload or compaction (admin-authenticated upload is future scope) +- `CompactionHint` or `SnapshotUpload` message types +- React hooks, browser UI, editor integration +- Local SSE bridge or direct-agent client UX +- Approve/deny flow or cursor overlay From 3166f0864df5bd1f7661f1e9fedc9fbf8bf6ac41 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Sat, 18 Apr 2026 19:26:09 -0700 Subject: [PATCH 04/41] feat(collab): browser/direct-agent client runtime + React hook (Slice 4) Adds packages/shared/collab/client-runtime/ (CollabRoomClient class, createRoom/joinRoom factories, apply-event reducer, backoff, opId dedupe, mock-websocket test harness) and packages/ui/hooks/useCollabRoom.ts as the React wrapper. Extends url.ts with admin-fragment parsing and buildAdminRoomUrl. Server-side validation and replay hardening land alongside: admin error-code contract comment, U+0000 replay cursor, cors/WS close constants shared between client and server. For provenance purposes, this commit was AI assisted. --- .gitignore | 4 + AGENTS.md | 32 +- apps/room-service/core/handler.ts | 59 +- apps/room-service/core/room-do.ts | 386 ++- apps/room-service/core/types.ts | 13 +- apps/room-service/core/validation.test.ts | 151 +- apps/room-service/core/validation.ts | 82 +- apps/room-service/scripts/smoke.ts | 36 +- bun.lock | 127 +- package.json | 2 +- .../collab/client-runtime/apply-event.test.ts | 243 ++ .../collab/client-runtime/apply-event.ts | 131 + .../collab/client-runtime/backoff.test.ts | 37 + .../shared/collab/client-runtime/backoff.ts | 34 + .../collab/client-runtime/client.test.ts | 2709 +++++++++++++++++ .../shared/collab/client-runtime/client.ts | 1652 ++++++++++ .../collab/client-runtime/create-room.test.ts | 92 + .../collab/client-runtime/create-room.ts | 130 + .../collab/client-runtime/emitter.test.ts | 77 + .../shared/collab/client-runtime/emitter.ts | 50 + .../shared/collab/client-runtime/index.ts | 20 + .../collab/client-runtime/integration.test.ts | 303 ++ .../collab/client-runtime/join-room.test.ts | 65 + .../shared/collab/client-runtime/join-room.ts | 100 + .../collab/client-runtime/mock-websocket.ts | 156 + .../shared/collab/client-runtime/types.ts | 186 ++ packages/shared/collab/client.ts | 6 + packages/shared/collab/constants.ts | 26 +- packages/shared/collab/crypto.test.ts | 4 +- packages/shared/collab/crypto.ts | 33 +- packages/shared/collab/types.test.ts | 355 +++ packages/shared/collab/types.ts | 297 +- packages/shared/collab/url.test.ts | 79 +- packages/shared/collab/url.ts | 56 +- packages/shared/package.json | 3 +- packages/ui/hooks/useCollabRoom.ts | 300 ++ packages/ui/tsconfig.collab.json | 37 + specs/v1-decisionbridge-local-clarity.md | 57 + specs/v1-decisionbridge.md | 177 ++ specs/v1-implementation-approach.md | 253 ++ specs/v1-prd.md | 126 + specs/v1-slice4-plan.md | 403 +++ specs/v1.md | 826 +++++ 43 files changed, 9760 insertions(+), 155 deletions(-) create mode 100644 packages/shared/collab/client-runtime/apply-event.test.ts create mode 100644 packages/shared/collab/client-runtime/apply-event.ts create mode 100644 packages/shared/collab/client-runtime/backoff.test.ts create mode 100644 packages/shared/collab/client-runtime/backoff.ts create mode 100644 packages/shared/collab/client-runtime/client.test.ts create mode 100644 packages/shared/collab/client-runtime/client.ts create mode 100644 packages/shared/collab/client-runtime/create-room.test.ts create mode 100644 packages/shared/collab/client-runtime/create-room.ts create mode 100644 packages/shared/collab/client-runtime/emitter.test.ts create mode 100644 packages/shared/collab/client-runtime/emitter.ts create mode 100644 packages/shared/collab/client-runtime/index.ts create mode 100644 packages/shared/collab/client-runtime/integration.test.ts create mode 100644 packages/shared/collab/client-runtime/join-room.test.ts create mode 100644 packages/shared/collab/client-runtime/join-room.ts create mode 100644 packages/shared/collab/client-runtime/mock-websocket.ts create mode 100644 packages/shared/collab/client-runtime/types.ts create mode 100644 packages/shared/collab/types.test.ts create mode 100644 packages/ui/hooks/useCollabRoom.ts create mode 100644 packages/ui/tsconfig.collab.json create mode 100644 specs/v1-decisionbridge-local-clarity.md create mode 100644 specs/v1-decisionbridge.md create mode 100644 specs/v1-implementation-approach.md create mode 100644 specs/v1-prd.md create mode 100644 specs/v1-slice4-plan.md create mode 100644 specs/v1.md diff --git a/.gitignore b/.gitignore index d208a52d..1e7c7e48 100644 --- a/.gitignore +++ b/.gitignore @@ -49,3 +49,7 @@ plannotator-local # Cloudflare Wrangler local state (Miniflare SQLite, caches) .wrangler/ + +# Claude Code local scratch files (per-session locks, etc.). Intentionally +# ignored so they can't be committed accidentally. +.claude/scheduled_tasks.lock diff --git a/AGENTS.md b/AGENTS.md index 8e9f37bd..b3c09aac 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,6 +28,11 @@ plannotator/ │ │ ├── index.html │ │ ├── index.tsx │ │ └── vite.config.ts +│ ├── room-service/ # Live collaboration rooms (Cloudflare Worker + Durable Object) +│ │ ├── core/ # Handler, DO class, validation, CORS, log, types +│ │ ├── targets/cloudflare.ts # Worker entry + DO re-export +│ │ ├── scripts/smoke.ts # Integration test against wrangler dev +│ │ └── wrangler.toml # SQLite-backed DO binding │ └── vscode-extension/ # VS Code extension — opens plans in editor tabs │ ├── bin/ # Router scripts (open-in-vscode, xdg-open) │ ├── src/ # extension.ts, cookie-proxy.ts, ipc-server.ts, panel-manager.ts, editor-annotations.ts, vscode-theme.ts @@ -53,13 +58,23 @@ plannotator/ │ │ │ ├── plan-diff/ # PlanDiffBadge, PlanDiffViewer, clean/raw diff views │ │ │ └── sidebar/ # SidebarContainer, SidebarTabs, VersionBrowser, ArchiveBrowser │ │ ├── utils/ # parser.ts, sharing.ts, storage.ts, planSave.ts, agentSwitch.ts, planDiffEngine.ts, planAgentInstructions.ts -│ │ ├── hooks/ # useAnnotationHighlighter.ts, useSharing.ts, usePlanDiff.ts, useSidebar.ts, useLinkedDoc.ts, useAnnotationDraft.ts, useCodeAnnotationDraft.ts, useArchive.ts +│ │ ├── hooks/ # useAnnotationHighlighter.ts, useSharing.ts, usePlanDiff.ts, useSidebar.ts, useLinkedDoc.ts, useAnnotationDraft.ts, useCodeAnnotationDraft.ts, useArchive.ts, useCollabRoom.ts │ │ └── types.ts │ ├── ai/ # Provider-agnostic AI backbone (providers, sessions, endpoints) │ ├── shared/ # Shared types, utilities, and cross-runtime logic │ │ ├── storage.ts # Plan saving, version history, archive listing (node:fs only) │ │ ├── draft.ts # Annotation draft persistence (node:fs only) -│ │ └── project.ts # Pure string helpers (sanitizeTag, extractRepoName, extractDirName) +│ │ ├── project.ts # Pure string helpers (sanitizeTag, extractRepoName, extractDirName) +│ │ └── collab/ # Live Rooms protocol, crypto, validators, client runtime, React hook +│ │ ├── types.ts # Protocol types + runtime validators (isRoomAnnotation, isRoomSnapshot, isPresenceState, ...) +│ │ ├── crypto.ts # HKDF key derivation, HMAC proofs, AES-GCM payload encrypt/decrypt +│ │ ├── ids.ts # roomId/secret/opId/clientId generators +│ │ ├── url.ts # parseRoomUrl / buildRoomJoinUrl / buildAdminRoomUrl (client-only) +│ │ ├── constants.ts # ROOM_SECRET_LENGTH_BYTES, ADMIN_SECRET_LENGTH_BYTES, WS_CLOSE_ROOM_UNAVAILABLE, WS_CLOSE_REASON_ROOM_DELETED, WS_CLOSE_REASON_ROOM_EXPIRED +│ │ ├── canonical-json.ts # canonicalJson for admin command proof binding +│ │ ├── encoding.ts # base64url helpers +│ │ ├── client.ts # Client barrel re-exports +│ │ └── client-runtime/ # CollabRoomClient class, createRoom, joinRoom, apply-event reducer │ ├── editor/ # Plan review App.tsx │ └── review-editor/ # Code review UI │ ├── App.tsx # Main review app @@ -275,6 +290,19 @@ All servers use random ports locally or fixed port (`19432`) in remote mode. Runs as a separate service on port `19433` (self-hosted) or as a Cloudflare Worker (hosted). +### Room Service (`apps/room-service/`) + +Live-collaboration rooms for encrypted multi-user annotation. Zero-knowledge: the Worker + Durable Object stores and relays ciphertext only. Clients hold the room secret in the URL fragment and derive `authKey`/`eventKey`/`presenceKey`/`adminKey` locally. + +| Endpoint | Method | Purpose | +| --------------------- | ------ | ------------------------------------------ | +| `/health` | GET | Worker liveness probe | +| `/c/:roomId` | GET | Room SPA shell (Slice 5 replaces with the editor bundle) | +| `/api/rooms` | POST | Create room. Body: `{ roomId, roomVerifier, adminVerifier, initialSnapshotCiphertext, expiresInDays? }`. Returns `201` on success; `409` on duplicate `roomId`. Response body is intentionally not consumed by `createRoom()`. | +| `/ws/:roomId` | GET | WebSocket upgrade into the room Durable Object. `roomId` is validated via `isRoomId()` before `idFromName()` to prevent arbitrary DO instantiation. | + +Protocol contract lives in `packages/shared/collab/`; the Worker/DO never imports client-only URL helpers. + ## Plan Version History Every plan is automatically saved to `~/.plannotator/history/{project}/{slug}/` on arrival, before the user sees the UI. Versions are numbered sequentially (`001.md`, `002.md`, etc.). The slug is derived from the plan's first `# Heading` + today's date via `generateSlug()`, scoped by project name (git repo or cwd). Same heading on the same day = same slug = same plan being iterated on. Identical resubmissions are deduplicated (no new file if content matches the latest version). diff --git a/apps/room-service/core/handler.ts b/apps/room-service/core/handler.ts index 68f275b1..25dcc71a 100644 --- a/apps/room-service/core/handler.ts +++ b/apps/room-service/core/handler.ts @@ -6,7 +6,7 @@ */ import type { Env } from './types'; -import { validateCreateRoomRequest, isValidationError } from './validation'; +import { isRoomId, validateCreateRoomRequest, isValidationError } from './validation'; import { safeLog } from './log'; const ROOM_PATH_RE = /^\/c\/([^/]+)$/; @@ -31,13 +31,36 @@ export async function handleRequest( return Response.json({ ok: true }, { headers: cors }); } - // Room SPA shell placeholder (Slice 5 serves the real editor bundle) + // Room SPA shell placeholder (Slice 5 serves the real editor bundle). + // + // Defense-in-depth header: Referrer-Policy: no-referrer. + // Note: browsers already do NOT include the URL fragment (#key=…&admin=…) + // in outbound Referer headers, so the header isn't plugging a fragment + // leak per se. What it does is belt-and-braces: it strips the *path* + // (which contains the room id) from Referer on any outbound navigation + // or subresource fetch the page performs, reducing room-id exposure to + // third parties. The actual credential-leak risk for this page is + // JavaScript telemetry reading `window.location.href` — Slice 5 editor + // code must scrub `#key=` and `#admin=` from any telemetry / + // error-reporting payload. const roomMatch = pathname.match(ROOM_PATH_RE); if (roomMatch && method === 'GET') { const roomId = roomMatch[1]; + // Validate up front so invalid URLs never reach the room shell (or, + // in Slice 5, the editor bundle). Matches the /ws/:roomId validation. + if (!isRoomId(roomId)) { + return new Response('Not Found', { status: 404, headers: cors }); + } return new Response( `Plannotator Room

Room: ${escapeHtml(roomId)}

`, - { status: 200, headers: { ...cors, 'Content-Type': 'text/html; charset=utf-8' } }, + { + status: 200, + headers: { + ...cors, + 'Content-Type': 'text/html; charset=utf-8', + 'Referrer-Policy': 'no-referrer', + }, + }, ); } @@ -69,6 +92,26 @@ export async function handleRequest( // --------------------------------------------------------------------------- // Room Creation +// +// PRODUCTION HARDENING (required before public deployment, not in V1 scope): +// `POST /api/rooms` is intentionally unauthenticated in the V1 protocol. A +// room is a capability-token pair (roomSecret + adminSecret) the creator +// generates locally; this endpoint only asserts existence on the server, not +// identity. That means anyone who can reach the Worker can create rooms — +// fine for local dev and gated staging, NOT fine for the open internet. +// +// Before this Worker is exposed publicly it MUST be gated by one of: +// - Cloudflare rate limiting / WAF rule keyed on source IP + path +// - application-level throttle at the Worker entry (shared Durable Object +// counter or KV-based token bucket) +// - authenticated proxy (plannotator.ai app calls on behalf of signed-in users) +// +// CORS is NOT abuse protection — it's a browser same-origin policy and does +// nothing to a direct HTTP client. Any future reviewer flagging "this +// endpoint is unauthenticated" should be pointed HERE and to +// `specs/v1-implementation-approach.md` → "Production hardening: rate-limit +// POST /api/rooms". The protocol design accommodates this gating without +// client changes. // --------------------------------------------------------------------------- async function handleCreateRoom( @@ -127,6 +170,16 @@ async function handleWebSocket( ); } + // Validate roomId BEFORE idFromName(). idFromName on arbitrary attacker + // input would instantiate a fresh DO and hit storage on every request — + // a cheap abuse surface. Reject malformed IDs up front. + if (!isRoomId(roomId)) { + return Response.json( + { error: 'Invalid roomId' }, + { status: 400, headers: cors }, + ); + } + // Forward to the Durable Object — no CORS on WebSocket upgrade const id = env.ROOM.idFromName(roomId); const stub = env.ROOM.get(id); diff --git a/apps/room-service/core/room-do.ts b/apps/room-service/core/room-do.ts index 2810815b..d45e41c5 100644 --- a/apps/room-service/core/room-do.ts +++ b/apps/room-service/core/room-do.ts @@ -24,24 +24,51 @@ import type { SequencedEnvelope, RoomTransportMessage, } from '@plannotator/shared/collab'; -import { verifyAuthProof, verifyAdminProof, generateChallengeId, generateNonce } from '@plannotator/shared/collab'; +import { verifyAuthProof, verifyAdminProof, generateChallengeId, generateClientId, generateNonce } from '@plannotator/shared/collab'; +// Shared delete close-signal constants — client matches on the exact literal, +// so both ends MUST import from the same source. +import { WS_CLOSE_REASON_ROOM_DELETED, WS_CLOSE_REASON_ROOM_EXPIRED, WS_CLOSE_ROOM_UNAVAILABLE } from '@plannotator/shared/collab/constants'; import { DurableObject } from 'cloudflare:workers'; import type { Env, RoomDurableState, WebSocketAttachment } from './types'; import { clampExpiryDays, hasRoomExpired, validateServerEnvelope, validateAdminCommandEnvelope, isValidationError } from './validation'; -import type { ValidationError } from './validation'; import { safeLog } from './log'; const CHALLENGE_TTL_MS = 30_000; const ADMIN_CHALLENGE_TTL_MS = 30_000; const DELETE_BATCH_SIZE = 128; // Cloudflare DO storage.delete() max keys per call +/** + * Page size for reconnect replay. Bounds peak DO memory during replay — + * storage.list() without a limit reads all matching rows at once, which + * fails for large/noisy rooms. Each page is streamed out to the WebSocket, + * then released. 128 is a conservative starting point well within DO memory + * budgets even if each event ciphertext is a few KB. + */ +const REPLAY_PAGE_SIZE = 128; + +/** + * Abuse/failure containment: per-room WebSocket cap. Not about expected + * normal room sizes — V1 rooms are small — but bounds broadcast fanout + * and runaway reconnect loops if a misbehaving client (or attacker with + * the room URL) opens sockets without releasing them. Returns 429 Too + * Many Requests when exceeded; honest clients see this only if the room + * is already saturated. + */ +const MAX_CONNECTIONS_PER_ROOM = 100; + +/** Pre-auth length caps on the auth.response message. Real values are + * much smaller (challengeId ~22 chars, clientId server-assigned, proof + * ~43 chars for HMAC-SHA-256 base64url). Generous caps bound the + * unauthenticated work the server is willing to do per connection. */ +const AUTH_CHALLENGE_ID_MAX_LENGTH = 64; +const AUTH_CLIENT_ID_MAX_LENGTH = 64; +const AUTH_PROOF_MAX_LENGTH = 128; -// WebSocket close codes +// WebSocket close codes (room-service-internal; shared close codes come from constants.ts) const WS_CLOSE_AUTH_REQUIRED = 4001; const WS_CLOSE_UNKNOWN_CHALLENGE = 4002; const WS_CLOSE_CHALLENGE_EXPIRED = 4003; const WS_CLOSE_INVALID_PROOF = 4004; const WS_CLOSE_PROTOCOL_ERROR = 4005; -const WS_CLOSE_ROOM_UNAVAILABLE = 4006; /** Zero-pad a seq number to 10 digits for lexicographic storage ordering. */ function padSeq(seq: number): string { @@ -141,12 +168,22 @@ export class RoomDurableObject extends DurableObject { return Response.json({ error: 'Room expired' }, { status: 410 }); } + // Per-room connection cap — see MAX_CONNECTIONS_PER_ROOM for rationale. + if (this.ctx.getWebSockets().length >= MAX_CONNECTIONS_PER_ROOM) { + safeLog('ws:room-full', { roomId: roomState.roomId, cap: MAX_CONNECTIONS_PER_ROOM }); + return Response.json({ error: 'Room is full' }, { status: 429 }); + } + const pair = new WebSocketPair(); const [client, server] = Object.values(pair); const challengeId = generateChallengeId(); const nonce = generateNonce(); const expiresAt = Date.now() + CHALLENGE_TTL_MS; + // Server-assigned clientId — see WebSocketAttachment docstring. The auth + // proof is bound to this value, so a participant cannot choose an active + // peer's clientId at auth time. + const clientId = generateClientId(); this.ctx.acceptWebSocket(server); @@ -156,6 +193,7 @@ export class RoomDurableObject extends DurableObject { challengeId, nonce, expiresAt, + clientId, }; server.serializeAttachment(attachment); @@ -164,6 +202,7 @@ export class RoomDurableObject extends DurableObject { challengeId, nonce, expiresAt, + clientId, }; server.send(JSON.stringify(challenge)); @@ -205,6 +244,22 @@ export class RoomDurableObject extends DurableObject { ws.close(WS_CLOSE_PROTOCOL_ERROR, 'Malformed auth response'); return; } + // Pre-auth length caps. Proofs + IDs are small in practice + // (challengeId ~22 chars, clientId server-assigned, proof 43 chars). + // Without caps, an unauthenticated peer can allocate/verify oversized + // strings. Match the admin-envelope caps for consistency. + if (msg.challengeId.length > AUTH_CHALLENGE_ID_MAX_LENGTH) { + ws.close(WS_CLOSE_PROTOCOL_ERROR, 'challengeId too long'); + return; + } + if (msg.clientId.length > AUTH_CLIENT_ID_MAX_LENGTH) { + ws.close(WS_CLOSE_PROTOCOL_ERROR, 'clientId too long'); + return; + } + if (msg.proof.length > AUTH_PROOF_MAX_LENGTH) { + ws.close(WS_CLOSE_PROTOCOL_ERROR, 'proof too long'); + return; + } // Validate lastSeq as non-negative integer if provided let lastSeq: number | undefined; if (msg.lastSeq !== undefined) { @@ -277,17 +332,17 @@ export class RoomDurableObject extends DurableObject { return null; } if (roomState.status === 'deleted') { - ws.close(WS_CLOSE_ROOM_UNAVAILABLE, 'Room deleted'); + ws.close(WS_CLOSE_ROOM_UNAVAILABLE, WS_CLOSE_REASON_ROOM_DELETED); return null; } if (roomState.status === 'expired') { - ws.close(WS_CLOSE_ROOM_UNAVAILABLE, 'Room expired'); + ws.close(WS_CLOSE_ROOM_UNAVAILABLE, WS_CLOSE_REASON_ROOM_EXPIRED); return null; } // Lazy expiry: active/locked room past retention deadline if (hasRoomExpired(roomState.expiresAt)) { await this.markExpired(roomState, ws); - ws.close(WS_CLOSE_ROOM_UNAVAILABLE, 'Room expired'); + ws.close(WS_CLOSE_ROOM_UNAVAILABLE, WS_CLOSE_REASON_ROOM_EXPIRED); return null; } return roomState; @@ -304,11 +359,12 @@ export class RoomDurableObject extends DurableObject { ): Promise { const validated = validateServerEnvelope(msg); if (isValidationError(validated)) { - this.sendError(ws, 'validation_error', (validated as ValidationError).error); + this.sendError(ws, 'validation_error', validated.error); return; } + // isValidationError narrows; `validated` is ServerEnvelope here. const envelope: ServerEnvelope = { - ...validated as ServerEnvelope, + ...validated, clientId: meta.clientId, // Override — prevent spoofing }; @@ -322,28 +378,48 @@ export class RoomDurableObject extends DurableObject { return; } - // Sequence the event - roomState.seq++; + // Sequence the event on an IMMUTABLE next-state object. If the + // durable write fails, we must NOT have already bumped roomState.seq + // in memory — the next event must reuse the current seq, not a gap'd + // one. Nor may we broadcast an event that was never persisted. + const nextSeq = roomState.seq + 1; const sequenced: SequencedEnvelope = { - seq: roomState.seq, + seq: nextSeq, receivedAt: Date.now(), envelope, }; + const nextRoomState: RoomDurableState = { ...roomState, seq: nextSeq }; + + // Atomic write: event key + room metadata in one put. + try { + await this.ctx.storage.put({ + [`event:${padSeq(nextSeq)}`]: sequenced, + 'room': nextRoomState, + } as Record); + } catch (e) { + // Persistence failed. Surface a clean error to the sender so their + // sendAnnotation* promise rejects (or their UI sees lastError) — + // otherwise they'd think the op landed on the wire. Do NOT bump + // in-memory seq, do NOT broadcast. + safeLog('room:event-persist-error', { + roomId: roomState.roomId, + attemptedSeq: nextSeq, + clientId: meta.clientId, + error: String(e), + }); + this.sendError(ws, 'event_persist_failed', 'Failed to persist event'); + return; + } - // Atomic write: event key + room metadata in one put - await this.ctx.storage.put({ - [`event:${padSeq(roomState.seq)}`]: sequenced, - 'room': roomState, - } as Record); - - // Broadcast to ALL (including sender for lastSeq advancement) + // Durable write succeeded — commit in-memory state and broadcast. + Object.assign(roomState, nextRoomState); const transport: RoomTransportMessage = { type: 'room.event', seq: sequenced.seq, receivedAt: sequenced.receivedAt, envelope: sequenced.envelope, }; - this.broadcastToAll(transport); + this.broadcast(transport); safeLog('room:event-sequenced', { roomId: roomState.roomId, seq: roomState.seq, clientId: meta.clientId }); } else { @@ -352,7 +428,7 @@ export class RoomDurableObject extends DurableObject { type: 'room.presence', envelope, }; - this.broadcastToOthers(ws, transport); + this.broadcast(transport, ws); } } @@ -371,26 +447,26 @@ export class RoomDurableObject extends DurableObject { return; } + // The clientId in auth.response MUST match the server-assigned clientId + // from this connection's challenge. This prevents a participant from + // choosing another peer's clientId at auth time and overwriting their + // presence slot. + if (authResponse.clientId !== meta.clientId) { + safeLog('ws:auth-rejected', { reason: 'clientId-mismatch', roomId: meta.roomId }); + ws.close(WS_CLOSE_INVALID_PROOF, 'clientId does not match challenge'); + return; + } + if (Date.now() > meta.expiresAt) { safeLog('ws:auth-rejected', { reason: 'expired', roomId: meta.roomId }); ws.close(WS_CLOSE_CHALLENGE_EXPIRED, 'Challenge expired'); return; } - const roomState = await this.ctx.storage.get('room'); - if (!roomState) { - ws.close(WS_CLOSE_ROOM_UNAVAILABLE, 'Room unavailable'); - return; - } - if (roomState.status === 'deleted') { - ws.close(WS_CLOSE_ROOM_UNAVAILABLE, 'Room deleted'); - return; - } - if (roomState.status === 'expired' || hasRoomExpired(roomState.expiresAt)) { - await this.markExpired(roomState, ws); - ws.close(WS_CLOSE_ROOM_UNAVAILABLE, 'Room expired'); - return; - } + // Delegate lifecycle checks (deleted / expired / lazy-expiry) to the + // shared helper so this path doesn't drift from the post-auth path. + const roomState = await this.checkRoomLifecycle(ws, meta.roomId); + if (!roomState) return; const valid = await verifyAuthProof( roomState.roomVerifier, @@ -437,6 +513,19 @@ export class RoomDurableObject extends DurableObject { roomState: RoomDurableState, lastSeq: number | undefined, ): Promise { + // Local helper: single place that constructs and sends a room.snapshot + // transport message. Keeps the message shape in one place so any future + // field addition lands once. + const sendSnapshotToSocket = (): void => { + if (!roomState.snapshotCiphertext) return; + const snapshotMsg: RoomTransportMessage = { + type: 'room.snapshot', + snapshotSeq: roomState.snapshotSeq ?? 0, + snapshotCiphertext: roomState.snapshotCiphertext, + }; + ws.send(JSON.stringify(snapshotMsg)); + }; + // Determine replay strategy let sendSnapshot = false; let replayFrom: number; @@ -452,13 +541,8 @@ export class RoomDurableObject extends DurableObject { safeLog('ws:replay-anomaly', { roomId: roomState.roomId, lastSeq, currentSeq: roomState.seq }); } else if (lastSeq === roomState.seq) { // Fully caught up — still send snapshot if seq is 0 (fresh room, no events yet) - if (roomState.seq === 0 && roomState.snapshotCiphertext) { - const snapshotMsg: RoomTransportMessage = { - type: 'room.snapshot', - snapshotSeq: roomState.snapshotSeq ?? 0, - snapshotCiphertext: roomState.snapshotCiphertext, - }; - ws.send(JSON.stringify(snapshotMsg)); + if (roomState.seq === 0) { + sendSnapshotToSocket(); } return; } else { @@ -476,31 +560,47 @@ export class RoomDurableObject extends DurableObject { } } - // Send snapshot if needed - if (sendSnapshot && roomState.snapshotCiphertext) { - const snapshotMsg: RoomTransportMessage = { - type: 'room.snapshot', - snapshotSeq: roomState.snapshotSeq ?? 0, - snapshotCiphertext: roomState.snapshotCiphertext, - }; - ws.send(JSON.stringify(snapshotMsg)); + if (sendSnapshot) { + sendSnapshotToSocket(); } - // Replay events from storage (if any exist) + // Replay events from storage (if any exist). Paginated so large rooms + // don't load the full event log into DO memory at reconnect time — + // storage.list() without a limit can blow memory in rooms with many + // retained events (V1 retains all events for the room lifetime). if (roomState.seq > 0 && replayFrom <= roomState.seq) { - const startKey = `event:${padSeq(replayFrom)}`; - const events = await this.ctx.storage.list({ - prefix: 'event:', - start: startKey, - }); - for (const [, sequenced] of events) { - const transport: RoomTransportMessage = { - type: 'room.event', - seq: sequenced.seq, - receivedAt: sequenced.receivedAt, - envelope: sequenced.envelope, - }; - ws.send(JSON.stringify(transport)); + let cursor = `event:${padSeq(replayFrom)}`; + const end = `event:${padSeq(roomState.seq)}\uffff`; // inclusive of roomState.seq + while (true) { + const page = await this.ctx.storage.list({ + prefix: 'event:', + start: cursor, + end, + limit: REPLAY_PAGE_SIZE, + }); + if (page.size === 0) break; + let lastKey = cursor; + for (const [key, sequenced] of page) { + const transport: RoomTransportMessage = { + type: 'room.event', + seq: sequenced.seq, + receivedAt: sequenced.receivedAt, + envelope: sequenced.envelope, + }; + ws.send(JSON.stringify(transport)); + lastKey = key; + } + if (page.size < REPLAY_PAGE_SIZE) break; + // Advance cursor past the last emitted key. `storage.list({ start })` + // is INCLUSIVE, so passing `lastKey` would re-emit the final event. + // Appending U+0000 (the smallest Unicode code point) produces a string + // strictly greater than `lastKey` but strictly less than any valid + // next key — because padded numeric seq keys are ASCII digits only + // and never contain a null byte, no real key can fall between them. + // Using `\uffff` (max code point) here would be WRONG: it would skip + // all keys lexicographically between `lastKey` and `lastKey\uffff`, + // dropping legitimate events from the replay. + cursor = `${lastKey}\u0000`; } } } @@ -544,12 +644,29 @@ export class RoomDurableObject extends DurableObject { meta: Extract, msg: Record, ): Promise { + // ADMIN ERROR-CODE CONTRACT + // ------------------------- + // Every error code emitted from this method AND from helpers it calls + // (applyLock, applyUnlock, applyDelete, admin-scoped branches of + // handleAdminChallengeRequest) must be listed in the client's + // ADMIN_SCOPED_ERROR_CODES Set in packages/shared/collab/client-runtime/client.ts. + // That Set gates which room.error payloads reject a pending admin promise; + // a code that fires here but is missing from the Set leaves the client + // hanging until AdminTimeoutError. A code that fires on the event channel + // but is ADDED to the Set (e.g. room_locked) wrongly cancels unrelated + // in-flight admin commands. When adding/renaming/removing admin-path + // codes, update the client Set in the same change. const validated = validateAdminCommandEnvelope(msg); if (isValidationError(validated)) { - this.sendError(ws, 'validation_error', (validated as ValidationError).error); + // Admin-scoped code so the client can distinguish admin-flow failures + // from event-channel failures (e.g. room_locked fires on the event + // channel while a lock command is in flight — rejecting pendingAdmin + // on those would be wrong). + this.sendError(ws, 'admin_validation_error', validated.error); return; } - const cmdEnvelope = validated as AdminCommandEnvelope; + // isValidationError narrows; `validated` is AdminCommandEnvelope here. + const cmdEnvelope = validated; // Reject cross-connection clientId spoofing if (cmdEnvelope.clientId !== meta.clientId) { @@ -612,6 +729,13 @@ export class RoomDurableObject extends DurableObject { case 'room.delete': await this.applyDelete(ws, roomState); break; + default: { + // Compile-time exhaustiveness guard: if a new admin command is added + // to the union and a case here is missed, TypeScript fails here. + const _exhaustive: never = cmdEnvelope.command; + void _exhaustive; + break; + } } } @@ -629,22 +753,62 @@ export class RoomDurableObject extends DurableObject { return; } - // Store final snapshot if provided + // Build a NEW state object immutably so a storage.put failure leaves the + // in-memory roomState reference unchanged and the caller can't observe + // a partially-applied lock. + const next: RoomDurableState = { + ...roomState, + status: 'locked', + lockedAt: Date.now(), + }; + + // Store final snapshot if provided. Rules: + // - atSeq > roomState.seq → reject (claims a position beyond what the + // server has durably sequenced). + // - atSeq < existingSnapshotSeq → reject (would regress the baseline). + // - atSeq === existingSnapshotSeq → accept the lock but IGNORE the + // redundant snapshot payload. Overwriting at the same seq would + // reintroduce the split-brain risk we explicitly blocked (two + // different ciphertexts labeled with the same seq); rejecting the + // whole lock would break the realistic lock → unlock → lock flow + // where no events arrive between the two locks and client.seq still + // equals the previous snapshotSeq. + // - atSeq > existingSnapshotSeq → accept, write the new snapshot. if (command.finalSnapshotCiphertext && command.finalSnapshotAtSeq !== undefined) { const atSeq = command.finalSnapshotAtSeq; - if (atSeq > roomState.seq || atSeq < (roomState.snapshotSeq ?? 0)) { - this.sendError(ws, 'invalid_snapshot_seq', `finalSnapshotAtSeq must be between ${roomState.snapshotSeq ?? 0} and ${roomState.seq}`); + const existingSnapshotSeq = roomState.snapshotSeq ?? 0; + if (atSeq > roomState.seq || atSeq < existingSnapshotSeq) { + this.sendError(ws, 'invalid_snapshot_seq', `finalSnapshotAtSeq must be >= ${existingSnapshotSeq} and <= ${roomState.seq}`); return; } - roomState.snapshotCiphertext = command.finalSnapshotCiphertext; - roomState.snapshotSeq = atSeq; + if (atSeq > existingSnapshotSeq) { + next.snapshotCiphertext = command.finalSnapshotCiphertext; + next.snapshotSeq = atSeq; + } else { + // atSeq === existingSnapshotSeq: keep the existing stored snapshot; + // log so this is visible in ops even though it's expected behavior. + safeLog('admin:lock-snapshot-redundant', { + roomId: roomState.roomId, + atSeq, + existingSnapshotSeq, + }); + } } - roomState.status = 'locked'; - roomState.lockedAt = Date.now(); - await this.ctx.storage.put('room', roomState); + try { + await this.ctx.storage.put('room', next); + } catch (e) { + // Do NOT broadcast room.status or mutate in-memory state on durable + // write failure. Send an admin-scoped error so the pending lock + // rejects cleanly on the caller. + safeLog('admin:lock-storage-error', { roomId: roomState.roomId, error: String(e) }); + this.sendError(ws, 'lock_failed', 'Failed to persist locked state'); + return; + } - this.broadcastToAll({ type: 'room.status', status: 'locked' }); + // Durable write succeeded — now safe to sync in-memory state and broadcast. + Object.assign(roomState, next); + this.broadcast({ type: 'room.status', status: 'locked' }); safeLog('admin:room-locked', { roomId: roomState.roomId }); } @@ -657,11 +821,23 @@ export class RoomDurableObject extends DurableObject { return; } - roomState.status = 'active'; - roomState.lockedAt = undefined; - await this.ctx.storage.put('room', roomState); + // Immutable next-state + gated broadcast, matching applyLock. + const next: RoomDurableState = { + ...roomState, + status: 'active', + lockedAt: undefined, + }; + + try { + await this.ctx.storage.put('room', next); + } catch (e) { + safeLog('admin:unlock-storage-error', { roomId: roomState.roomId, error: String(e) }); + this.sendError(ws, 'unlock_failed', 'Failed to persist unlocked state'); + return; + } - this.broadcastToAll({ type: 'room.status', status: 'active' }); + Object.assign(roomState, next); + this.broadcast({ type: 'room.status', status: 'active' }); safeLog('admin:room-unlocked', { roomId: roomState.roomId }); } @@ -693,9 +869,13 @@ export class RoomDurableObject extends DurableObject { try { await this.ctx.storage.put('room', deletedState); } catch (e) { + // Tombstone write failed — the room is still ALIVE. Tell the admin + // caller the delete failed, but do NOT close other clients: kicking + // everyone into a terminal "room unavailable" state would lie about + // the lifecycle (the room is still active server-side). The admin + // can retry deleteRoom(). safeLog('room:delete-storage-error', { roomId: roomState.roomId, error: String(e) }); this.sendError(ws, 'delete_failed', 'Failed to delete room'); - this.closeRoomSockets('Room delete failed'); return; } @@ -706,8 +886,10 @@ export class RoomDurableObject extends DurableObject { safeLog('room:delete-purge-error', { roomId: roomState.roomId, error: String(e) }); } - this.broadcastToAll({ type: 'room.status', status: 'deleted' }); - this.closeRoomSockets('Room deleted'); + this.broadcast({ type: 'room.status', status: 'deleted' }); + // Use the shared constant — the client matches this literal to decide + // that deleteRoom() succeeded even if the status broadcast was missed. + this.closeRoomSockets(WS_CLOSE_REASON_ROOM_DELETED); safeLog('admin:room-deleted', { roomId: roomState.roomId }); } @@ -715,15 +897,20 @@ export class RoomDurableObject extends DurableObject { // Storage Helpers // --------------------------------------------------------------------------- - /** Delete all event keys from storage in batches of DELETE_BATCH_SIZE. */ + /** + * Delete all event keys from storage in batches. Paginated for the same + * reason as replay: avoid loading the full event log into DO memory. + * Less latency-sensitive than replay but the memory bound still matters. + */ private async purgeEventKeys(): Promise { - const events = await this.ctx.storage.list({ prefix: 'event:' }); - if (events.size === 0) return; - - const keys = [...events.keys()]; - for (let i = 0; i < keys.length; i += DELETE_BATCH_SIZE) { - const batch = keys.slice(i, i + DELETE_BATCH_SIZE); - await this.ctx.storage.delete(batch); + while (true) { + const page = await this.ctx.storage.list({ + prefix: 'event:', + limit: DELETE_BATCH_SIZE, + }); + if (page.size === 0) break; + await this.ctx.storage.delete([...page.keys()]); + if (page.size < DELETE_BATCH_SIZE) break; } } @@ -771,7 +958,7 @@ export class RoomDurableObject extends DurableObject { safeLog('room:expire-purge-error', { roomId: roomState.roomId, error: String(e) }); } - this.closeRoomSockets('Room expired', except); + this.closeRoomSockets(WS_CLOSE_REASON_ROOM_EXPIRED, except); safeLog('room:expired', { roomId: roomState.roomId }); return true; } @@ -780,17 +967,12 @@ export class RoomDurableObject extends DurableObject { // Broadcast Helpers // --------------------------------------------------------------------------- - private broadcastToAll(message: RoomTransportMessage): void { - const json = JSON.stringify(message); - for (const socket of this.ctx.getWebSockets()) { - const att = socket.deserializeAttachment() as WebSocketAttachment | null; - if (att?.authenticated) { - try { socket.send(json); } catch { /* socket may have closed */ } - } - } - } - - private broadcastToOthers(exclude: WebSocket, message: RoomTransportMessage): void { + /** + * Send a transport message to every authenticated socket in the room, + * optionally excluding one (e.g. the sender for presence relay). Send + * failures are intentionally ignored — the target socket may have closed. + */ + private broadcast(message: RoomTransportMessage, exclude?: WebSocket): void { const json = JSON.stringify(message); for (const socket of this.ctx.getWebSockets()) { if (socket === exclude) continue; diff --git a/apps/room-service/core/types.ts b/apps/room-service/core/types.ts index 4c06dcae..1ccf198e 100644 --- a/apps/room-service/core/types.ts +++ b/apps/room-service/core/types.ts @@ -51,7 +51,18 @@ export interface RoomDurableState { * Both variants carry roomId so webSocketMessage() can access it without a storage read. */ export type WebSocketAttachment = - | { authenticated: false; roomId: string; challengeId: string; nonce: string; expiresAt: number } + | { + authenticated: false; + roomId: string; + challengeId: string; + nonce: string; + expiresAt: number; + /** Server-assigned ephemeral client id for this connection. Included in + * the auth challenge so the client's proof binds to it; prevents a + * malicious participant from choosing another user's clientId at auth + * time and overwriting their presence slot after auth. */ + clientId: string; + } | { authenticated: true; roomId: string; diff --git a/apps/room-service/core/validation.test.ts b/apps/room-service/core/validation.test.ts index e7da5fc2..0e776f68 100644 --- a/apps/room-service/core/validation.test.ts +++ b/apps/room-service/core/validation.test.ts @@ -1,5 +1,13 @@ import { describe, expect, test } from 'bun:test'; -import { validateCreateRoomRequest, isValidationError, clampExpiryDays, hasRoomExpired } from './validation'; +import { + validateCreateRoomRequest, + isValidationError, + clampExpiryDays, + hasRoomExpired, + isRoomId, + validateServerEnvelope, + validateAdminCommandEnvelope, +} from './validation'; describe('validateCreateRoomRequest', () => { // 22-char base64url room ID (matches generateRoomId() output: 16 random bytes) @@ -170,3 +178,144 @@ describe('hasRoomExpired', () => { expect(hasRoomExpired(2_000, 2_001)).toBe(true); }); }); + +describe('isRoomId', () => { + test('accepts valid 22-char base64url ids', () => { + expect(isRoomId('ABCDEFGHIJKLMNOPQRSTUv')).toBe(true); + expect(isRoomId('abcdef_ghij-klmnopqrst')).toBe(true); + }); + test('rejects wrong-length ids', () => { + expect(isRoomId('short')).toBe(false); + expect(isRoomId('A'.repeat(21))).toBe(false); + expect(isRoomId('A'.repeat(23))).toBe(false); + }); + test('rejects ids containing disallowed characters', () => { + expect(isRoomId('A'.repeat(21) + '!')).toBe(false); + expect(isRoomId('A'.repeat(21) + '/')).toBe(false); + expect(isRoomId('A'.repeat(21) + '=')).toBe(false); + }); + test('rejects non-string inputs', () => { + expect(isRoomId(undefined)).toBe(false); + expect(isRoomId(42 as unknown as string)).toBe(false); + expect(isRoomId(null)).toBe(false); + }); +}); + +describe('validateAdminCommandEnvelope — strips extra fields (P2)', () => { + const validBase = { + type: 'admin.command', + challengeId: 'cid', + clientId: 'client', + adminProof: 'proof', + }; + test('room.unlock strips extras from command', () => { + const r = validateAdminCommandEnvelope({ + ...validBase, + command: { type: 'room.unlock', extra: 'smuggled', maliciousField: 42 }, + }); + expect(isValidationError(r)).toBe(false); + if (!isValidationError(r)) { + expect(r.command).toEqual({ type: 'room.unlock' }); + expect(Object.keys(r.command)).toEqual(['type']); + } + }); + test('room.delete strips extras from command', () => { + const r = validateAdminCommandEnvelope({ + ...validBase, + command: { type: 'room.delete', piggyback: 'value' }, + }); + expect(isValidationError(r)).toBe(false); + if (!isValidationError(r)) { + expect(r.command).toEqual({ type: 'room.delete' }); + } + }); + test('room.lock without snapshot strips extras', () => { + const r = validateAdminCommandEnvelope({ + ...validBase, + command: { type: 'room.lock', unexpected: 'junk' }, + }); + expect(isValidationError(r)).toBe(false); + if (!isValidationError(r)) { + expect(r.command).toEqual({ type: 'room.lock' }); + } + }); + test('room.lock with snapshot keeps exactly the two snapshot fields', () => { + const r = validateAdminCommandEnvelope({ + ...validBase, + command: { + type: 'room.lock', + finalSnapshotCiphertext: 'ct', + finalSnapshotAtSeq: 5, + extra: 'ignored', + }, + }); + expect(isValidationError(r)).toBe(false); + if (!isValidationError(r) && r.command.type === 'room.lock') { + expect(r.command).toEqual({ + type: 'room.lock', + finalSnapshotCiphertext: 'ct', + finalSnapshotAtSeq: 5, + }); + } + }); + test('rejects unknown command type', () => { + const r = validateAdminCommandEnvelope({ + ...validBase, + command: { type: 'room.explode' }, + }); + expect(isValidationError(r)).toBe(true); + }); + + test('rejects overlong adminProof', () => { + const r = validateAdminCommandEnvelope({ + ...validBase, + adminProof: 'x'.repeat(129), + command: { type: 'room.unlock' }, + }); + expect(isValidationError(r)).toBe(true); + if (isValidationError(r)) expect(r.error).toMatch(/adminProof/); + }); + + test('rejects overlong challengeId', () => { + const r = validateAdminCommandEnvelope({ + ...validBase, + challengeId: 'x'.repeat(65), + command: { type: 'room.unlock' }, + }); + expect(isValidationError(r)).toBe(true); + if (isValidationError(r)) expect(r.error).toMatch(/challengeId/); + }); + + test('rejects overlong clientId', () => { + const r = validateAdminCommandEnvelope({ + ...validBase, + clientId: 'x'.repeat(65), + command: { type: 'room.unlock' }, + }); + expect(isValidationError(r)).toBe(true); + if (isValidationError(r)) expect(r.error).toMatch(/clientId/); + }); +}); + +describe('validateServerEnvelope — length caps (P3)', () => { + const validBase = { + clientId: 'c123', + opId: 'o123', + channel: 'event' as const, + ciphertext: 'abc', + }; + test('accepts valid envelope', () => { + const r = validateServerEnvelope({ ...validBase }); + expect(isValidationError(r)).toBe(false); + }); + test('rejects opId over 64 chars (replay amplification surface)', () => { + const r = validateServerEnvelope({ ...validBase, opId: 'x'.repeat(65) }); + expect(isValidationError(r)).toBe(true); + if (isValidationError(r)) expect(r.error).toMatch(/opId/); + }); + test('rejects clientId over 64 chars', () => { + const r = validateServerEnvelope({ ...validBase, clientId: 'x'.repeat(65) }); + expect(isValidationError(r)).toBe(true); + if (isValidationError(r)) expect(r.error).toMatch(/clientId/); + }); +}); diff --git a/apps/room-service/core/validation.ts b/apps/room-service/core/validation.ts index 99cb8e38..addc11fc 100644 --- a/apps/room-service/core/validation.ts +++ b/apps/room-service/core/validation.ts @@ -38,6 +38,13 @@ function isNonEmptyString(value: unknown): value is string { */ const ROOM_ID_RE = /^[A-Za-z0-9_-]{22}$/; +/** Runtime check for the roomId shape. Exported for use in WebSocket upgrade + * paths where invalid IDs must be rejected BEFORE idFromName/DO instantiation + * to avoid arbitrary DO names and storage reads on attacker-controlled input. */ +export function isRoomId(s: unknown): s is string { + return typeof s === 'string' && ROOM_ID_RE.test(s); +} + /** * HMAC-SHA-256 output is 32 bytes, which base64url-encodes to 43 chars without padding. * Verifiers must match this exact shape. @@ -99,6 +106,34 @@ export function isValidationError(result: T | ValidationError): result is Val const VALID_CHANNELS = new Set(['event', 'presence']); const VALID_ADMIN_COMMANDS = new Set(['room.lock', 'room.unlock', 'room.delete']); +/** + * Max opId length on inbound event-channel envelopes. opId is stored DURABLY + * inside sequenced envelopes, so an authenticated participant could otherwise + * bloat replay bandwidth/storage by sending oversized opIds. generateOpId() + * produces 22-char base64url values (128 bits); 64 gives comfortable headroom + * without enabling amplification. + */ +const MAX_OP_ID_LENGTH = 64; +/** + * Max clientId length. Server overrides envelope.clientId with the + * authenticated meta.clientId before persistence, but we still cap inbound + * values to keep validation symmetric and avoid storing oversized strings + * if the override is ever removed. + */ +const MAX_CLIENT_ID_LENGTH = 64; + +/** + * Max adminProof length. HMAC-SHA-256 base64url-encodes to 43 chars; the + * generous cap guards against pathological input without rejecting any + * legitimate client. Prevents an authenticated peer from spamming + * oversized proof strings to blow up verification cost / log volume. + */ +const MAX_ADMIN_PROOF_LENGTH = 128; + +/** Max challengeId length. generateChallengeId() produces 16-byte base64url + * (22 chars); the cap leaves generous headroom without legitimizing abuse. */ +const MAX_CHALLENGE_ID_LENGTH = 64; + /** Validate a ServerEnvelope from an authenticated WebSocket message. */ export function validateServerEnvelope( msg: Record, @@ -106,9 +141,15 @@ export function validateServerEnvelope( if (!isNonEmptyString(msg.clientId)) { return { error: 'Missing or empty "clientId"', status: 400 }; } + if (msg.clientId.length > MAX_CLIENT_ID_LENGTH) { + return { error: `"clientId" exceeds max length ${MAX_CLIENT_ID_LENGTH}`, status: 400 }; + } if (!isNonEmptyString(msg.opId)) { return { error: 'Missing or empty "opId"', status: 400 }; } + if (msg.opId.length > MAX_OP_ID_LENGTH) { + return { error: `"opId" exceeds max length ${MAX_OP_ID_LENGTH}`, status: 400 }; + } if (!isNonEmptyString(msg.channel) || !VALID_CHANNELS.has(msg.channel)) { return { error: '"channel" must be "event" or "presence"', status: 400 }; } @@ -138,12 +179,24 @@ export function validateAdminCommandEnvelope( if (!isNonEmptyString(msg.challengeId)) { return { error: 'Missing or empty "challengeId"', status: 400 }; } + // Cap string inputs that flow into proof verification and command dispatch. + // Prevents an authenticated peer from spamming oversized identifiers that + // would otherwise hit canonicalJson / log volume on every admin attempt. + if (msg.challengeId.length > MAX_CHALLENGE_ID_LENGTH) { + return { error: `"challengeId" exceeds max length ${MAX_CHALLENGE_ID_LENGTH}`, status: 400 }; + } if (!isNonEmptyString(msg.clientId)) { return { error: 'Missing or empty "clientId"', status: 400 }; } + if (msg.clientId.length > MAX_CLIENT_ID_LENGTH) { + return { error: `"clientId" exceeds max length ${MAX_CLIENT_ID_LENGTH}`, status: 400 }; + } if (!isNonEmptyString(msg.adminProof)) { return { error: 'Missing or empty "adminProof"', status: 400 }; } + if (msg.adminProof.length > MAX_ADMIN_PROOF_LENGTH) { + return { error: `"adminProof" exceeds max length ${MAX_ADMIN_PROOF_LENGTH}`, status: 400 }; + } if (!msg.command || typeof msg.command !== 'object') { return { error: 'Missing or invalid "command"', status: 400 }; @@ -154,7 +207,16 @@ export function validateAdminCommandEnvelope( return { error: `Unknown command type: ${String(cmd.type)}`, status: 400 }; } - // Validate room.lock snapshot pair — both present or both absent + // Build a SANITIZED command with exactly the expected fields. Extra fields + // on the inbound payload are dropped. This is defense-in-depth: + // - The admin proof is computed over canonicalJson(command), so if a client + // smuggles extra fields into the payload, their proof is bound to + // `canonicalJson(dirty)` while the server's re-verification will be + // computed over `canonicalJson(sanitized)` — proof verification fails. + // Honest clients serialize clean commands and their proofs verify. + // - Downstream code (logging, storage, proof recomputation) only ever sees + // the narrow shape its type says it does. + let sanitizedCommand: AdminCommandEnvelope['command']; if (cmd.type === 'room.lock') { const hasCiphertext = isNonEmptyString(cmd.finalSnapshotCiphertext); const hasAtSeq = typeof cmd.finalSnapshotAtSeq === 'number'; @@ -167,13 +229,29 @@ export function validateAdminCommandEnvelope( if (hasAtSeq && ((cmd.finalSnapshotAtSeq as number) < 0 || !Number.isInteger(cmd.finalSnapshotAtSeq))) { return { error: '"finalSnapshotAtSeq" must be a non-negative integer', status: 400 }; } + sanitizedCommand = hasCiphertext && hasAtSeq + ? { + type: 'room.lock', + finalSnapshotCiphertext: cmd.finalSnapshotCiphertext as string, + finalSnapshotAtSeq: cmd.finalSnapshotAtSeq as number, + } + : { type: 'room.lock' }; + } else if (cmd.type === 'room.unlock') { + sanitizedCommand = { type: 'room.unlock' }; + } else if (cmd.type === 'room.delete') { + sanitizedCommand = { type: 'room.delete' }; + } else { + // Explicit fallback: if a future admin command is added to + // VALID_ADMIN_COMMANDS without a branch here, reject rather than + // accidentally aliasing to room.delete. + return { error: `Unknown command type: ${String(cmd.type)}`, status: 400 }; } return { type: 'admin.command', challengeId: msg.challengeId, clientId: msg.clientId, - command: msg.command as AdminCommandEnvelope['command'], + command: sanitizedCommand, adminProof: msg.adminProof, }; } diff --git a/apps/room-service/scripts/smoke.ts b/apps/room-service/scripts/smoke.ts index a40e2296..c70eb33e 100644 --- a/apps/room-service/scripts/smoke.ts +++ b/apps/room-service/scripts/smoke.ts @@ -20,10 +20,10 @@ import { computeAdminProof, encryptSnapshot, encryptPayload, + encryptPresence, generateRoomId, generateRoomSecret, generateAdminSecret, - generateClientId, generateOpId, } from '@plannotator/shared/collab/client'; @@ -74,8 +74,11 @@ async function connectAndAuth( ): Promise { return new Promise((resolve, reject) => { const ws = new WebSocket(`${WS_BASE}/ws/${roomId}`); - const clientId = generateClientId(); - const result: AuthedSocket = { ws, clientId, messages: [], closed: false }; + // clientId is now assigned by the server in the auth.challenge message; + // we adopt it here instead of self-generating (see PresenceImpersonation + // fix). Placeholder until challenge arrives. + let clientId = ''; + const result: AuthedSocket = { ws, clientId: '', messages: [], closed: false }; let authed = false; const timeout = setTimeout(() => { @@ -86,6 +89,8 @@ async function connectAndAuth( const msg = JSON.parse(String(event.data)); if (!authed && msg.type === 'auth.challenge') { + clientId = msg.clientId; + result.clientId = clientId; const proof = await computeAuthProof(roomVerifier, roomId, clientId, msg.challengeId, msg.nonce); ws.send(JSON.stringify({ type: 'auth.response', challengeId: msg.challengeId, clientId, proof, lastSeq })); return; @@ -135,7 +140,7 @@ async function run(): Promise { const roomSecret = generateRoomSecret(); const adminSecret = generateAdminSecret(); - const { authKey, eventKey } = await deriveRoomKeys(roomSecret); + const { authKey, eventKey, presenceKey } = await deriveRoomKeys(roomSecret); const adminKey = await deriveAdminKey(adminSecret); const roomVerifier = await computeRoomVerifier(authKey, roomId); @@ -191,8 +196,19 @@ async function run(): Promise { client1.messages.length = 0; client2.messages.length = 0; - // Client1 sends an event - const eventCiphertext = await encryptPayload(eventKey, JSON.stringify({ type: 'annotation.add', annotations: [] })); + // Client1 sends an event. Use a real annotation — empty annotation.add is + // rejected by conforming clients (no-op would burn a durable seq). + const realAnnotation = { + id: 'smoke-ann-1', + blockId: 'block-1', + startOffset: 0, + endOffset: 5, + type: 'COMMENT' as const, + originalText: 'hello', + createdA: Date.now(), + text: 'smoke test annotation', + }; + const eventCiphertext = await encryptPayload(eventKey, JSON.stringify({ type: 'annotation.add', annotations: [realAnnotation] })); client1.ws.send(JSON.stringify({ clientId: client1.clientId, opId: generateOpId(), @@ -213,7 +229,13 @@ async function run(): Promise { client1.messages.length = 0; client2.messages.length = 0; - const presenceCiphertext = await encryptPayload(eventKey, '{}'); + // Presence MUST be encrypted with presenceKey (not eventKey) and carry a + // valid PresenceState shape — conforming clients reject malformed presence. + const validPresence = { + user: { id: 'smoke-u1', name: 'smoke', color: '#f00' }, + cursor: null, + }; + const presenceCiphertext = await encryptPresence(presenceKey, validPresence); client1.ws.send(JSON.stringify({ clientId: client1.clientId, opId: generateOpId(), diff --git a/bun.lock b/bun.lock index d5274736..64728ead 100644 --- a/bun.lock +++ b/bun.lock @@ -5,18 +5,18 @@ "": { "name": "plannotator", "dependencies": { - "@anthropic-ai/claude-agent-sdk": "0.2.92", - "@openai/codex-sdk": "0.118.0", + "@anthropic-ai/claude-agent-sdk": "^0.2.92", + "@openai/codex-sdk": "^0.118.0", "@opencode-ai/sdk": "^1.3.0", "@pierre/diffs": "^1.1.12", - "diff": "8.0.4", + "diff": "^8.0.4", "dockview-react": "^5.2.0", "dompurify": "^3.3.3", - "marked": "17.0.6", + "marked": "^17.0.6", }, "devDependencies": { "@types/dompurify": "^3.2.0", - "@types/node": "25.5.2", + "@types/node": "^25.5.2", "@types/turndown": "^5.0.6", "bun-types": "^1.3.11", }, @@ -64,7 +64,7 @@ }, "apps/opencode-plugin": { "name": "@plannotator/opencode", - "version": "0.17.9", + "version": "0.17.10", "dependencies": { "@opencode-ai/plugin": "^1.1.10", }, @@ -86,7 +86,7 @@ }, "apps/pi-extension": { "name": "@plannotator/pi-extension", - "version": "0.17.9", + "version": "0.17.10", "dependencies": { "@joplin/turndown-plugin-gfm": "^1.0.64", "turndown": "^7.2.4", @@ -135,6 +135,17 @@ "vite-plugin-singlefile": "^2.0.3", }, }, + "apps/room-service": { + "name": "@plannotator/room-service", + "version": "0.1.0", + "dependencies": { + "@plannotator/shared": "workspace:*", + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20241218.0", + "wrangler": "^4.80.0", + }, + }, "apps/vscode-extension": { "name": "plannotator-webview", "version": "0.16.5", @@ -176,7 +187,7 @@ }, "packages/server": { "name": "@plannotator/server", - "version": "0.17.9", + "version": "0.17.10", "dependencies": { "@plannotator/ai": "workspace:*", "@plannotator/shared": "workspace:*", @@ -655,6 +666,8 @@ "@plannotator/review-editor": ["@plannotator/review-editor@workspace:packages/review-editor"], + "@plannotator/room-service": ["@plannotator/room-service@workspace:apps/room-service"], + "@plannotator/server": ["@plannotator/server@workspace:packages/server"], "@plannotator/shared": ["@plannotator/shared@workspace:packages/shared"], @@ -663,6 +676,12 @@ "@plannotator/web-highlighter": ["@plannotator/web-highlighter@0.8.1", "", {}, "sha512-FlteNOwRj9iNSY/AhFMtqOnVS4FvsACvTw6IiOM1y8iDyhiU/WeZOgjURENvIY+wuUaiS9DDFmg0PrHMyuMR1Q=="], + "@poppinss/colors": ["@poppinss/colors@4.1.6", "", { "dependencies": { "kleur": "^4.1.5" } }, "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg=="], + + "@poppinss/dumper": ["@poppinss/dumper@0.6.5", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@sindresorhus/is": "^7.0.2", "supports-color": "^10.0.0" } }, "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw=="], + + "@poppinss/exception": ["@poppinss/exception@1.2.3", "", {}, "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw=="], + "@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="], "@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="], @@ -775,6 +794,8 @@ "@sinclair/typebox": ["@sinclair/typebox@0.34.48", "", {}, "sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA=="], + "@sindresorhus/is": ["@sindresorhus/is@7.2.0", "", {}, "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw=="], + "@sindresorhus/merge-streams": ["@sindresorhus/merge-streams@2.3.0", "", {}, "sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg=="], "@smithy/abort-controller": ["@smithy/abort-controller@4.2.8", "", { "dependencies": { "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-peuVfkYHAmS5ybKxWcfraK7WBBP0J+rkfUcbHJJKQ4ir3UAUNQI+Y4Vt/PqSzGqgloJ5O1dk7+WzNL8wcCSXbw=="], @@ -867,6 +888,8 @@ "@smithy/uuid": ["@smithy/uuid@1.1.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-4aUIteuyxtBUhVdiQqcDhKFitwfd9hqoSDYY2KRXiWtgoWJ9Bmise+KfEPDiVHWeJepvF8xJO9/9+WDIciMFFw=="], + "@speed-highlight/core": ["@speed-highlight/core@1.2.15", "", {}, "sha512-BMq1K3DsElxDWawkX6eLg9+CKJrTVGCBAWVuHXVUV2u0s2711qiChLSId6ikYPfxhdYocLNt3wWwSvDiTvFabw=="], + "@tailwindcss/node": ["@tailwindcss/node@4.1.18", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.6.1", "lightningcss": "1.30.2", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.1.18" } }, "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ=="], "@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.18", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.18", "@tailwindcss/oxide-darwin-arm64": "4.1.18", "@tailwindcss/oxide-darwin-x64": "4.1.18", "@tailwindcss/oxide-freebsd-x64": "4.1.18", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", "@tailwindcss/oxide-linux-x64-musl": "4.1.18", "@tailwindcss/oxide-wasm32-wasi": "4.1.18", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" } }, "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A=="], @@ -1417,6 +1440,8 @@ "environment": ["environment@1.1.0", "", {}, "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q=="], + "error-stack-parser-es": ["error-stack-parser-es@1.0.5", "", {}, "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA=="], + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], @@ -2521,6 +2546,8 @@ "youch": ["youch@3.3.4", "", { "dependencies": { "cookie": "^0.7.1", "mustache": "^4.2.0", "stacktracey": "^2.1.8" } }, "sha512-UeVBXie8cA35DS6+nBkls68xaBBXCye0CNznrhszZjTbRVnJKQuNsyLKBTTL4ln1o1rh2PKtv35twV7irj5SEg=="], + "youch-core": ["youch-core@0.3.3", "", { "dependencies": { "@poppinss/exception": "^1.2.2", "error-stack-parser-es": "^1.0.5" } }, "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA=="], + "zod": ["zod@4.1.8", "", {}, "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ=="], "zod-to-json-schema": ["zod-to-json-schema@3.25.1", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA=="], @@ -2591,6 +2618,12 @@ "@plannotator/review/@types/node": ["@types/node@22.19.3", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA=="], + "@plannotator/room-service/wrangler": ["wrangler@4.81.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.4.2", "@cloudflare/unenv-preset": "2.16.0", "blake3-wasm": "2.1.5", "esbuild": "0.27.3", "miniflare": "4.20260405.0", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260405.1" }, "optionalDependencies": { "fsevents": "~2.3.2" }, "peerDependencies": { "@cloudflare/workers-types": "^4.20260405.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js" } }, "sha512-9fLPDuDcb8Nu6iXrl5E3HGYt3TVhQr/UvqtTvWr9Nl1X7PlQrmWMwQCfSioqN8VHYyQCyESV5jQsoKg8Sx+sEA=="], + + "@poppinss/colors/kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], + + "@poppinss/dumper/supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="], + "@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.7.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg=="], @@ -2793,6 +2826,18 @@ "@plannotator/review/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + "@plannotator/room-service/wrangler/@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.4.2", "", {}, "sha512-SIOD2DxrRRwQ+jgzlXCqoEFiKOFqaPjhnNTGKXSRLvp1HiOvapLaFG2kEr9dYQTYe8rKrd9uvDUzmAITeNyaHQ=="], + + "@plannotator/room-service/wrangler/@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.16.0", "", { "peerDependencies": { "unenv": "2.0.0-rc.24", "workerd": "1.20260301.1 || ~1.20260302.1 || ~1.20260303.1 || ~1.20260304.1 || >1.20260305.0 <2.0.0-0" }, "optionalPeers": ["workerd"] }, "sha512-8ovsRpwzPoEqPUzoErAYVv8l3FMZNeBVQfJTvtzP4AgLSRGZISRfuChFxHWUQd3n6cnrwkuTGxT+2cGo8EsyYg=="], + + "@plannotator/room-service/wrangler/esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="], + + "@plannotator/room-service/wrangler/miniflare": ["miniflare@4.20260405.0", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "^0.34.5", "undici": "7.24.4", "workerd": "1.20260405.1", "ws": "8.18.0", "youch": "4.1.0-beta.10" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-tpr4XdWMq7zFdsHH+CS0XS47nQzlRZH0rMJ1vobOZbkrs3cIj7qbD40ON616hDnzHxwqwB2qKHzmmuj6oRisSQ=="], + + "@plannotator/room-service/wrangler/unenv": ["unenv@2.0.0-rc.24", "", { "dependencies": { "pathe": "^2.0.3" } }, "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw=="], + + "@plannotator/room-service/wrangler/workerd": ["workerd@1.20260405.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260405.1", "@cloudflare/workerd-darwin-arm64": "1.20260405.1", "@cloudflare/workerd-linux-64": "1.20260405.1", "@cloudflare/workerd-linux-arm64": "1.20260405.1", "@cloudflare/workerd-windows-64": "1.20260405.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-bSaRWCv9iO8/FWpgZRjHLGZLolX5s1AErRSYaTECMMHOZKuCbl2+ehnSyc+ZZ/70y+9owADmN6HoYEWvBlJdYw=="], + "@textlint/linter-formatter/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], "@textlint/linter-formatter/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], @@ -3045,6 +3090,72 @@ "@aws-sdk/credential-provider-sso/@aws-sdk/token-providers/@aws-sdk/nested-clients/@aws-sdk/util-endpoints": ["@aws-sdk/util-endpoints@3.993.0", "", { "dependencies": { "@aws-sdk/types": "^3.973.1", "@smithy/types": "^4.12.0", "@smithy/url-parser": "^4.2.8", "@smithy/util-endpoints": "^3.2.8", "tslib": "^2.6.2" } }, "sha512-j6vioBeRZ4eHX4SWGvGPpwGg/xSOcK7f1GL0VM+rdf3ZFTIsUEhCFmD78B+5r2PgztcECSzEfvHQX01k8dPQPw=="], + "@plannotator/room-service/wrangler/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="], + + "@plannotator/room-service/wrangler/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="], + + "@plannotator/room-service/wrangler/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.3", "", { "os": "android", "cpu": "arm64" }, "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg=="], + + "@plannotator/room-service/wrangler/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.27.3", "", { "os": "android", "cpu": "x64" }, "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ=="], + + "@plannotator/room-service/wrangler/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg=="], + + "@plannotator/room-service/wrangler/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg=="], + + "@plannotator/room-service/wrangler/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w=="], + + "@plannotator/room-service/wrangler/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA=="], + + "@plannotator/room-service/wrangler/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.3", "", { "os": "linux", "cpu": "arm" }, "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw=="], + + "@plannotator/room-service/wrangler/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg=="], + + "@plannotator/room-service/wrangler/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.3", "", { "os": "linux", "cpu": "ia32" }, "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg=="], + + "@plannotator/room-service/wrangler/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA=="], + + "@plannotator/room-service/wrangler/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw=="], + + "@plannotator/room-service/wrangler/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA=="], + + "@plannotator/room-service/wrangler/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ=="], + + "@plannotator/room-service/wrangler/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw=="], + + "@plannotator/room-service/wrangler/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.3", "", { "os": "linux", "cpu": "x64" }, "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA=="], + + "@plannotator/room-service/wrangler/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA=="], + + "@plannotator/room-service/wrangler/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.3", "", { "os": "none", "cpu": "x64" }, "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA=="], + + "@plannotator/room-service/wrangler/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.3", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw=="], + + "@plannotator/room-service/wrangler/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.3", "", { "os": "openbsd", "cpu": "x64" }, "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ=="], + + "@plannotator/room-service/wrangler/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g=="], + + "@plannotator/room-service/wrangler/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.3", "", { "os": "sunos", "cpu": "x64" }, "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA=="], + + "@plannotator/room-service/wrangler/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA=="], + + "@plannotator/room-service/wrangler/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q=="], + + "@plannotator/room-service/wrangler/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="], + + "@plannotator/room-service/wrangler/miniflare/undici": ["undici@7.24.4", "", {}, "sha512-BM/JzwwaRXxrLdElV2Uo6cTLEjhSb3WXboncJamZ15NgUURmvlXvxa6xkwIOILIjPNo9i8ku136ZvWV0Uly8+w=="], + + "@plannotator/room-service/wrangler/miniflare/youch": ["youch@4.1.0-beta.10", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@poppinss/dumper": "^0.6.4", "@speed-highlight/core": "^1.2.7", "cookie": "^1.0.2", "youch-core": "^0.3.3" } }, "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ=="], + + "@plannotator/room-service/wrangler/workerd/@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260405.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-EbmdBcmeIGogKG4V1odSWQe7z4rHssUD4iaXv0cXA22/MFrzH3iQT0R+FJFyhucGtih/9B9E+6j0QbSQD8xT3w=="], + + "@plannotator/room-service/wrangler/workerd/@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260405.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-r44r418bOQtoP+Odu+L/BQM9q5cRSXRd1N167PgZQIo4MlqzTwHO4L0wwXhxbcV/PF46rrQre/uTFS8R0R+xSQ=="], + + "@plannotator/room-service/wrangler/workerd/@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260405.1", "", { "os": "linux", "cpu": "x64" }, "sha512-Aaq3RWnaTCzMBo77wC8fjOx+SFdO/rlcXa6HAf+PJs51LyMISFOBCJKqSlS6Irphen0WHHxFKPHUO9bjfj8g2g=="], + + "@plannotator/room-service/wrangler/workerd/@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260405.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-Lbp9Z2wiMzy3Sji3YwMHK5WDlejsH3jF4swAFEv7+jIf3NowZHga3GzwTypNRmcwnfz/XrqQ7Hc0Ul9OoU/lCw=="], + + "@plannotator/room-service/wrangler/workerd/@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260405.1", "", { "os": "win32", "cpu": "x64" }, "sha512-FhE0kt93kj5JnSPVqi4BAXpQQENyKnuSOoJLd35mkMMGhtPrwv5EsReJdck0S8hUocCBlb+U0RmP8ta6k41HjQ=="], + "@vscode/vsce/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], "ansi-align/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], diff --git a/package.json b/package.json index 92d9b8e3..bb8350bd 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,7 @@ "build:vscode": "bun run --cwd apps/vscode-extension build", "package:vscode": "bun run --cwd apps/vscode-extension package", "test": "bun test", - "typecheck": "tsc --noEmit -p packages/shared/tsconfig.json && tsc --noEmit -p packages/ai/tsconfig.json && tsc --noEmit -p packages/server/tsconfig.json" + "typecheck": "bunx tsc --noEmit -p packages/shared/tsconfig.json && bunx tsc --noEmit -p packages/ai/tsconfig.json && bunx tsc --noEmit -p packages/server/tsconfig.json && bunx tsc --noEmit -p packages/ui/tsconfig.collab.json" }, "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.2.92", diff --git a/packages/shared/collab/client-runtime/apply-event.test.ts b/packages/shared/collab/client-runtime/apply-event.test.ts new file mode 100644 index 00000000..c0dab93c --- /dev/null +++ b/packages/shared/collab/client-runtime/apply-event.test.ts @@ -0,0 +1,243 @@ +import { describe, expect, test } from 'bun:test'; +import { applyAnnotationEvent, annotationsToArray } from './apply-event'; +import type { RoomAnnotation, RoomServerEvent, RoomSnapshot } from '../types'; + +function makeAnnotation(id: string, extras: Partial = {}): RoomAnnotation { + return { + id, + blockId: 'b1', + startOffset: 0, + endOffset: 5, + type: 'COMMENT', + originalText: 'hello', + createdA: 1234567890, + ...extras, + }; +} + +describe('applyAnnotationEvent', () => { + test('annotation.add inserts annotations', () => { + const map = new Map(); + const event: RoomServerEvent = { + type: 'annotation.add', + annotations: [makeAnnotation('a1'), makeAnnotation('a2')], + }; + const result = applyAnnotationEvent(map, event); + expect(result.applied).toBe(true); + expect(map.size).toBe(2); + expect(map.has('a1')).toBe(true); + expect(map.has('a2')).toBe(true); + }); + + test('annotation.update merges patch into existing', () => { + const map = new Map(); + map.set('a1', makeAnnotation('a1', { text: 'original' })); + const event: RoomServerEvent = { + type: 'annotation.update', + id: 'a1', + patch: { text: 'updated' }, + }; + const result = applyAnnotationEvent(map, event); + expect(result.applied).toBe(true); + expect(map.get('a1')?.text).toBe('updated'); + }); + + test('annotation.update drops own-property undefined values from patch (normalization)', () => { + const map = new Map(); + map.set('a1', makeAnnotation('a1', { text: 'original', author: 'alice' })); + const event: RoomServerEvent = { + type: 'annotation.update', + id: 'a1', + // Direct in-process caller passes undefined — must be dropped, not + // stored as an own key with value undefined. + patch: { text: 'updated', author: undefined } as Partial, + }; + applyAnnotationEvent(map, event); + const stored = map.get('a1')!; + expect(stored.text).toBe('updated'); + // author must still be 'alice' — the undefined patch must not have erased it. + expect(stored.author).toBe('alice'); + // And own-property check: the stored object must NOT have an own `author: undefined` slot. + expect('author' in stored).toBe(true); + expect(stored.author).not.toBeUndefined(); + }); + + test('annotation.update rejects when merged final annotation violates cross-field invariants', () => { + // Inline annotations (COMMENT/DELETION) must have non-empty blockId. A + // patch that sets blockId: '' on a COMMENT passes the patch-level + // validator (blockId is just a string) but produces an invalid merged + // final annotation. The reducer must validate the merged state and + // refuse to store the invalid result. + const map = new Map(); + map.set('a1', makeAnnotation('a1')); // COMMENT with blockId: 'b1' + const originalBlockId = map.get('a1')!.blockId; + + const event: RoomServerEvent = { + type: 'annotation.update', + id: 'a1', + patch: { blockId: '' } as Partial, + }; + const result = applyAnnotationEvent(map, event); + expect(result.applied).toBe(false); + expect(result.reason).toContain('failed shape validation'); + // Stored annotation is untouched — blockId is still non-empty. + expect(map.get('a1')!.blockId).toBe(originalBlockId); + }); + + test('annotation.update rejects when patching type turns existing annotation into an invalid inline (empty blockId)', () => { + // A GLOBAL_COMMENT legitimately carries blockId: ''. Patching its type + // to COMMENT produces an invalid final state (inline requires non-empty + // blockId). Must reject. + const map = new Map(); + map.set('g1', makeAnnotation('g1', { type: 'GLOBAL_COMMENT', blockId: '' })); + const event: RoomServerEvent = { + type: 'annotation.update', + id: 'g1', + patch: { type: 'COMMENT' }, + }; + const result = applyAnnotationEvent(map, event); + expect(result.applied).toBe(false); + // Stored annotation type is untouched. + expect(map.get('g1')!.type).toBe('GLOBAL_COMMENT'); + }); + + test('annotation.update defensively preserves existing.id even if patch slipped in a mismatched id', () => { + // Defense-in-depth against identity-mutation: isRoomAnnotationPatch + // already rejects id in patches. The reducer ALSO forces existing.id so + // that if a malformed patch ever reached here, we'd still store the + // annotation under the correct id. + const map = new Map(); + map.set('a1', makeAnnotation('a1', { text: 'original' })); + const event: RoomServerEvent = { + type: 'annotation.update', + id: 'a1', + patch: { id: 'hijacked', text: 'updated' } as Partial, + }; + const result = applyAnnotationEvent(map, event); + expect(result.applied).toBe(true); + expect(map.size).toBe(1); + expect(map.has('a1')).toBe(true); + expect(map.has('hijacked')).toBe(false); + const stored = map.get('a1')!; + expect(stored.id).toBe('a1'); // internal id unchanged + expect(stored.text).toBe('updated'); // other fields still patched + }); + + test('annotation.update isolates nested startMeta/endMeta between input patch and stored annotation', () => { + const map = new Map(); + map.set('a1', makeAnnotation('a1', { + startMeta: { parentTagName: 'p', parentIndex: 0, textOffset: 0 }, + })); + const patch: Partial = { + startMeta: { parentTagName: 'div', parentIndex: 1, textOffset: 5 }, + endMeta: { parentTagName: 'div', parentIndex: 1, textOffset: 10 }, + }; + const event: RoomServerEvent = { type: 'annotation.update', id: 'a1', patch }; + applyAnnotationEvent(map, event); + + // Mutate the INPUT patch's nested meta objects after apply. + patch.startMeta!.parentTagName = 'HIJACKED'; + patch.endMeta!.textOffset = 999; + + const stored = map.get('a1')!; + // Stored annotation must be unaffected. + expect(stored.startMeta!.parentTagName).toBe('div'); + expect(stored.startMeta!.textOffset).toBe(5); + expect(stored.endMeta!.textOffset).toBe(10); + }); + + test('annotation.update on missing id is a no-op', () => { + const map = new Map(); + const event: RoomServerEvent = { + type: 'annotation.update', + id: 'missing', + patch: { text: 'x' }, + }; + const result = applyAnnotationEvent(map, event); + expect(result.applied).toBe(false); + expect(result.reason).toContain('not found'); + expect(map.size).toBe(0); + }); + + test('annotation.remove deletes ids', () => { + const map = new Map(); + map.set('a1', makeAnnotation('a1')); + map.set('a2', makeAnnotation('a2')); + map.set('a3', makeAnnotation('a3')); + const event: RoomServerEvent = { + type: 'annotation.remove', + ids: ['a1', 'a3'], + }; + applyAnnotationEvent(map, event); + expect(map.has('a1')).toBe(false); + expect(map.has('a2')).toBe(true); + expect(map.has('a3')).toBe(false); + }); + + test('annotation.clear without source clears all', () => { + const map = new Map(); + map.set('a1', makeAnnotation('a1')); + map.set('a2', makeAnnotation('a2', { source: 'eslint' })); + applyAnnotationEvent(map, { type: 'annotation.clear' }); + expect(map.size).toBe(0); + }); + + test('annotation.clear with source only removes matching', () => { + const map = new Map(); + map.set('a1', makeAnnotation('a1')); + map.set('a2', makeAnnotation('a2', { source: 'eslint' })); + map.set('a3', makeAnnotation('a3', { source: 'eslint' })); + applyAnnotationEvent(map, { type: 'annotation.clear', source: 'eslint' }); + expect(map.has('a1')).toBe(true); + expect(map.has('a2')).toBe(false); + expect(map.has('a3')).toBe(false); + }); + + test('snapshot is NOT handled here — production uses CollabRoomClient.handleRoomSnapshot()', () => { + // Snapshots must atomically update planMarkdown + seq + annotations, + // which this reducer cannot do. The client's snapshot path is the sole + // entry point; this reducer returns applied: false so any accidental + // caller gets a loud no-op rather than a half-applied snapshot. + const map = new Map(); + map.set('a1', makeAnnotation('a1')); + const snapshot: RoomSnapshot = { + versionId: 'v1', + planMarkdown: '# Plan', + annotations: [makeAnnotation('b1'), makeAnnotation('b2')], + }; + const result = applyAnnotationEvent(map, { type: 'snapshot', payload: snapshot, snapshotSeq: 5 }); + expect(result.applied).toBe(false); + expect(result.reason).toContain('snapshot'); + // Map is untouched. + expect(map.size).toBe(1); + expect(map.has('a1')).toBe(true); + }); + + test('presence.update is not handled here', () => { + const map = new Map(); + const result = applyAnnotationEvent(map, { + type: 'presence.update', + clientId: 'c1', + presence: { + user: { id: 'u1', name: 'alice', color: '#f00' }, + cursor: null, + }, + }); + expect(result.applied).toBe(false); + }); +}); + +describe('annotationsToArray', () => { + test('returns array in insertion order', () => { + const map = new Map(); + map.set('a1', makeAnnotation('a1')); + map.set('a2', makeAnnotation('a2')); + map.set('a3', makeAnnotation('a3')); + const arr = annotationsToArray(map); + expect(arr.map(a => a.id)).toEqual(['a1', 'a2', 'a3']); + }); + + test('empty map returns empty array', () => { + expect(annotationsToArray(new Map())).toEqual([]); + }); +}); diff --git a/packages/shared/collab/client-runtime/apply-event.ts b/packages/shared/collab/client-runtime/apply-event.ts new file mode 100644 index 00000000..bd35eafb --- /dev/null +++ b/packages/shared/collab/client-runtime/apply-event.ts @@ -0,0 +1,131 @@ +/** + * Pure reducer: applies a decrypted RoomServerEvent to a Map. + * + * In V1 this runs only on the server-echo path — the client does not apply + * annotation ops optimistically. Server echo is authoritative; this reducer + * is the single point where annotations enter state. + * + * Separated from the client class so it can be unit-tested without WebSocket mocks. + */ + +import { isRoomAnnotation, type RoomAnnotation, type RoomServerEvent } from '../types'; + +/** Shallow + nested-meta clone so stored annotations are isolated from inputs. + * Exported so client.ts and other reducer callers share the same definition — + * avoids the drift risk of two helpers cloning the same nested fields. */ +export function cloneRoomAnnotation(a: RoomAnnotation): RoomAnnotation { + return { + ...a, + startMeta: a.startMeta ? { ...a.startMeta } : undefined, + endMeta: a.endMeta ? { ...a.endMeta } : undefined, + }; +} + +/** + * Clone a partial patch, including nested startMeta/endMeta. A direct-event + * subscriber mutating the emitted event.patch.startMeta must not reach back + * into the stored annotation, and vice versa. + */ +export function cloneRoomAnnotationPatch(patch: Partial): Partial { + const out: Partial = { ...patch }; + if (patch.startMeta !== undefined) out.startMeta = { ...patch.startMeta }; + if (patch.endMeta !== undefined) out.endMeta = { ...patch.endMeta }; + return out; +} + +/** + * Apply an annotation-related event to the annotations map. + * Mutates the map in place. Returns a hint for the caller about what happened. + * + * Annotations from the event are CLONED before being stored. Callers (and + * event subscribers) can safely mutate input annotations without reaching + * back into the stored map. + */ +export function applyAnnotationEvent( + annotations: Map, + event: RoomServerEvent, +): { applied: boolean; reason?: string } { + switch (event.type) { + case 'annotation.add': + for (const ann of event.annotations) { + annotations.set(ann.id, cloneRoomAnnotation(ann)); + } + return { applied: true }; + + case 'annotation.update': { + const existing = annotations.get(event.id); + if (!existing) { + return { applied: false, reason: `annotation ${event.id} not found` }; + } + // `undefined` in a patch means "field absent / no change" — it is NOT + // a clear-field signal. We strip own-property undefined values before + // the spread so `{ text: undefined }` is treated identically to `{}` + // and does not create an own `text` key on the stored annotation. + // + // Wire-path patches come from JSON (which cannot encode undefined), so + // this only matters for direct in-process callers. If clear-field + // semantics are ever needed, add them explicitly via `null` or a + // dedicated operation; do not repurpose `undefined`. + const normalized = Object.fromEntries( + Object.entries(event.patch).filter(([, v]) => v !== undefined), + ) as Partial; + // Clone nested startMeta/endMeta before merging so a later mutation to + // the input patch can't reach back into the stored annotation. + const patch = cloneRoomAnnotationPatch(normalized); + // Defense-in-depth: isRoomAnnotationPatch rejects `id` in patches, but + // we also force `id` back to `existing.id` here. Without this, a patch + // that slipped through with a mismatched `id` would store an annotation + // under map key `existing.id` whose object reports a different id — + // subsequent removes/updates by the visible id would miss it. + const merged = { ...existing, ...patch, id: existing.id } as RoomAnnotation; + // Validate the MERGED final annotation against the full annotation + // validator. Individual patch fields pass their type checks but can + // still produce an invalid final state when combined with existing + // fields — e.g. a patch { blockId: '' } applied to a COMMENT, or a + // patch { type: 'COMMENT' } applied to a GLOBAL_COMMENT that carried + // blockId: ''. isRoomAnnotation enforces cross-field invariants + // (inline annotations require non-empty blockId, etc.). + if (!isRoomAnnotation(merged)) { + return { applied: false, reason: `merged annotation ${event.id} failed shape validation` }; + } + annotations.set(event.id, cloneRoomAnnotation(merged)); + return { applied: true }; + } + + case 'annotation.remove': + for (const id of event.ids) { + annotations.delete(id); + } + return { applied: true }; + + case 'annotation.clear': { + if (event.source === undefined) { + annotations.clear(); + } else { + for (const [id, ann] of annotations) { + if (ann.source === event.source) annotations.delete(id); + } + } + return { applied: true }; + } + + case 'snapshot': + // Snapshots are NOT handled by this reducer. A correct snapshot apply + // must update planMarkdown, seq, AND the annotations map together; + // handling any of those in isolation risks drift. Production clients + // use CollabRoomClient.handleRoomSnapshot() for that atomic path. + return { applied: false, reason: 'snapshots handled by client snapshot path, not this reducer' }; + + case 'presence.update': + // Presence is handled separately by the caller — not a snapshot mutation. + return { applied: false, reason: 'presence event handled separately' }; + + default: + return { applied: false, reason: 'unknown event type' }; + } +} + +/** Return annotations as an ordered array (insertion order preserved). */ +export function annotationsToArray(annotations: Map): RoomAnnotation[] { + return [...annotations.values()]; +} diff --git a/packages/shared/collab/client-runtime/backoff.test.ts b/packages/shared/collab/client-runtime/backoff.test.ts new file mode 100644 index 00000000..1bc1e3cd --- /dev/null +++ b/packages/shared/collab/client-runtime/backoff.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, test } from 'bun:test'; +import { computeBackoffMs, DEFAULT_BACKOFF } from './backoff'; + +describe('computeBackoffMs', () => { + // Stable random for deterministic tests + const rand05 = () => 0.5; + const rand0 = () => 0; + const rand1 = () => 0.999999; + + test('attempt 0 uses initial delay (with jitter)', () => { + expect(computeBackoffMs(0, {}, rand05)).toBe(Math.floor(0.5 * DEFAULT_BACKOFF.initialDelayMs)); + }); + + test('attempt 1 doubles (factor 2)', () => { + expect(computeBackoffMs(1, {}, rand05)).toBe(Math.floor(0.5 * DEFAULT_BACKOFF.initialDelayMs * 2)); + }); + + test('delay caps at maxDelayMs', () => { + // Attempt 20 would be 500 * 2^20 = 524,288,000 — capped at 15_000 + expect(computeBackoffMs(20, {}, rand1)).toBe(Math.floor(0.999999 * 15_000)); + }); + + test('rand=0 produces 0 delay', () => { + expect(computeBackoffMs(5, {}, rand0)).toBe(0); + }); + + test('custom options override defaults', () => { + const opts = { initialDelayMs: 100, maxDelayMs: 1000, factor: 3 }; + expect(computeBackoffMs(0, opts, rand05)).toBe(Math.floor(0.5 * 100)); + expect(computeBackoffMs(1, opts, rand05)).toBe(Math.floor(0.5 * 300)); + expect(computeBackoffMs(5, opts, rand1)).toBe(Math.floor(0.999999 * 1000)); + }); + + test('negative attempt treated as 0', () => { + expect(computeBackoffMs(-5, {}, rand05)).toBe(Math.floor(0.5 * DEFAULT_BACKOFF.initialDelayMs)); + }); +}); diff --git a/packages/shared/collab/client-runtime/backoff.ts b/packages/shared/collab/client-runtime/backoff.ts new file mode 100644 index 00000000..f4b733c4 --- /dev/null +++ b/packages/shared/collab/client-runtime/backoff.ts @@ -0,0 +1,34 @@ +/** + * Exponential backoff with full jitter, used by auto-reconnect. + * + * Pure function — all timing/randomness injected so tests can stub. + */ + +export interface BackoffOptions { + initialDelayMs?: number; // default 500 + maxDelayMs?: number; // default 15_000 + factor?: number; // default 2 +} + +export const DEFAULT_BACKOFF: Required = { + initialDelayMs: 500, + maxDelayMs: 15_000, + factor: 2, +}; + +/** + * Compute the delay (ms) before retry attempt N. + * + * Uses full jitter: `rand() * min(maxDelayMs, initialDelayMs * factor^attempt)`. + * Attempt 0 is the first retry. Attempts are capped at the max delay. + */ +export function computeBackoffMs( + attempt: number, + options: BackoffOptions = {}, + rand: () => number = Math.random, +): number { + const { initialDelayMs, maxDelayMs, factor } = { ...DEFAULT_BACKOFF, ...options }; + const rawDelay = initialDelayMs * Math.pow(factor, Math.max(0, attempt)); + const capped = Math.min(maxDelayMs, rawDelay); + return Math.floor(rand() * capped); +} diff --git a/packages/shared/collab/client-runtime/client.test.ts b/packages/shared/collab/client-runtime/client.test.ts new file mode 100644 index 00000000..fbeed039 --- /dev/null +++ b/packages/shared/collab/client-runtime/client.test.ts @@ -0,0 +1,2709 @@ +/** + * Unit tests for CollabRoomClient using MockWebSocket. + * + * Scripts the server-side handshake, events, and admin flow deterministically. + */ + +import { describe, expect, test } from 'bun:test'; +import { + CollabRoomClient, + AdminNotAuthorizedError, + NotConnectedError, + AdminRejectedError, + ConnectTimeoutError, + InvalidOutboundPayloadError, +} from './client'; +import { MockWebSocket } from './mock-websocket'; +import { + deriveRoomKeys, + deriveAdminKey, + computeRoomVerifier, + computeAdminVerifier, + computeAuthProof, + encryptEventOp, + encryptPresence, + encryptSnapshot, + decryptEventPayload, +} from '../crypto'; +import { generateClientId, generateRoomSecret, generateAdminSecret, generateChallengeId, generateNonce } from '../ids'; +import type { AuthChallenge, AuthAccepted, RoomSnapshot, ServerEnvelope, RoomTransportMessage, RoomAnnotation, AuthResponse, AdminChallenge, AdminCommandEnvelope } from '../types'; +import type { CollabRoomState, CollabRoomUser } from './types'; + +// --------------------------------------------------------------------------- +// Test fixture +// --------------------------------------------------------------------------- + +const USER: CollabRoomUser = { id: 'u1', name: 'alice', color: '#f00' }; +const ROOM_ID = 'ABCDEFGHIJKLMNOPQRSTUv'; // 22 chars + +/** + * Construct a test auth.challenge including the now-required server-assigned + * clientId. Tests that care about the exact clientId can pass it explicitly; + * otherwise a fresh one is generated per call. + */ +function makeAuthChallenge(overrides: Partial = {}): AuthChallenge { + return { + type: 'auth.challenge', + challengeId: overrides.challengeId ?? generateChallengeId(), + nonce: overrides.nonce ?? generateNonce(), + expiresAt: overrides.expiresAt ?? Date.now() + 30_000, + clientId: overrides.clientId ?? generateClientId(), + }; +} + +interface TestSetup { + client: CollabRoomClient; + ws: MockWebSocket; + roomSecret: Uint8Array; + roomVerifier: string; + adminSecret: Uint8Array; + adminVerifier: string; + eventKey: CryptoKey; + presenceKey: CryptoKey; + snapshot: RoomSnapshot; +} + +async function setup(options: { withAdmin?: boolean } = {}): Promise { + const roomSecret = generateRoomSecret(); + const adminSecret = generateAdminSecret(); + const { authKey, eventKey, presenceKey } = await deriveRoomKeys(roomSecret); + const adminKey = options.withAdmin ? await deriveAdminKey(adminSecret) : null; + const roomVerifier = await computeRoomVerifier(authKey, ROOM_ID); + const adminVerifier = adminKey ? await computeAdminVerifier(adminKey, ROOM_ID) : null; + + const snapshot: RoomSnapshot = { + versionId: 'v1', + planMarkdown: '# Plan', + annotations: [], + }; + + // Capture the constructed WebSocket for scripting + let capturedWs: MockWebSocket | null = null; + const WebSocketImpl = class extends MockWebSocket { + constructor(url: string | URL, protocols?: string | string[]) { + super(url, protocols); + capturedWs = this; + } + } as unknown as typeof WebSocket; + + const client = new CollabRoomClient({ + roomId: ROOM_ID, + baseUrl: 'http://localhost:8787', + eventKey, + presenceKey, + adminKey, + roomVerifier, + adminVerifier, + user: USER, + webSocketImpl: WebSocketImpl, + connectTimeoutMs: 2000, + reconnect: { maxAttempts: 0 }, // disable auto-reconnect in tests unless overridden + presenceTtlMs: 50, // short for testing + presenceSweepIntervalMs: 20, + }); + + // Start connect asynchronously so the mock WS gets constructed + const connectPromise = client.connect(); + + // Wait for ws to be captured + await new Promise((r) => { + const check = () => { + if (capturedWs) r(); + else queueMicrotask(check); + }; + check(); + }); + + // Complete auth handshake + const ws = capturedWs!; + // Let the mock ws fire onopen + await new Promise(r => queueMicrotask(r)); + await new Promise(r => queueMicrotask(r)); + + const challenge = makeAuthChallenge(); + ws.peer.sendFromServer(JSON.stringify(challenge)); + + // Client responds with auth.response + const responseMsg = await ws.peer.expectFromClient(); + const response = JSON.parse(responseMsg) as AuthResponse; + expect(response.type).toBe('auth.response'); + expect(response.challengeId).toBe(challenge.challengeId); + + // Server sends auth.accepted + const accepted: AuthAccepted = { + type: 'auth.accepted', + roomStatus: 'active', + seq: 0, + snapshotSeq: 0, + snapshotAvailable: true, + }; + ws.peer.sendFromServer(JSON.stringify(accepted)); + + // Server sends snapshot + const snapshotCiphertext = await encryptSnapshot(eventKey, snapshot); + const snapshotMsg: RoomTransportMessage = { + type: 'room.snapshot', + snapshotSeq: 0, + snapshotCiphertext, + }; + ws.peer.sendFromServer(JSON.stringify(snapshotMsg)); + + await connectPromise; + await new Promise(r => setTimeout(r, 10)); // let snapshot decrypt settle + + return { + client, + ws, + roomSecret, + roomVerifier, + adminSecret, + adminVerifier: adminVerifier ?? '', + eventKey, + presenceKey, + snapshot, + }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('CollabRoomClient — constructor isolates initialSnapshot (P2)', () => { + test('caller mutating initialSnapshot.annotations after construction does not affect internal state', async () => { + const roomSecret = generateRoomSecret(); + const { authKey, eventKey, presenceKey } = await deriveRoomKeys(roomSecret); + const roomVerifier = await computeRoomVerifier(authKey, ROOM_ID); + + const ann: RoomAnnotation = { + id: 'seed-1', blockId: 'b1', startOffset: 0, endOffset: 5, + type: 'COMMENT', originalText: 'original', createdA: 1, + startMeta: { parentTagName: 'p', parentIndex: 0, textOffset: 0 }, + }; + const initialSnapshot = { versionId: 'v1' as const, planMarkdown: '# P', annotations: [ann] }; + + const client = new CollabRoomClient({ + roomId: ROOM_ID, + baseUrl: 'http://localhost:8787', + eventKey, + presenceKey, + adminKey: null, + roomVerifier, + adminVerifier: null, + user: USER, + initialSnapshot, + webSocketImpl: MockWebSocket as unknown as typeof WebSocket, + }); + + // Mutate caller's copy. + ann.originalText = 'MUTATED'; + ann.startMeta!.parentTagName = 'HIJACKED'; + initialSnapshot.annotations.push({ ...ann, id: 'injected' }); + + // Client's internal view is unchanged. + const snap = client.getState(); + expect(snap.annotations.length).toBe(1); + expect(snap.annotations[0].id).toBe('seed-1'); + expect(snap.annotations[0].originalText).toBe('original'); + expect(snap.annotations[0].startMeta!.parentTagName).toBe('p'); + }); +}); + +describe('CollabRoomClient — connect', () => { + test('authenticates and transitions to authenticated', async () => { + const { client } = await setup(); + expect(client.getState().connectionStatus).toBe('authenticated'); + client.disconnect(); + }); + + test('getState includes snapshot plan markdown', async () => { + const { client } = await setup(); + expect(client.getState().planMarkdown).toBe('# Plan'); + client.disconnect(); + }); + + test('hasAdminCapability is true with admin key, false without', async () => { + const withAdmin = await setup({ withAdmin: true }); + expect(withAdmin.client.getState().hasAdminCapability).toBe(true); + withAdmin.client.disconnect(); + + const noAdmin = await setup(); + expect(noAdmin.client.getState().hasAdminCapability).toBe(false); + noAdmin.client.disconnect(); + }); +}); + +describe('CollabRoomClient — concurrent sendOp ordering (P2)', () => { + test('concurrent sendAnnotationAdd + sendAnnotationRemove preserves call order on the wire', async () => { + const { client, ws, eventKey } = await setup(); + + // Sized so the first encrypt (large payload) is slower than the second. + const big: RoomAnnotation = { + id: 'order-add', blockId: 'b1', startOffset: 0, endOffset: 5, + type: 'COMMENT', originalText: 'x'.repeat(50_000), createdA: 1, + }; + + // Fire two calls without awaiting between them — the second starts + // encryption immediately. Without outbound serialization, the small + // remove's ciphertext would finish first and land on the wire BEFORE + // the add. + const p1 = client.sendAnnotationAdd([big]); + const p2 = client.sendAnnotationRemove(['order-add']); + await Promise.all([p1, p2]); + + const first = JSON.parse(await ws.peer.expectFromClient()) as ServerEnvelope; + const second = JSON.parse(await ws.peer.expectFromClient()) as ServerEnvelope; + + const firstOp = await decryptEventPayload(eventKey, first.ciphertext) as { type: string }; + const secondOp = await decryptEventPayload(eventKey, second.ciphertext) as { type: string }; + + expect(firstOp.type).toBe('annotation.add'); + expect(secondOp.type).toBe('annotation.remove'); + client.disconnect(); + }); +}); + +describe('CollabRoomClient — sendAnnotationAdd', () => { + test('produces encrypted envelope on wire', async () => { + const { client, ws, eventKey } = await setup(); + const ann: RoomAnnotation = { + id: 'ann-1', + blockId: 'b1', + startOffset: 0, + endOffset: 5, + type: 'COMMENT', + originalText: 'hello', + createdA: 1234, + text: 'my comment', + }; + await client.sendAnnotationAdd([ann]); + const sent = await ws.peer.expectFromClient(); + const envelope = JSON.parse(sent) as ServerEnvelope; + expect(envelope.channel).toBe('event'); + expect(envelope.clientId).toBe(client.getState().clientId); + + // Decrypt the envelope ciphertext to confirm round-trip + const decrypted = await decryptEventPayload(eventKey, envelope.ciphertext); + expect(decrypted).toEqual({ type: 'annotation.add', annotations: [ann] }); + + client.disconnect(); + }); + + test('does NOT apply to local state until server echo arrives (V1 policy)', async () => { + const { client, ws, eventKey } = await setup(); + const ann: RoomAnnotation = { + id: 'non-opt-1', + blockId: 'b1', startOffset: 0, endOffset: 5, + type: 'COMMENT', originalText: 'x', createdA: 1, + }; + const before = client.getState().annotations.length; + await client.sendAnnotationAdd([ann]); + + // Send-only path — local annotations map must be unchanged pre-echo. + expect(client.getState().annotations.length).toBe(before); + + // Drain and echo back. + const sent = await ws.peer.expectFromClient(); + const envelope = JSON.parse(sent) as ServerEnvelope; + ws.peer.sendFromServer(JSON.stringify({ + type: 'room.event', seq: 1, receivedAt: Date.now(), envelope, + })); + await new Promise(r => setTimeout(r, 10)); + + // Now the server echo applies the op authoritatively. + expect(client.getState().annotations.length).toBe(before + 1); + expect(client.getState().annotations.find(a => a.id === 'non-opt-1')).toBeDefined(); + + client.disconnect(); + }); +}); + +describe('CollabRoomClient — server echo is authoritative', () => { + test('our own echoed event applies exactly once', async () => { + const { client, ws } = await setup(); + const ann: RoomAnnotation = { + id: 'echo-1', + blockId: 'b1', startOffset: 0, endOffset: 5, + type: 'COMMENT', originalText: 'x', createdA: 1, + }; + await client.sendAnnotationAdd([ann]); + + // No optimistic apply — pre-echo count is 0. + expect(client.getState().annotations.length).toBe(0); + + const sent = await ws.peer.expectFromClient(); + const envelope = JSON.parse(sent) as ServerEnvelope; + + ws.peer.sendFromServer(JSON.stringify({ + type: 'room.event', seq: 1, receivedAt: Date.now(), envelope, + })); + await new Promise(r => setTimeout(r, 10)); + + // Echo applied once; seq advanced. + expect(client.getState().annotations.length).toBe(1); + expect(client.getState().annotations[0].id).toBe('echo-1'); + expect(client.getState().seq).toBe(1); + client.disconnect(); + }); + + test('event from another client applies normally', async () => { + const { client, ws, eventKey } = await setup(); + const otherAnn: RoomAnnotation = { + id: 'other-1', + blockId: 'b1', startOffset: 0, endOffset: 5, + type: 'COMMENT', originalText: 'x', createdA: 1, + }; + const ciphertext = await encryptEventOp(eventKey, { type: 'annotation.add', annotations: [otherAnn] }); + const envelope: ServerEnvelope = { + clientId: 'other-client', + opId: 'other-op-id', + channel: 'event', + ciphertext, + }; + const event: RoomTransportMessage = { type: 'room.event', seq: 1, receivedAt: Date.now(), envelope }; + ws.peer.sendFromServer(JSON.stringify(event)); + await new Promise(r => setTimeout(r, 10)); + + expect(client.getState().annotations.length).toBe(1); + expect(client.getState().annotations[0].id).toBe('other-1'); + client.disconnect(); + }); +}); + +describe('CollabRoomClient — presence', () => { + test('decrypts presence with presenceKey', async () => { + const { client, ws, presenceKey } = await setup(); + const presence = { + user: { id: 'u2', name: 'bob', color: '#0f0' }, + cursor: null, + }; + const ciphertext = await encryptPresence(presenceKey, presence); + const envelope: ServerEnvelope = { + clientId: 'other-client', + opId: 'p-op', + channel: 'presence', + ciphertext, + }; + const msg: RoomTransportMessage = { type: 'room.presence', envelope }; + ws.peer.sendFromServer(JSON.stringify(msg)); + await new Promise(r => setTimeout(r, 10)); + + const state = client.getState(); + expect(state.remotePresence['other-client']).toEqual(presence); + client.disconnect(); + }); +}); + +describe('CollabRoomClient — admin', () => { + test('lockRoom without admin rejects', async () => { + const { client } = await setup(); // no admin + await expect(client.lockRoom()).rejects.toThrow(AdminNotAuthorizedError); + client.disconnect(); + }); + + test('lockRoom sends challenge.request, then admin.command with proof, resolves on room.status: locked', async () => { + const { client, ws } = await setup({ withAdmin: true }); + + const lockPromise = client.lockRoom(); + + // Client sends admin.challenge.request + const req = await ws.peer.expectFromClient(); + expect(JSON.parse(req).type).toBe('admin.challenge.request'); + + // Server sends admin.challenge + const adminChallenge: AdminChallenge = { + type: 'admin.challenge', + challengeId: generateChallengeId(), + nonce: generateNonce(), + expiresAt: Date.now() + 30_000, + }; + ws.peer.sendFromServer(JSON.stringify(adminChallenge)); + + // Client sends admin.command + const cmdMsg = await ws.peer.expectFromClient(); + const cmd = JSON.parse(cmdMsg) as AdminCommandEnvelope; + expect(cmd.type).toBe('admin.command'); + expect(cmd.command.type).toBe('room.lock'); + expect(cmd.challengeId).toBe(adminChallenge.challengeId); + expect(cmd.adminProof.length).toBeGreaterThan(0); + + // Server broadcasts room.status: locked + ws.peer.sendFromServer(JSON.stringify({ type: 'room.status', status: 'locked' })); + + await lockPromise; // resolves on observed effect + expect(client.getState().roomStatus).toBe('locked'); + client.disconnect(); + }); + + test('admin command rejects on room.error', async () => { + const { client, ws } = await setup({ withAdmin: true }); + + const lockPromise = client.lockRoom(); + await ws.peer.expectFromClient(); // admin.challenge.request + + const adminChallenge: AdminChallenge = { + type: 'admin.challenge', + challengeId: generateChallengeId(), + nonce: generateNonce(), + expiresAt: Date.now() + 30_000, + }; + ws.peer.sendFromServer(JSON.stringify(adminChallenge)); + await ws.peer.expectFromClient(); // admin.command + + // Server sends room.error instead of room.status + ws.peer.sendFromServer(JSON.stringify({ + type: 'room.error', + code: 'invalid_state', + message: 'Cannot lock', + })); + + await expect(lockPromise).rejects.toThrow(AdminRejectedError); + client.disconnect(); + }); + + test('admin-scoped room.error client_id_mismatch and no_admin_challenge reject pendingAdmin immediately', async () => { + // Regression: previous ADMIN_SCOPED_ERROR_CODES was out of sync with the + // server — it listed 'admin_client_id_mismatch' (never emitted) and was + // missing 'no_admin_challenge'. Either miss caused the admin promise to + // hang until the 5s timeout even though the server had already rejected. + for (const code of ['client_id_mismatch', 'no_admin_challenge']) { + const { client, ws } = await setup({ withAdmin: true }); + + const lockPromise = client.lockRoom(); + await ws.peer.expectFromClient(); // admin.challenge.request + + const start = Date.now(); + ws.peer.sendFromServer(JSON.stringify({ + type: 'room.error', + code, + message: `Server rejected: ${code}`, + })); + + await expect(lockPromise).rejects.toThrow(AdminRejectedError); + const elapsed = Date.now() - start; + // Must reject immediately (within a few ms), not at the 5s admin timeout. + expect(elapsed).toBeLessThan(500); + + client.disconnect(); + } + }); + + test('non-admin room.error (e.g. room_locked from event channel) does NOT reject pending admin', async () => { + // Regression: previously ANY room.error rejected the pending admin + // command. But room.error is also used for event-channel failures + // (room_locked, validation_error). If one of those lands while a lock + // command is in flight, we must NOT cancel the lock — its + // room.status: locked may still be on the way. + const { client, ws } = await setup({ withAdmin: true }); + + const lockPromise = client.lockRoom(); + await ws.peer.expectFromClient(); // admin.challenge.request + const adminChallenge: AdminChallenge = { + type: 'admin.challenge', + challengeId: generateChallengeId(), + nonce: generateNonce(), + expiresAt: Date.now() + 30_000, + }; + ws.peer.sendFromServer(JSON.stringify(adminChallenge)); + await ws.peer.expectFromClient(); // admin.command + + // Event-channel error lands BEFORE the admin command's status broadcast. + // pendingAdmin must stay alive. + ws.peer.sendFromServer(JSON.stringify({ + type: 'room.error', + code: 'room_locked', + message: 'Room is locked — annotation operations are not allowed', + })); + // Give the error a tick to land. + await new Promise(r => setTimeout(r, 20)); + + // The actual admin status broadcast arrives now — lock should resolve. + ws.peer.sendFromServer(JSON.stringify({ type: 'room.status', status: 'locked' })); + await lockPromise; // resolves (does NOT reject) + + // lastError was still set by the event-channel error for UI consumers. + expect(client.getState().lastError?.code).toBe('room_locked'); + client.disconnect(); + }); + + test('lockRoom with finalSnapshot includes ciphertext and atSeq', async () => { + const { client, ws, eventKey } = await setup({ withAdmin: true }); + const finalSnapshot: RoomSnapshot = { + versionId: 'v1', + planMarkdown: '# Final', + annotations: [], + }; + + // New API: snapshot + seq supplied together and must match client.seq. + const lockPromise = client.lockRoom({ finalSnapshot, finalSnapshotSeq: client.getState().seq }); + await ws.peer.expectFromClient(); // challenge request + + const adminChallenge: AdminChallenge = { + type: 'admin.challenge', + challengeId: generateChallengeId(), + nonce: generateNonce(), + expiresAt: Date.now() + 30_000, + }; + ws.peer.sendFromServer(JSON.stringify(adminChallenge)); + + const cmdMsg = await ws.peer.expectFromClient(); + const cmd = JSON.parse(cmdMsg) as AdminCommandEnvelope; + expect(cmd.command.type).toBe('room.lock'); + if (cmd.command.type === 'room.lock') { + expect(cmd.command.finalSnapshotCiphertext).toBeDefined(); + expect(cmd.command.finalSnapshotAtSeq).toBe(0); + } + + ws.peer.sendFromServer(JSON.stringify({ type: 'room.status', status: 'locked' })); + await lockPromise; + client.disconnect(); + }); +}); + +describe('CollabRoomClient — NotConnectedError', () => { + test('sendAnnotationAdd before connect throws', async () => { + const roomSecret = generateRoomSecret(); + const { authKey, eventKey, presenceKey } = await deriveRoomKeys(roomSecret); + const roomVerifier = await computeRoomVerifier(authKey, ROOM_ID); + + const client = new CollabRoomClient({ + roomId: ROOM_ID, + baseUrl: 'http://localhost:8787', + eventKey, + presenceKey, + adminKey: null, + roomVerifier, + adminVerifier: null, + user: USER, + webSocketImpl: MockWebSocket as unknown as typeof WebSocket, + }); + + // Use a valid (non-empty) annotation so the test exercises the + // NotConnectedError path rather than tripping outbound validation's + // empty-array rejection. + const ann: RoomAnnotation = { + id: 'nc-1', + blockId: 'b1', startOffset: 0, endOffset: 5, + type: 'COMMENT', originalText: 'x', createdA: 1, + }; + await expect(client.sendAnnotationAdd([ann])).rejects.toThrow(NotConnectedError); + }); +}); + +describe('CollabRoomClient — initial connect timeout', () => { + test('rejects with ConnectTimeoutError, stays disconnected, does not auto-reconnect', async () => { + const roomSecret = generateRoomSecret(); + const { authKey, eventKey, presenceKey } = await deriveRoomKeys(roomSecret); + const roomVerifier = await computeRoomVerifier(authKey, ROOM_ID); + + const constructed: MockWebSocket[] = []; + const WebSocketImpl = class extends MockWebSocket { + constructor(url: string | URL, protocols?: string | string[]) { + super(url, protocols); + constructed.push(this); + } + } as unknown as typeof WebSocket; + + const client = new CollabRoomClient({ + roomId: ROOM_ID, + baseUrl: 'http://localhost:8787', + eventKey, + presenceKey, + adminKey: null, + roomVerifier, + adminVerifier: null, + user: USER, + webSocketImpl: WebSocketImpl, + connectTimeoutMs: 50, // very short — server never sends challenge + reconnect: { maxAttempts: 5, initialDelayMs: 10, maxDelayMs: 20 }, + }); + + // No server script — let the timeout fire + await expect(client.connect()).rejects.toThrow(ConnectTimeoutError); + + // Wait past any potential reconnect delay + close handling + await new Promise(r => setTimeout(r, 100)); + + expect(client.getState().connectionStatus).toBe('disconnected'); + expect(constructed.length).toBe(1); // no auto-reconnect attempt + }); +}); + +describe('CollabRoomClient — locked room rejects annotation sends', () => { + test('sendAnnotationAdd in locked room throws and does not mutate state', async () => { + const { client, ws } = await setup(); + + // Server transitions room to locked + ws.peer.sendFromServer(JSON.stringify({ type: 'room.status', status: 'locked' })); + await new Promise(r => setTimeout(r, 10)); + expect(client.getState().roomStatus).toBe('locked'); + + const before = client.getState().annotations.length; + const ann: RoomAnnotation = { + id: 'locked-1', + blockId: 'b1', startOffset: 0, endOffset: 5, + type: 'COMMENT', originalText: 'x', createdA: 1, + }; + await expect(client.sendAnnotationAdd([ann])).rejects.toThrow(/locked/); + expect(client.getState().annotations.length).toBe(before); + client.disconnect(); + }); + + test('sendOp rechecks roomStatus AFTER async encryption (race)', async () => { + // The room may be active at the start of sendOp but transition to locked + // during the async encryptEventOp await. Without a post-encrypt recheck, + // we'd send a doomed op and only learn from async lastError — confusing UX. + const { client, ws } = await setup(); + + // Large annotation so encryption takes measurable time; fire lock mid-flight. + const ann: RoomAnnotation = { + id: 'race-1', + blockId: 'b1', startOffset: 0, endOffset: 5, + type: 'COMMENT', originalText: 'x'.repeat(50_000), createdA: 1, + }; + const sentBefore = ws.peer.sent.length; + const sendPromise = client.sendAnnotationAdd([ann]); + + // Deliver `room.status: locked` while encryption is in flight. + ws.peer.sendFromServer(JSON.stringify({ type: 'room.status', status: 'locked' })); + + await expect(sendPromise).rejects.toThrow(/locked/); + // No envelope was sent. + expect(ws.peer.sent.length).toBe(sentBefore); + client.disconnect(); + }); +}); + +describe('CollabRoomClient — every event applies (no echo dedup in V1)', () => { + test('opId collision from another client does not drop the event', async () => { + // V1 removed echo dedup — every room.event applies, including our own + // echoes and any event another client happens to send with the same opId. + // This makes the "malicious participant silences our ops by opId reuse" + // attack inapplicable. + const { client, ws, eventKey } = await setup(); + const ourAnn: RoomAnnotation = { + id: 'ours-1', + blockId: 'b1', startOffset: 0, endOffset: 5, + type: 'COMMENT', originalText: 'x', createdA: 1, + }; + await client.sendAnnotationAdd([ourAnn]); + const sent = await ws.peer.expectFromClient(); + const ourEnvelope = JSON.parse(sent) as ServerEnvelope; + + // Server echoes our op (this applies ours-1). + ws.peer.sendFromServer(JSON.stringify({ + type: 'room.event', seq: 1, receivedAt: Date.now(), envelope: ourEnvelope, + })); + + // Another client sends an op with the SAME opId. Must still apply. + const otherAnn: RoomAnnotation = { + id: 'other-1', + blockId: 'b2', startOffset: 0, endOffset: 5, + type: 'COMMENT', originalText: 'y', createdA: 2, + }; + const spoofCiphertext = await encryptEventOp(eventKey, { + type: 'annotation.add', annotations: [otherAnn], + }); + const spoofEnvelope: ServerEnvelope = { + clientId: 'attacker', + opId: ourEnvelope.opId, // reused + channel: 'event', + ciphertext: spoofCiphertext, + }; + ws.peer.sendFromServer(JSON.stringify({ + type: 'room.event', seq: 2, receivedAt: Date.now(), envelope: spoofEnvelope, + })); + await new Promise(r => setTimeout(r, 10)); + + const ids = client.getState().annotations.map(a => a.id); + expect(ids).toContain('ours-1'); + expect(ids).toContain('other-1'); + client.disconnect(); + }); +}); + +describe('CollabRoomClient — state events fire on status transitions', () => { + test('subscribers receive state for connecting/authenticating/authenticated', async () => { + const roomSecret = generateRoomSecret(); + const { authKey, eventKey, presenceKey } = await deriveRoomKeys(roomSecret); + const roomVerifier = await computeRoomVerifier(authKey, ROOM_ID); + + let capturedWs: MockWebSocket | null = null; + const WebSocketImpl = class extends MockWebSocket { + constructor(url: string | URL, protocols?: string | string[]) { + super(url, protocols); + capturedWs = this; + } + } as unknown as typeof WebSocket; + + const client = new CollabRoomClient({ + roomId: ROOM_ID, + baseUrl: 'http://localhost:8787', + eventKey, + presenceKey, + adminKey: null, + roomVerifier, + adminVerifier: null, + user: USER, + webSocketImpl: WebSocketImpl, + connectTimeoutMs: 2000, + }); + + const statusesFromState: string[] = []; + client.on('state', (s) => { statusesFromState.push(s.connectionStatus); }); + + const connectPromise = client.connect(); + + await new Promise((r) => { + const check = () => (capturedWs ? r() : queueMicrotask(check)); + check(); + }); + await new Promise(r => queueMicrotask(r)); + await new Promise(r => queueMicrotask(r)); + + const ws = capturedWs!; + const challenge = makeAuthChallenge(); + ws.peer.sendFromServer(JSON.stringify(challenge)); + await ws.peer.expectFromClient(); // drain auth.response + + const accepted: AuthAccepted = { + type: 'auth.accepted', + roomStatus: 'active', + seq: 0, + snapshotSeq: 0, + snapshotAvailable: false, + }; + ws.peer.sendFromServer(JSON.stringify(accepted)); + await connectPromise; + + expect(statusesFromState).toContain('connecting'); + expect(statusesFromState).toContain('authenticating'); + expect(statusesFromState).toContain('authenticated'); + client.disconnect(); + }); + + test('authenticated state is never emitted with null roomStatus or stale lastError (P2 ordering)', async () => { + const roomSecret = generateRoomSecret(); + const { authKey, eventKey, presenceKey } = await deriveRoomKeys(roomSecret); + const roomVerifier = await computeRoomVerifier(authKey, ROOM_ID); + + let capturedWs: MockWebSocket | null = null; + const WebSocketImpl = class extends MockWebSocket { + constructor(url: string | URL, protocols?: string | string[]) { + super(url, protocols); + capturedWs = this; + } + } as unknown as typeof WebSocket; + + const client = new CollabRoomClient({ + roomId: ROOM_ID, + baseUrl: 'http://localhost:8787', + eventKey, + presenceKey, + adminKey: null, + roomVerifier, + adminVerifier: null, + user: USER, + webSocketImpl: WebSocketImpl, + connectTimeoutMs: 2000, + }); + + // Record every state snapshot so we can inspect intermediate values. + const snapshots: CollabRoomState[] = []; + client.on('state', (s) => { snapshots.push({ ...s }); }); + + const connectPromise = client.connect(); + await new Promise((r) => { const c = () => capturedWs ? r() : queueMicrotask(c); c(); }); + await new Promise(r => queueMicrotask(r)); + await new Promise(r => queueMicrotask(r)); + + const ws = capturedWs!; + ws.peer.sendFromServer(JSON.stringify(makeAuthChallenge())); + await ws.peer.expectFromClient(); + + ws.peer.sendFromServer(JSON.stringify({ + type: 'auth.accepted', + roomStatus: 'locked', // non-trivial so null would be visibly wrong + seq: 0, snapshotSeq: 0, snapshotAvailable: false, + })); + await connectPromise; + + // Every snapshot with connectionStatus === 'authenticated' must have a + // non-null roomStatus. If setStatus('authenticated') fired before + // roomStatus was assigned, at least one snapshot would violate this. + const authedSnapshots = snapshots.filter(s => s.connectionStatus === 'authenticated'); + expect(authedSnapshots.length).toBeGreaterThan(0); + for (const s of authedSnapshots) { + expect(s.roomStatus).not.toBeNull(); + expect(s.roomStatus).toBe('locked'); + } + + client.disconnect(); + }); +}); + +describe('CollabRoomClient — disconnect', () => { + test('disconnect transitions to closed', async () => { + const { client } = await setup(); + client.disconnect(); + expect(client.getState().connectionStatus).toBe('closed'); + }); + + test('reconnect after disconnect clears userDisconnected', async () => { + const { client } = await setup(); + client.disconnect(); + expect(client.getState().connectionStatus).toBe('closed'); + + // Trying to connect again should not throw immediately (userDisconnected cleared) + // We don't fully run the handshake here — just verify the state reset + const connectPromise = client.connect(); + // Cancel by disconnecting again + client.disconnect(); + await expect(connectPromise).rejects.toThrow(); + }); +}); + +// --------------------------------------------------------------------------- +// Regression tests for P1/P2 race fixes +// --------------------------------------------------------------------------- + +describe('CollabRoomClient — auth.accepted does not advance local seq (P1 replay safety)', () => { + test('accepted.seq > 0 does not update seq until replay snapshot/event applies', async () => { + const roomSecret = generateRoomSecret(); + const { authKey, eventKey, presenceKey } = await deriveRoomKeys(roomSecret); + const roomVerifier = await computeRoomVerifier(authKey, ROOM_ID); + + let capturedWs: MockWebSocket | null = null; + const WebSocketImpl = class extends MockWebSocket { + constructor(url: string | URL, protocols?: string | string[]) { + super(url, protocols); + capturedWs = this; + } + } as unknown as typeof WebSocket; + + const client = new CollabRoomClient({ + roomId: ROOM_ID, + baseUrl: 'http://localhost:8787', + eventKey, + presenceKey, + adminKey: null, + roomVerifier, + adminVerifier: null, + user: USER, + webSocketImpl: WebSocketImpl, + connectTimeoutMs: 2000, + }); + + const connectPromise = client.connect(); + await new Promise((r) => { const c = () => capturedWs ? r() : queueMicrotask(c); c(); }); + await new Promise(r => queueMicrotask(r)); + await new Promise(r => queueMicrotask(r)); + const ws = capturedWs!; + + const challenge = makeAuthChallenge(); + ws.peer.sendFromServer(JSON.stringify(challenge)); + await ws.peer.expectFromClient(); + + // Server claims seq: 42 in auth.accepted (replay incoming) + const accepted: AuthAccepted = { + type: 'auth.accepted', roomStatus: 'active', + seq: 42, snapshotSeq: 40, snapshotAvailable: true, + }; + ws.peer.sendFromServer(JSON.stringify(accepted)); + await connectPromise; + + // Crucially: BEFORE any snapshot/event arrives, seq must still be 0. + expect(client.getState().seq).toBe(0); + + // Now simulate the server delivering the snapshot — only THEN does seq move. + const snapshot: RoomSnapshot = { versionId: 'v1', planMarkdown: '# Plan', annotations: [] }; + const snapshotCiphertext = await encryptSnapshot(eventKey, snapshot); + ws.peer.sendFromServer(JSON.stringify({ + type: 'room.snapshot', snapshotSeq: 40, snapshotCiphertext, + })); + await new Promise(r => setTimeout(r, 10)); + expect(client.getState().seq).toBe(40); + + // And a replayed event after the snapshot advances seq further — proving + // the "last server seq consumed" contract holds on the event path too. + const replayedAnn: RoomAnnotation = { + id: 'replay-1', + blockId: 'b1', startOffset: 0, endOffset: 5, + type: 'COMMENT', originalText: 'x', createdA: 1, + }; + const replayCipher = await encryptEventOp(eventKey, { type: 'annotation.add', annotations: [replayedAnn] }); + ws.peer.sendFromServer(JSON.stringify({ + type: 'room.event', seq: 42, receivedAt: Date.now(), + envelope: { clientId: 'other', opId: 'replay-op-1', channel: 'event', ciphertext: replayCipher }, + })); + await new Promise(r => setTimeout(r, 10)); + expect(client.getState().seq).toBe(42); + expect(client.getState().annotations.map(a => a.id)).toContain('replay-1'); + + client.disconnect(); + }); +}); + +describe('CollabRoomClient — disconnect during pending auth (P2)', () => { + test('ends in closed, not disconnected', async () => { + const roomSecret = generateRoomSecret(); + const { authKey, eventKey, presenceKey } = await deriveRoomKeys(roomSecret); + const roomVerifier = await computeRoomVerifier(authKey, ROOM_ID); + + let capturedWs: MockWebSocket | null = null; + const WebSocketImpl = class extends MockWebSocket { + constructor(url: string | URL, protocols?: string | string[]) { + super(url, protocols); + capturedWs = this; + } + } as unknown as typeof WebSocket; + + const client = new CollabRoomClient({ + roomId: ROOM_ID, + baseUrl: 'http://localhost:8787', + eventKey, + presenceKey, + adminKey: null, + roomVerifier, + adminVerifier: null, + user: USER, + webSocketImpl: WebSocketImpl, + connectTimeoutMs: 5000, + }); + + const connectPromise = client.connect(); + await new Promise((r) => { const c = () => capturedWs ? r() : queueMicrotask(c); c(); }); + await new Promise(r => queueMicrotask(r)); + await new Promise(r => queueMicrotask(r)); + + // Do NOT complete auth. User calls disconnect() while pendingConnect is live. + client.disconnect(); + await expect(connectPromise).rejects.toThrow(); + + // Must be 'closed' (terminal), NOT 'disconnected' — the pending-connect + // close branch must respect userDisconnected. + expect(client.getState().connectionStatus).toBe('closed'); + }); +}); + +describe('CollabRoomClient — sendOp send() throw does not mutate state (P2)', () => { + test('synchronous ws.send throw propagates, leaves local state clean, next send works', async () => { + const { client, ws } = await setup(); + + const sendMock = ws.send.bind(ws); + let shouldThrow = true; + ws.send = (data: string | ArrayBufferLike | Blob | ArrayBufferView) => { + if (shouldThrow) { shouldThrow = false; throw new Error('simulated send failure'); } + return sendMock(data); + }; + + const ann: RoomAnnotation = { + id: 'send-fail-1', + blockId: 'b1', startOffset: 0, endOffset: 5, + type: 'COMMENT', originalText: 'x', createdA: 1, + }; + const before = client.getState().annotations.length; + await expect(client.sendAnnotationAdd([ann])).rejects.toThrow('simulated send failure'); + + // Local annotations untouched (V1 has no optimistic apply anyway). + expect(client.getState().annotations.length).toBe(before); + + // Subsequent successful send + echo works — no lingering state blocks it. + const ann2: RoomAnnotation = { + id: 'send-ok-1', + blockId: 'b1', startOffset: 0, endOffset: 5, + type: 'COMMENT', originalText: 'y', createdA: 2, + }; + await client.sendAnnotationAdd([ann2]); + const sent = await ws.peer.expectFromClient(); + const env = JSON.parse(sent) as ServerEnvelope; + + ws.peer.sendFromServer(JSON.stringify({ + type: 'room.event', seq: 1, receivedAt: Date.now(), envelope: env, + })); + await new Promise(r => setTimeout(r, 10)); + expect(client.getState().annotations.filter(a => a.id === 'send-ok-1').length).toBe(1); + + client.disconnect(); + }); +}); + +describe('CollabRoomClient — lockRoom final-snapshot-seq contract (P1)', () => { + test('rejects when caller-supplied finalSnapshotSeq does not match client.seq (stale snapshot)', async () => { + const { client, ws, eventKey } = await setup({ withAdmin: true }); + + // Advance client.seq to 5 with an incoming event. + const ann: RoomAnnotation = { + id: 'e-1', blockId: 'b1', startOffset: 0, endOffset: 5, + type: 'COMMENT', originalText: 'x', createdA: 1, + }; + const cipher = await encryptEventOp(eventKey, { type: 'annotation.add', annotations: [ann] }); + ws.peer.sendFromServer(JSON.stringify({ + type: 'room.event', seq: 5, receivedAt: Date.now(), + envelope: { clientId: 'other', opId: 'o1', channel: 'event', ciphertext: cipher }, + })); + await waitFor(() => client.getState().seq === 5, 1000); + + const staleSnapshot: RoomSnapshot = { versionId: 'v1', planMarkdown: '# Stale', annotations: [] }; + // Caller built this snapshot at seq 3, but client.seq is now 5 — must reject. + const sentBefore = ws.peer.sent.length; + await expect( + client.lockRoom({ finalSnapshot: staleSnapshot, finalSnapshotSeq: 3 }), + ).rejects.toThrow(InvalidOutboundPayloadError); + // No admin handshake initiated. + expect(ws.peer.sent.length).toBe(sentBefore); + client.disconnect(); + }); + + test('requires finalSnapshot and finalSnapshotSeq to be supplied together', async () => { + const { client } = await setup({ withAdmin: true }); + const snap: RoomSnapshot = { versionId: 'v1', planMarkdown: '# S', annotations: [] }; + await expect(client.lockRoom({ finalSnapshot: snap })).rejects.toThrow(InvalidOutboundPayloadError); + await expect(client.lockRoom({ finalSnapshotSeq: 0 })).rejects.toThrow(InvalidOutboundPayloadError); + client.disconnect(); + }); + + test('rejects when includeFinalSnapshot combined with explicit finalSnapshot', async () => { + const { client } = await setup({ withAdmin: true }); + const snap: RoomSnapshot = { versionId: 'v1', planMarkdown: '# S', annotations: [] }; + await expect( + client.lockRoom({ includeFinalSnapshot: true, finalSnapshot: snap, finalSnapshotSeq: 0 }), + ).rejects.toThrow(InvalidOutboundPayloadError); + client.disconnect(); + }); + + test('includeFinalSnapshot: true on a fresh room (seq=0) skips the final snapshot, not rejected by server', async () => { + // If no events have been consumed, the initial snapshot (seq 0) is already + // the baseline. Sending another snapshot at atSeq=0 would be rejected by + // the server's atSeq > existingSnapshotSeq rule. The client must skip + // the final-snapshot payload entirely in this case. + const { client, ws } = await setup({ withAdmin: true }); + expect(client.getState().seq).toBe(0); + + const lockPromise = client.lockRoom({ includeFinalSnapshot: true }); + await ws.peer.expectFromClient(); // admin.challenge.request + + const adminChallenge: AdminChallenge = { + type: 'admin.challenge', challengeId: generateChallengeId(), + nonce: generateNonce(), expiresAt: Date.now() + 30_000, + }; + ws.peer.sendFromServer(JSON.stringify(adminChallenge)); + + const cmdMsg = await ws.peer.expectFromClient(); + const cmd = JSON.parse(cmdMsg) as AdminCommandEnvelope; + expect(cmd.command.type).toBe('room.lock'); + if (cmd.command.type === 'room.lock') { + expect(cmd.command.finalSnapshotCiphertext).toBeUndefined(); + expect(cmd.command.finalSnapshotAtSeq).toBeUndefined(); + } + + ws.peer.sendFromServer(JSON.stringify({ type: 'room.status', status: 'locked' })); + await lockPromise; + client.disconnect(); + }); + + test('includeFinalSnapshot: true builds snapshot + atSeq atomically from current state', async () => { + const { client, ws, eventKey } = await setup({ withAdmin: true }); + + // Advance client.seq to 5. + const ann: RoomAnnotation = { + id: 'inc-1', blockId: 'b1', startOffset: 0, endOffset: 5, + type: 'COMMENT', originalText: 'x', createdA: 1, + }; + const cipher = await encryptEventOp(eventKey, { type: 'annotation.add', annotations: [ann] }); + ws.peer.sendFromServer(JSON.stringify({ + type: 'room.event', seq: 5, receivedAt: Date.now(), + envelope: { clientId: 'other', opId: 'o1', channel: 'event', ciphertext: cipher }, + })); + await waitFor(() => client.getState().seq === 5, 1000); + + const lockPromise = client.lockRoom({ includeFinalSnapshot: true }); + await ws.peer.expectFromClient(); // admin.challenge.request + + const adminChallenge: AdminChallenge = { + type: 'admin.challenge', challengeId: generateChallengeId(), + nonce: generateNonce(), expiresAt: Date.now() + 30_000, + }; + ws.peer.sendFromServer(JSON.stringify(adminChallenge)); + + const cmdMsg = await ws.peer.expectFromClient(); + const cmd = JSON.parse(cmdMsg) as AdminCommandEnvelope; + if (cmd.command.type === 'room.lock') { + expect(cmd.command.finalSnapshotAtSeq).toBe(5); + expect(cmd.command.finalSnapshotCiphertext).toBeDefined(); + } + + ws.peer.sendFromServer(JSON.stringify({ type: 'room.status', status: 'locked' })); + await lockPromise; + client.disconnect(); + }); +}); + +describe('CollabRoomClient — lockRoom finalSnapshotAtSeq (P2)', () => { + test('captures seq synchronously at lockRoom entry (not after async encrypt)', async () => { + // This test is structural: it proves the captured atSeq matches the value of + // client.seq observed at the exact moment lockRoom() is invoked, regardless + // of how many events race in during encryption. The buggy version (reading + // this.seq AFTER the await) could silently include later events in the label. + // + // We verify determinism by (a) driving seq to a known value before the call, + // (b) immediately invoking lockRoom and capturing that seq snapshot, + // (c) delivering further events during the admin challenge roundtrip, + // (d) asserting the admin.command carries the ORIGINALLY-observed seq. + const { client, ws, eventKey } = await setup({ withAdmin: true }); + + // Prime to seq 5 + const pre1: RoomAnnotation = { + id: 'pre-1', + blockId: 'b1', startOffset: 0, endOffset: 5, + type: 'COMMENT', originalText: 'x', createdA: 1, + }; + const pre1Cipher = await encryptEventOp(eventKey, { type: 'annotation.add', annotations: [pre1] }); + ws.peer.sendFromServer(JSON.stringify({ + type: 'room.event', seq: 5, receivedAt: Date.now(), + envelope: { clientId: 'other', opId: 'o1', channel: 'event', ciphertext: pre1Cipher }, + })); + await waitFor(() => client.getState().seq === 5, 1000); + + const seqAtLockStart = client.getState().seq; + expect(seqAtLockStart).toBe(5); + + // ───────────────────────────────────────────────────────────────────── + // NOTE: This is a regression stress test, not a scheduler proof. + // + // We rely on a large payload to make WebCrypto encrypt of the final + // snapshot empirically slower than the small event decrypt, which reliably + // reproduces the race in practice (verified: the test fails against the + // pre-fix implementation, passes against the fix). It is NOT a formal + // proof that encryptSnapshot is still pending when the seq-7 event applies. + // + // If this ever flakes in CI, the correct fix is NOT to delete or weaken + // the test. The right answer is to introduce a small injectable test seam + // for encryptSnapshot (e.g. an optional `cryptoImpl` on InternalClientOptions, + // or a `mock.module` hook wired before client.ts imports resolve) so the + // encrypt completion can be controlled deterministically via a pending + // promise. The invariant this test pins — finalSnapshotAtSeq must match + // the seq observed at lockRoom entry — is worth preserving. + // ───────────────────────────────────────────────────────────────────── + const bigAnnotations: RoomAnnotation[] = Array.from({ length: 2000 }, (_, i) => ({ + id: `big-${i}`, + blockId: `block-${i}`, + startOffset: 0, + endOffset: 10, + type: 'COMMENT' as const, + originalText: 'x'.repeat(200), + text: 'y'.repeat(200), + createdA: 1, + })); + const finalSnapshot: RoomSnapshot = { + versionId: 'v1', + planMarkdown: 'z'.repeat(100_000), + annotations: bigAnnotations, + }; + + // Precompute the small event's ciphertext so its delivery is instantaneous. + const pre2: RoomAnnotation = { ...pre1, id: 'pre-2' }; + const pre2Cipher = await encryptEventOp(eventKey, { type: 'annotation.add', annotations: [pre2] }); + + // Kick off lockRoom. New API: caller supplies snapshot + the seq it was + // built from, together. The client rejects if client.seq has advanced + // past the supplied finalSnapshotSeq (prevents stale-content labeling). + const lockPromise = client.lockRoom({ finalSnapshot, finalSnapshotSeq: seqAtLockStart }); + + // While encryptSnapshot (~MB) is still in flight, deliver a seq-7 event and + // wait for the client to have fully applied it. The big encrypt won't have + // completed yet, so the buggy code's post-await read of this.seq will see 7. + ws.peer.sendFromServer(JSON.stringify({ + type: 'room.event', seq: 7, receivedAt: Date.now(), + envelope: { clientId: 'other', opId: 'o2', channel: 'event', ciphertext: pre2Cipher }, + })); + await waitFor(() => client.getState().seq === 7, 1000); + + await ws.peer.expectFromClient(); // admin.challenge.request + + const adminChallenge: AdminChallenge = { + type: 'admin.challenge', challengeId: generateChallengeId(), + nonce: generateNonce(), expiresAt: Date.now() + 30_000, + }; + ws.peer.sendFromServer(JSON.stringify(adminChallenge)); + + const cmdMsg = await ws.peer.expectFromClient(); + const cmd = JSON.parse(cmdMsg) as AdminCommandEnvelope; + expect(cmd.command.type).toBe('room.lock'); + if (cmd.command.type === 'room.lock') { + // Must match the seq observed at lockRoom entry, not the now-current seq of 7. + expect(cmd.command.finalSnapshotAtSeq).toBe(seqAtLockStart); + } + + ws.peer.sendFromServer(JSON.stringify({ type: 'room.status', status: 'locked' })); + await lockPromise; + client.disconnect(); + }); +}); + +async function waitFor(cond: () => boolean, timeoutMs: number): Promise { + const start = Date.now(); + while (!cond()) { + if (Date.now() - start > timeoutMs) throw new Error(`waitFor timed out after ${timeoutMs}ms`); + await new Promise(r => setTimeout(r, 5)); + } +} + +describe('CollabRoomClient — auth proof handler handles mid-await rotation (P2)', () => { + test('each socket only ever receives auth.response bound to its own challengeId', async () => { + // This test pins the invariant that `auth.response` is never sent to a + // different socket than the one that issued the challenge. It cannot + // deterministically force the specific race where `computeAuthProof` + // resolves after a socket rotation without patching Web Crypto internals + // (bun's AES-GCM is microtask-fast, rotation happens on a 10ms timer). + // What it DOES pin: the guard's observable property — no cross-talk of + // auth.response challengeIds between rotated sockets. + const constructed: MockWebSocket[] = []; + const WebSocketImpl = class extends MockWebSocket { + constructor(url: string | URL, protocols?: string | string[]) { + super(url, protocols); + constructed.push(this); + } + } as unknown as typeof WebSocket; + + const roomSecret = generateRoomSecret(); + const { authKey, eventKey, presenceKey } = await deriveRoomKeys(roomSecret); + const roomVerifier = await computeRoomVerifier(authKey, ROOM_ID); + + const client = new CollabRoomClient({ + roomId: ROOM_ID, + baseUrl: 'http://localhost:8787', + eventKey, + presenceKey, + adminKey: null, + roomVerifier, + adminVerifier: null, + user: USER, + webSocketImpl: WebSocketImpl, + connectTimeoutMs: 2000, + reconnect: { maxAttempts: 5, initialDelayMs: 10, maxDelayMs: 20 }, + }); + + // First handshake completes. + const connectPromise = client.connect(); + await waitFor(() => constructed.length >= 1, 1000); + await new Promise(r => queueMicrotask(r)); + await new Promise(r => queueMicrotask(r)); + const firstWs = constructed[0]; + + const firstChallengeId = generateChallengeId(); + firstWs.peer.sendFromServer(JSON.stringify(makeAuthChallenge({ challengeId: firstChallengeId }))); + await firstWs.peer.expectFromClient(); + firstWs.peer.sendFromServer(JSON.stringify({ + type: 'auth.accepted', roomStatus: 'active', + seq: 0, snapshotSeq: 0, snapshotAvailable: false, + })); + await connectPromise; + + // Rotate to a second socket via post-auth close. + firstWs.peer.simulateClose(1006, 'network hiccup'); + await waitFor(() => constructed.length >= 2, 2000); + const secondWs = constructed[1]; + await new Promise(r => queueMicrotask(r)); + await new Promise(r => queueMicrotask(r)); + + // Fire challenge + close mid-handshake to force a rotation attempt. + const secondChallengeId = generateChallengeId(); + secondWs.peer.sendFromServer(JSON.stringify(makeAuthChallenge({ challengeId: secondChallengeId }))); + secondWs.peer.simulateClose(1006, 'rotate mid-handshake'); + + await waitFor(() => constructed.length >= 3, 2000); + const thirdWs = constructed[2]; + await new Promise(r => queueMicrotask(r)); + await new Promise(r => queueMicrotask(r)); + await new Promise(r => setTimeout(r, 30)); + + const thirdChallengeId = generateChallengeId(); + thirdWs.peer.sendFromServer(JSON.stringify(makeAuthChallenge({ challengeId: thirdChallengeId }))); + await new Promise(r => setTimeout(r, 30)); + + // Collect auth.response messages per socket and check each one only saw + // responses for its OWN challengeId (or none, if it rotated before resolving). + const responsesFor = (ws: MockWebSocket) => ws.peer.sent + .map(s => { try { return JSON.parse(s) as { type?: string; challengeId?: string }; } catch { return null; } }) + .filter((m): m is { type: string; challengeId: string } => m?.type === 'auth.response'); + + const firstResponses = responsesFor(firstWs); + const secondResponses = responsesFor(secondWs); + const thirdResponses = responsesFor(thirdWs); + + // First socket: only firstChallengeId + expect(firstResponses.every(r => r.challengeId === firstChallengeId)).toBe(true); + // Second socket: only secondChallengeId (if any — proof may have resolved after rotation and been dropped) + expect(secondResponses.every(r => r.challengeId === secondChallengeId)).toBe(true); + // Third socket: only thirdChallengeId — crucially NOT secondChallengeId + expect(thirdResponses.every(r => r.challengeId === thirdChallengeId)).toBe(true); + expect(thirdResponses.some(r => r.challengeId === secondChallengeId)).toBe(false); + expect(thirdResponses.some(r => r.challengeId === firstChallengeId)).toBe(false); + + client.disconnect(); + }); +}); + +// --------------------------------------------------------------------------- +// Regression tests for the latest review round +// --------------------------------------------------------------------------- + +describe('CollabRoomClient — presence shape validation (P2)', () => { + test('malformed presence payload is rejected with presence_malformed error and not stored', async () => { + const { client, ws, presenceKey } = await setup(); + + const errors: { code: string; message: string }[] = []; + client.on('error', (e) => errors.push(e)); + + // Encrypt a payload that decrypts to something that is NOT a valid PresenceState. + // Use the presence crypto path (encryptPresence accepts an object) with a + // garbage object that is valid encrypted JSON but wrong shape. + const malformed = { user: { id: 'x', name: 42 /* not a string */, color: '#f00' }, cursor: null }; + // encryptPresence is typed to take PresenceState; cast to bypass for this adversarial test. + const ciphertext = await encryptPresence(presenceKey, malformed as unknown as import('../types').PresenceState); + ws.peer.sendFromServer(JSON.stringify({ + type: 'room.presence', + envelope: { clientId: 'attacker', opId: 'p1', channel: 'presence', ciphertext }, + })); + await new Promise(r => setTimeout(r, 10)); + + expect(errors.some(e => e.code === 'presence_malformed')).toBe(true); + expect(client.getState().remotePresence.attacker).toBeUndefined(); + // lastError must reflect the malformed presence so hook consumers see it. + expect(client.getState().lastError?.code).toBe('presence_malformed'); + client.disconnect(); + }); + + test('valid presence payload is stored and emitted', async () => { + const { client, ws, presenceKey } = await setup(); + + const valid = { + user: { id: 'u2', name: 'bob', color: '#0f0' }, + cursor: { x: 10, y: 20, coordinateSpace: 'document' as const }, + }; + const ciphertext = await encryptPresence(presenceKey, valid); + ws.peer.sendFromServer(JSON.stringify({ + type: 'room.presence', + envelope: { clientId: 'friend', opId: 'p2', channel: 'presence', ciphertext }, + })); + await new Promise(r => setTimeout(r, 10)); + + expect(client.getState().remotePresence.friend).toEqual(valid); + client.disconnect(); + }); +}); + +describe('CollabRoomClient — snapshot is authoritative baseline (P2)', () => { + test('snapshotSeq overrides this.seq even when snapshotSeq < this.seq', async () => { + const { client, ws, eventKey } = await setup(); + + // Drive seq up with an incoming event. + const ann: RoomAnnotation = { + id: 'e-1', blockId: 'b1', startOffset: 0, endOffset: 5, + type: 'COMMENT', originalText: 'x', createdA: 1, + }; + const cipher = await encryptEventOp(eventKey, { type: 'annotation.add', annotations: [ann] }); + ws.peer.sendFromServer(JSON.stringify({ + type: 'room.event', seq: 10, receivedAt: Date.now(), + envelope: { clientId: 'other', opId: 'o1', channel: 'event', ciphertext: cipher }, + })); + await waitFor(() => client.getState().seq === 10, 1000); + + // Now the server delivers a snapshot with snapshotSeq=5 (LOWER than local seq). + // This simulates the "future claim" fallback where the server's view diverges + // from the client's. The snapshot must replace seq unconditionally so future + // reconnects don't keep sending the stale higher lastSeq. + const snap: RoomSnapshot = { versionId: 'v1', planMarkdown: '# Recovered', annotations: [] }; + const snapCipher = await encryptSnapshot(eventKey, snap); + ws.peer.sendFromServer(JSON.stringify({ + type: 'room.snapshot', snapshotSeq: 5, snapshotCiphertext: snapCipher, + })); + await waitFor(() => client.getState().planMarkdown === '# Recovered', 1000); + + expect(client.getState().seq).toBe(5); + client.disconnect(); + }); +}); + +describe('CollabRoomClient — stale-seq and baseline-invalid guards (P2)', () => { + test('stale event (seq <= this.seq) is dropped — no decrypt, no state change, no event emission', async () => { + const { client, ws, eventKey } = await setup(); + + // Drive seq to 5 with a valid event. + const ann: RoomAnnotation = { + id: 'guard-1', blockId: 'b1', startOffset: 0, endOffset: 5, + type: 'COMMENT', originalText: 'x', createdA: 1, + }; + const cipher = await encryptEventOp(eventKey, { type: 'annotation.add', annotations: [ann] }); + ws.peer.sendFromServer(JSON.stringify({ + type: 'room.event', seq: 5, receivedAt: Date.now(), + envelope: { clientId: 'other', opId: 'o1', channel: 'event', ciphertext: cipher }, + })); + await waitFor(() => client.getState().seq === 5, 1000); + const snapBefore = client.getState(); + expect(snapBefore.annotations.length).toBe(1); + + // Replay the SAME event (seq 5) — must be dropped entirely. + let eventEmissions = 0; + client.on('event', () => { eventEmissions++; }); + ws.peer.sendFromServer(JSON.stringify({ + type: 'room.event', seq: 5, receivedAt: Date.now(), + envelope: { clientId: 'other', opId: 'o1-dup', channel: 'event', ciphertext: cipher }, + })); + await new Promise(r => setTimeout(r, 20)); + expect(eventEmissions).toBe(0); + expect(client.getState().seq).toBe(5); + expect(client.getState().annotations.length).toBe(1); + + client.disconnect(); + }); + + test('malformed snapshot blocks subsequent event application (baseline invalid)', async () => { + const { client, ws, eventKey } = await setup(); + + // Deliver a malformed snapshot. + const badSnap = { versionId: 'v99', planMarkdown: 'bad', annotations: [] }; + const badSnapCipher = await encryptSnapshot(eventKey, badSnap as unknown as RoomSnapshot); + ws.peer.sendFromServer(JSON.stringify({ + type: 'room.snapshot', snapshotSeq: 10, snapshotCiphertext: badSnapCipher, + })); + await waitFor(() => client.getState().lastError?.code === 'snapshot_malformed', 1000); + const annsBefore = client.getState().annotations.length; + + // Now a valid event at seq 11 — must NOT apply (baseline is invalid). + const ann: RoomAnnotation = { + id: 'post-bad-snap', blockId: 'b1', startOffset: 0, endOffset: 5, + type: 'COMMENT', originalText: 'y', createdA: 1, + }; + const cipher = await encryptEventOp(eventKey, { type: 'annotation.add', annotations: [ann] }); + ws.peer.sendFromServer(JSON.stringify({ + type: 'room.event', seq: 11, receivedAt: Date.now(), + envelope: { clientId: 'other', opId: 'o2', channel: 'event', ciphertext: cipher }, + })); + await waitFor(() => client.getState().seq === 11, 1000); + // seq advanced for forward-progress, annotations untouched. + expect(client.getState().annotations.length).toBe(annsBefore); + expect(client.getState().annotations.find(a => a.id === 'post-bad-snap')).toBeUndefined(); + + // Delivering a VALID snapshot clears baseline-invalid; subsequent events apply. + const goodSnap: RoomSnapshot = { + versionId: 'v1', + planMarkdown: '# Recovered', + annotations: [], + }; + const goodSnapCipher = await encryptSnapshot(eventKey, goodSnap); + ws.peer.sendFromServer(JSON.stringify({ + type: 'room.snapshot', snapshotSeq: 20, snapshotCiphertext: goodSnapCipher, + })); + await waitFor(() => client.getState().planMarkdown === '# Recovered', 1000); + + const ann2: RoomAnnotation = { + id: 'post-good-snap', blockId: 'b1', startOffset: 0, endOffset: 5, + type: 'COMMENT', originalText: 'z', createdA: 1, + }; + const cipher2 = await encryptEventOp(eventKey, { type: 'annotation.add', annotations: [ann2] }); + ws.peer.sendFromServer(JSON.stringify({ + type: 'room.event', seq: 21, receivedAt: Date.now(), + envelope: { clientId: 'other', opId: 'o3', channel: 'event', ciphertext: cipher2 }, + })); + await waitFor(() => + client.getState().annotations.some(a => a.id === 'post-good-snap'), + 1000, + ); + + client.disconnect(); + }); + + test('baselineInvalid persists across reconnect: lastSeq omitted, events blocked until valid snapshot', async () => { + // 1. Authenticate socket A and consume a valid event to drive seq to 1. + const roomSecret = generateRoomSecret(); + const { authKey, eventKey, presenceKey } = await deriveRoomKeys(roomSecret); + const roomVerifier = await computeRoomVerifier(authKey, ROOM_ID); + + const constructed: MockWebSocket[] = []; + const WebSocketImpl = class extends MockWebSocket { + constructor(url: string | URL, protocols?: string | string[]) { + super(url, protocols); + constructed.push(this); + } + } as unknown as typeof WebSocket; + + const client = new CollabRoomClient({ + roomId: ROOM_ID, + baseUrl: 'http://localhost:8787', + eventKey, + presenceKey, + adminKey: null, + roomVerifier, + adminVerifier: null, + user: USER, + webSocketImpl: WebSocketImpl, + connectTimeoutMs: 2000, + reconnect: { maxAttempts: 5, initialDelayMs: 10, maxDelayMs: 20 }, + }); + + const connectPromise = client.connect(); + await waitFor(() => constructed.length >= 1, 1000); + await new Promise(r => queueMicrotask(r)); + await new Promise(r => queueMicrotask(r)); + const wsA = constructed[0]; + wsA.peer.sendFromServer(JSON.stringify(makeAuthChallenge())); + await wsA.peer.expectFromClient(); + wsA.peer.sendFromServer(JSON.stringify({ + type: 'auth.accepted', roomStatus: 'active', + seq: 0, snapshotSeq: 0, snapshotAvailable: false, + })); + await connectPromise; + + // Drive seq to 10 with a malformed snapshot (baselineInvalid = true). + const badSnap = { versionId: 'v99', planMarkdown: 'bad', annotations: [] }; + const badSnapCipher = await encryptSnapshot(eventKey, badSnap as unknown as RoomSnapshot); + wsA.peer.sendFromServer(JSON.stringify({ + type: 'room.snapshot', snapshotSeq: 10, snapshotCiphertext: badSnapCipher, + })); + await waitFor(() => client.getState().lastError?.code === 'snapshot_malformed', 1000); + + // Deliver a post-snapshot event; it must NOT apply but SEQ must advance. + const blockedAnn: RoomAnnotation = { + id: 'blocked', blockId: 'b1', startOffset: 0, endOffset: 5, + type: 'COMMENT', originalText: 'should-not-show', createdA: 1, + }; + const blockedCipher = await encryptEventOp(eventKey, { type: 'annotation.add', annotations: [blockedAnn] }); + wsA.peer.sendFromServer(JSON.stringify({ + type: 'room.event', seq: 11, receivedAt: Date.now(), + envelope: { clientId: 'other', opId: 'b1', channel: 'event', ciphertext: blockedCipher }, + })); + await waitFor(() => client.getState().seq === 11, 1000); + expect(client.getState().annotations.find(a => a.id === 'blocked')).toBeUndefined(); + + // 2. Force a reconnect. The new socket's auth.response MUST omit lastSeq + // because baselineInvalid is true — otherwise the server might skip + // snapshot replay and leave us stale forever. + wsA.peer.simulateClose(1006, 'reconnect drill'); + await waitFor(() => constructed.length >= 2, 2000); + const wsB = constructed[1]; + await new Promise(r => queueMicrotask(r)); + await new Promise(r => queueMicrotask(r)); + + wsB.peer.sendFromServer(JSON.stringify(makeAuthChallenge())); + const authResponseMsg = await wsB.peer.expectFromClient(); + const authResp = JSON.parse(authResponseMsg) as { lastSeq?: number }; + expect(authResp.lastSeq).toBeUndefined(); + + wsB.peer.sendFromServer(JSON.stringify({ + type: 'auth.accepted', roomStatus: 'active', + seq: 11, snapshotSeq: 11, snapshotAvailable: true, + })); + // auth.accepted alone must NOT clear baselineInvalid — only a valid + // snapshot apply does. Prove by delivering another event BEFORE the + // snapshot: it must still not apply. + await new Promise(r => setTimeout(r, 20)); + const stillBlockedAnn: RoomAnnotation = { + id: 'still-blocked', blockId: 'b1', startOffset: 0, endOffset: 5, + type: 'COMMENT', originalText: 'still-should-not-show', createdA: 1, + }; + const stillBlockedCipher = await encryptEventOp(eventKey, { type: 'annotation.add', annotations: [stillBlockedAnn] }); + wsB.peer.sendFromServer(JSON.stringify({ + type: 'room.event', seq: 12, receivedAt: Date.now(), + envelope: { clientId: 'other', opId: 'b2', channel: 'event', ciphertext: stillBlockedCipher }, + })); + await waitFor(() => client.getState().seq === 12, 1000); + expect(client.getState().annotations.find(a => a.id === 'still-blocked')).toBeUndefined(); + + // 3. Valid snapshot arrives and clears baselineInvalid — subsequent + // events apply. + const goodSnap: RoomSnapshot = { + versionId: 'v1', + planMarkdown: '# Recovered', + annotations: [], + }; + const goodSnapCipher = await encryptSnapshot(eventKey, goodSnap); + wsB.peer.sendFromServer(JSON.stringify({ + type: 'room.snapshot', snapshotSeq: 20, snapshotCiphertext: goodSnapCipher, + })); + await waitFor(() => client.getState().planMarkdown === '# Recovered', 1000); + + const recoveredAnn: RoomAnnotation = { + id: 'recovered', blockId: 'b1', startOffset: 0, endOffset: 5, + type: 'COMMENT', originalText: 'ok', createdA: 1, + }; + const recoveredCipher = await encryptEventOp(eventKey, { type: 'annotation.add', annotations: [recoveredAnn] }); + wsB.peer.sendFromServer(JSON.stringify({ + type: 'room.event', seq: 21, receivedAt: Date.now(), + envelope: { clientId: 'other', opId: 'r1', channel: 'event', ciphertext: recoveredCipher }, + })); + await waitFor(() => + client.getState().annotations.some(a => a.id === 'recovered'), + 1000, + ); + + client.disconnect(); + }); + + test('reducer-rejected update (merged-annotation invalid) advances seq without mutating state or emitting event', async () => { + const { client, ws, eventKey } = await setup(); + + // Seed a COMMENT with a valid non-empty blockId. + const seed: RoomAnnotation = { + id: 'reducer-seed', blockId: 'b1', startOffset: 0, endOffset: 5, + type: 'COMMENT', originalText: 'x', createdA: 1, + }; + const seedCipher = await encryptEventOp(eventKey, { type: 'annotation.add', annotations: [seed] }); + ws.peer.sendFromServer(JSON.stringify({ + type: 'room.event', seq: 1, receivedAt: Date.now(), + envelope: { clientId: 'other', opId: 'seed', channel: 'event', ciphertext: seedCipher }, + })); + await waitFor(() => client.getState().annotations.length === 1, 1000); + const stored = client.getState().annotations[0]; + + // Patch passes op-level validation (blockId is a string per field rules) + // but the merged final annotation violates the cross-field invariant + // (COMMENT must have non-empty blockId). The reducer should reject. + let eventEmissions = 0; + client.on('event', () => { eventEmissions++; }); + + const badPatch = { type: 'annotation.update', id: 'reducer-seed', patch: { blockId: '' } }; + const badCipher = await encryptEventOp(eventKey, badPatch as unknown as import('../types').RoomEventClientOp); + ws.peer.sendFromServer(JSON.stringify({ + type: 'room.event', seq: 2, receivedAt: Date.now(), + envelope: { clientId: 'other', opId: 'bad-patch', channel: 'event', ciphertext: badCipher }, + })); + await waitFor(() => client.getState().seq === 2, 1000); + + // seq advanced for forward-progress, annotation untouched, lastError set, + // no `event` emitted. + const after = client.getState().annotations[0]; + expect(after.blockId).toBe(stored.blockId); + expect(client.getState().lastError?.code).toBe('event_rejected_by_reducer'); + expect(eventEmissions).toBe(0); + + client.disconnect(); + }); + + test('outbound event payload is cloned before encryption — caller mutations cannot alter the wire op', async () => { + const { client, ws, eventKey } = await setup(); + + const ann: RoomAnnotation = { + id: 'clone-1', blockId: 'b1', startOffset: 0, endOffset: 5, + type: 'COMMENT', originalText: 'original', createdA: 1, + }; + const anns = [ann]; + + // Synchronously kick off the send, THEN mutate the caller's arrays before + // the encryption queue has had a chance to run. + const sendPromise = client.sendAnnotationAdd(anns); + ann.originalText = 'MUTATED'; + anns.push({ ...ann, id: 'injected' }); + await sendPromise; + + const sent = JSON.parse(await ws.peer.expectFromClient()) as ServerEnvelope; + const decrypted = await decryptEventPayload(eventKey, sent.ciphertext) as { type: string; annotations: RoomAnnotation[] }; + expect(decrypted.type).toBe('annotation.add'); + expect(decrypted.annotations).toHaveLength(1); // injected push did NOT affect wire + expect(decrypted.annotations[0].id).toBe('clone-1'); + expect(decrypted.annotations[0].originalText).toBe('original'); + + client.disconnect(); + }); +}); + +describe('CollabRoomClient — event/snapshot shape validation (P2)', () => { + test('malformed RoomClientOp is rejected via event_malformed error, does not enter state', async () => { + const { client, ws, eventKey } = await setup(); + + const errors: { code: string; message: string }[] = []; + client.on('error', (e) => errors.push(e)); + + // A participant holds the eventKey but ships a structurally bad annotation. + const malformed = { + type: 'annotation.add', + annotations: [{ id: null, blockId: 'b', type: null, originalText: null, startOffset: 0, endOffset: 0, createdA: 0 }], + }; + const ciphertext = await encryptEventOp(eventKey, malformed as unknown as import('../types').RoomEventClientOp); + ws.peer.sendFromServer(JSON.stringify({ + type: 'room.event', seq: 1, receivedAt: Date.now(), + envelope: { clientId: 'attacker', opId: 'o1', channel: 'event', ciphertext }, + })); + await new Promise(r => setTimeout(r, 20)); + + expect(errors.some(e => e.code === 'event_malformed')).toBe(true); + expect(client.getState().annotations.length).toBe(0); + // V1 forward-progress: seq MUST advance even though the event was rejected. + // If it didn't, reconnect lastSeq would keep replaying the malformed event + // forever and block every valid event behind it. + expect(client.getState().seq).toBe(1); + // Event errors must also surface via state.lastError for hook consumers. + expect(client.getState().lastError?.code).toBe('event_malformed'); + client.disconnect(); + }); + + test('inbound presence.update on event channel is rejected (event/presence split)', async () => { + const { client, ws, eventKey } = await setup(); + const errors: { code: string; message: string }[] = []; + client.on('error', (e) => errors.push(e)); + + // A participant with the eventKey encrypts a presence.update as if it + // were an event-channel op. The narrow event validator must reject it so + // presence traffic cannot pollute the durable event log. + const presenceOnEvent = { + type: 'presence.update', + presence: { + user: { id: 'u', name: 'x', color: '#f00' }, + cursor: null, + }, + }; + const ciphertext = await encryptEventOp(eventKey, presenceOnEvent as unknown as import('../types').RoomEventClientOp); + ws.peer.sendFromServer(JSON.stringify({ + type: 'room.event', seq: 1, receivedAt: Date.now(), + envelope: { clientId: 'attacker', opId: 'sneaky', channel: 'event', ciphertext }, + })); + await waitFor(() => client.getState().seq === 1, 1000); + + expect(errors.some(e => e.code === 'event_malformed')).toBe(true); + expect(client.getState().annotations.length).toBe(0); + client.disconnect(); + }); + + test('malformed event at seq=N does not block valid events at seq>N (forward-progress)', async () => { + const { client, ws, eventKey } = await setup(); + + // Ship a malformed event at seq=1. + const malformed = { + type: 'annotation.add', + annotations: [{ id: null, blockId: 'b', type: null, originalText: null, startOffset: 0, endOffset: 0, createdA: 0 }], + }; + const badCipher = await encryptEventOp(eventKey, malformed as unknown as import('../types').RoomEventClientOp); + ws.peer.sendFromServer(JSON.stringify({ + type: 'room.event', seq: 1, receivedAt: Date.now(), + envelope: { clientId: 'attacker', opId: 'bad', channel: 'event', ciphertext: badCipher }, + })); + await waitFor(() => client.getState().seq === 1, 1000); + + // Ship a valid event at seq=2. It must apply — replay-stream is not poisoned. + const goodAnn: RoomAnnotation = { + id: 'after-bad', + blockId: 'b1', startOffset: 0, endOffset: 5, + type: 'COMMENT', originalText: 'y', createdA: 2, + }; + const goodCipher = await encryptEventOp(eventKey, { type: 'annotation.add', annotations: [goodAnn] }); + ws.peer.sendFromServer(JSON.stringify({ + type: 'room.event', seq: 2, receivedAt: Date.now(), + envelope: { clientId: 'friend', opId: 'good', channel: 'event', ciphertext: goodCipher }, + })); + await waitFor(() => client.getState().seq === 2, 1000); + + const ids = client.getState().annotations.map(a => a.id); + expect(ids).toContain('after-bad'); + client.disconnect(); + }); + + test('malformed annotation.update patch is rejected (does not corrupt existing annotations)', async () => { + const { client, ws, eventKey } = await setup(); + + // Seed a real annotation first. + const ann: RoomAnnotation = { + id: 'real-1', + blockId: 'b1', startOffset: 0, endOffset: 5, + type: 'COMMENT', originalText: 'x', createdA: 1, + }; + const addCipher = await encryptEventOp(eventKey, { type: 'annotation.add', annotations: [ann] }); + ws.peer.sendFromServer(JSON.stringify({ + type: 'room.event', seq: 1, receivedAt: Date.now(), + envelope: { clientId: 'other', opId: 'a1', channel: 'event', ciphertext: addCipher }, + })); + await waitFor(() => client.getState().annotations.length === 1, 1000); + + // Malicious update with patch that tries to set type=null (not a valid enum). + const malformedPatch = { + type: 'annotation.update', + id: 'real-1', + patch: { type: null, originalText: 42 }, + }; + const ciphertext = await encryptEventOp(eventKey, malformedPatch as unknown as import('../types').RoomEventClientOp); + ws.peer.sendFromServer(JSON.stringify({ + type: 'room.event', seq: 2, receivedAt: Date.now(), + envelope: { clientId: 'attacker', opId: 'u1', channel: 'event', ciphertext }, + })); + await new Promise(r => setTimeout(r, 20)); + + // The existing annotation must be untouched. + const stillThere = client.getState().annotations.find(a => a.id === 'real-1'); + expect(stillThere).toBeDefined(); + expect(stillThere!.type).toBe('COMMENT'); + expect(stillThere!.originalText).toBe('x'); + client.disconnect(); + }); + + test('annotation.update with mismatched id in patch is rejected (identity-mutation attack)', async () => { + const { client, ws, eventKey } = await setup(); + + // Seed a real annotation via a valid event. + const ann: RoomAnnotation = { + id: 'stable-id', + blockId: 'b1', startOffset: 0, endOffset: 5, + type: 'COMMENT', originalText: 'x', createdA: 1, + }; + const addCipher = await encryptEventOp(eventKey, { type: 'annotation.add', annotations: [ann] }); + ws.peer.sendFromServer(JSON.stringify({ + type: 'room.event', seq: 1, receivedAt: Date.now(), + envelope: { clientId: 'other', opId: 'a1', channel: 'event', ciphertext: addCipher }, + })); + await waitFor(() => client.getState().annotations.length === 1, 1000); + + // Malicious update: patch tries to hijack the id to a new value. + // isRoomClientOp must reject this via isRoomAnnotationPatch — event_malformed emitted. + const errors: { code: string; message: string }[] = []; + client.on('error', (e) => errors.push(e)); + const hijackPatch = { + type: 'annotation.update', + id: 'stable-id', + patch: { id: 'hijacked-id', text: 'pwned' }, + }; + const hijackCipher = await encryptEventOp( + eventKey, + hijackPatch as unknown as import('../types').RoomEventClientOp, + ); + ws.peer.sendFromServer(JSON.stringify({ + type: 'room.event', seq: 2, receivedAt: Date.now(), + envelope: { clientId: 'attacker', opId: 'hijack', channel: 'event', ciphertext: hijackCipher }, + })); + await waitFor(() => client.getState().seq === 2, 1000); + + expect(errors.some(e => e.code === 'event_malformed')).toBe(true); + const ids = client.getState().annotations.map(a => a.id); + expect(ids).toContain('stable-id'); + expect(ids).not.toContain('hijacked-id'); + // Also confirm no renaming happened under the hood — the annotation at key 'stable-id' is intact. + const stored = client.getState().annotations.find(a => a.id === 'stable-id')!; + expect(stored.originalText).toBe('x'); + expect(stored.text).toBeUndefined(); // not patched with 'pwned' + client.disconnect(); + }); + + test('malformed snapshot is rejected via snapshot_malformed error, does not corrupt state', async () => { + const { client, ws, eventKey } = await setup(); + + const errors: { code: string; message: string }[] = []; + client.on('error', (e) => errors.push(e)); + + // First seed a real annotation via event so we can assert state is unchanged. + const ann: RoomAnnotation = { + id: 'keep-me', + blockId: 'b1', startOffset: 0, endOffset: 5, + type: 'COMMENT', originalText: 'x', createdA: 1, + }; + const addCipher = await encryptEventOp(eventKey, { type: 'annotation.add', annotations: [ann] }); + ws.peer.sendFromServer(JSON.stringify({ + type: 'room.event', seq: 1, receivedAt: Date.now(), + envelope: { clientId: 'other', opId: 'a1', channel: 'event', ciphertext: addCipher }, + })); + await waitFor(() => client.getState().annotations.length === 1, 1000); + + // Now a malformed snapshot: wrong versionId. + const malformedSnap = { versionId: 'v99', planMarkdown: 'corrupt', annotations: [] }; + const snapCipher = await encryptSnapshot(eventKey, malformedSnap as unknown as RoomSnapshot); + ws.peer.sendFromServer(JSON.stringify({ + type: 'room.snapshot', snapshotSeq: 99, snapshotCiphertext: snapCipher, + })); + await new Promise(r => setTimeout(r, 20)); + + expect(errors.some(e => e.code === 'snapshot_malformed')).toBe(true); + // Existing state preserved + expect(client.getState().annotations.length).toBe(1); + expect(client.getState().planMarkdown).toBe('# Plan'); // from setup() + // seq not advanced by rejected snapshot + expect(client.getState().seq).toBe(1); + // Snapshot errors must surface to `state` subscribers via lastError so + // hook consumers (which only subscribe to state) can react. + expect(client.getState().lastError?.code).toBe('snapshot_malformed'); + client.disconnect(); + }); +}); + +describe('CollabRoomClient — terminal close maps roomStatus (P2)', () => { + test('close 4006 "Room deleted" sets roomStatus = deleted even if the broadcast was missed', async () => { + const { client, ws } = await setup(); + // Client missed the room.status: deleted broadcast (never sent in this test). + // The terminal close is the ONLY signal that the room is gone. + expect(client.getState().roomStatus).toBe('active'); + ws.peer.simulateClose(4006, 'Room deleted'); + await new Promise(r => setTimeout(r, 10)); + expect(client.getState().roomStatus).toBe('deleted'); + expect(client.getState().connectionStatus).toBe('closed'); + }); + + test('close 4006 "Room expired" sets roomStatus = expired', async () => { + const { client, ws } = await setup(); + ws.peer.simulateClose(4006, 'Room expired'); + await new Promise(r => setTimeout(r, 10)); + expect(client.getState().roomStatus).toBe('expired'); + expect(client.getState().connectionStatus).toBe('closed'); + }); + + test('generic "Room unavailable" close does NOT map to a specific terminal status', async () => { + const { client, ws } = await setup(); + ws.peer.simulateClose(4006, 'Room unavailable'); + await new Promise(r => setTimeout(r, 10)); + // roomStatus is left as-is (was 'active' from setup). The client is in a + // terminal 'closed' connection state but no specific terminal room status. + expect(client.getState().roomStatus).toBe('active'); + expect(client.getState().connectionStatus).toBe('closed'); + }); +}); + +describe('CollabRoomClient — deleteRoom socket-close semantics (P2)', () => { + test('deleteRoom rejects with AdminInterruptedError on network drop (not a delete close)', async () => { + const { client, ws } = await setup({ withAdmin: true }); + + const deletePromise = client.deleteRoom(); + await ws.peer.expectFromClient(); // admin.challenge.request + + const adminChallenge: AdminChallenge = { + type: 'admin.challenge', challengeId: generateChallengeId(), + nonce: generateNonce(), expiresAt: Date.now() + 30_000, + }; + ws.peer.sendFromServer(JSON.stringify(adminChallenge)); + await ws.peer.expectFromClient(); // admin.command + + // Simulate a network drop — NOT the server's delete close. + // Code 1006, no reason. Must NOT be treated as successful delete. + ws.peer.simulateClose(1006, ''); + + await expect(deletePromise).rejects.toThrow(/interrupted/i); + }); + + test('deleteRoom resolves on server delete close (code 4006, reason "Room deleted")', async () => { + const { client, ws } = await setup({ withAdmin: true }); + + const deletePromise = client.deleteRoom(); + await ws.peer.expectFromClient(); + const adminChallenge: AdminChallenge = { + type: 'admin.challenge', challengeId: generateChallengeId(), + nonce: generateNonce(), expiresAt: Date.now() + 30_000, + }; + ws.peer.sendFromServer(JSON.stringify(adminChallenge)); + await ws.peer.expectFromClient(); + + // Server's successful-delete close. + ws.peer.simulateClose(4006, 'Room deleted'); + + await deletePromise; // resolves + }); + + test('deleteRoom resolves on room.status: deleted broadcast even before close', async () => { + const { client, ws } = await setup({ withAdmin: true }); + + const deletePromise = client.deleteRoom(); + await ws.peer.expectFromClient(); + const adminChallenge: AdminChallenge = { + type: 'admin.challenge', challengeId: generateChallengeId(), + nonce: generateNonce(), expiresAt: Date.now() + 30_000, + }; + ws.peer.sendFromServer(JSON.stringify(adminChallenge)); + await ws.peer.expectFromClient(); + + // Server broadcasts the status change; this alone resolves deleteRoom. + ws.peer.sendFromServer(JSON.stringify({ type: 'room.status', status: 'deleted' })); + await deletePromise; + }); +}); + +describe('CollabRoomClient — stale socket handlers do not clobber current socket (P3)', () => { + // Helper: create a client + constructed-sockets array, with configurable reconnect. + async function makeClient(opts: { asyncClose?: boolean; reconnect?: { maxAttempts: number; initialDelayMs?: number; maxDelayMs?: number } } = {}) { + const prevAsyncMode = MockWebSocket.asyncCloseMode; + MockWebSocket.asyncCloseMode = opts.asyncClose ?? false; + const constructed: MockWebSocket[] = []; + const WebSocketImpl = class extends MockWebSocket { + constructor(url: string | URL, protocols?: string | string[]) { + super(url, protocols); + constructed.push(this); + } + } as unknown as typeof WebSocket; + + const roomSecret = generateRoomSecret(); + const { authKey, eventKey, presenceKey } = await deriveRoomKeys(roomSecret); + const roomVerifier = await computeRoomVerifier(authKey, ROOM_ID); + + const client = new CollabRoomClient({ + roomId: ROOM_ID, + baseUrl: 'http://localhost:8787', + eventKey, + presenceKey, + adminKey: null, + roomVerifier, + adminVerifier: null, + user: USER, + webSocketImpl: WebSocketImpl, + connectTimeoutMs: 5000, + reconnect: opts.reconnect ?? { maxAttempts: 0 }, + }); + return { + client, constructed, + restore: () => { MockWebSocket.asyncCloseMode = prevAsyncMode; }, + }; + } + + async function completeAuth(ws: MockWebSocket, connectPromise: Promise) { + await new Promise(r => queueMicrotask(r)); + await new Promise(r => queueMicrotask(r)); + ws.peer.sendFromServer(JSON.stringify(makeAuthChallenge())); + await ws.peer.expectFromClient(); + ws.peer.sendFromServer(JSON.stringify({ + type: 'auth.accepted', roomStatus: 'active', + seq: 0, snapshotSeq: 0, snapshotAvailable: false, + })); + await connectPromise; + } + + test('auto-reconnect + explicit connect() during B: B is retired, C completes auth, late B events are ignored', async () => { + // Exact original-race reproduction — NO intervening disconnect(): + // 1. Authenticate socket A. + // 2. Server closes A; auto-reconnect opens socket B. + // 3. Caller invokes connect() while B is still in flight. + // 4. connect() must rotate: retire B, open socket C. + // 5. Fire late onclose / onmessage on B — handlers must no-op, C must not be clobbered. + // 6. Complete auth on C — must succeed. + const { client, constructed, restore } = await makeClient({ + reconnect: { maxAttempts: 5, initialDelayMs: 10, maxDelayMs: 20 }, + }); + + try { + // 1) Authenticate on socket A. + const firstConnect = client.connect(); + await waitFor(() => constructed.length >= 1, 1000); + const socketA = constructed[0]; + await completeAuth(socketA, firstConnect); + expect(client.getState().connectionStatus).toBe('authenticated'); + + // 2) Server closes A. Auto-reconnect opens socket B (post-auth, so + // pendingConnect is null and handleSocketClose schedules a reconnect). + socketA.peer.simulateClose(1006, 'network flap'); + await waitFor(() => constructed.length >= 2, 1000); + const socketB = constructed[1]; + // At this point B is the current socket; its handlers are bound; status + // should be reconnecting / connecting. pendingConnect is null because + // this is auto-reconnect, not initial-connect or explicit connect(). + + // 3) Caller invokes connect() DIRECTLY while B is live (no disconnect()). + // This must open socket C and retire socket B. + const rotationConnect = client.connect(); + await waitFor(() => constructed.length >= 3, 1000); + const socketC = constructed[2]; + expect(socketC).not.toBe(socketB); + + // B should be closed by retireSocket (the implementation calls + // ws.close() when it adds a socket to retiredSockets). + expect(socketB.readyState).toBe(socketB.CLOSED); + + // 4) Fire a late onclose directly on the retired B — the handler + // (bound to B when it was constructed) must short-circuit on the + // retiredSockets check. If the guard is broken, this would re-enter + // handleSocketClose and null out this.ws, orphaning C. + socketB.onclose?.(new CloseEvent('close', { code: 1006, reason: 'late B straggler', wasClean: false })); + // Also fire a late onmessage on B — must be gated out. + socketB.onmessage?.(new MessageEvent('message', { data: '{"type":"room.error","code":"stale","message":"from B"}' })); + + // 5) Complete auth on socket C — if B had clobbered this.ws, this would hang. + await completeAuth(socketC, rotationConnect); + expect(client.getState().connectionStatus).toBe('authenticated'); + + client.disconnect(); + } finally { + restore(); + } + }); + + test('async-close mock: intentional disconnect rejects pendingConnect synchronously (does not wait for deferred onclose)', async () => { + // In real browsers ws.close() returns immediately and onclose fires in a + // later microtask. Previously the client relied on the synchronous onclose + // from closeSocket() to reject pendingConnect/pendingAdmin. Under true + // async-close semantics, that produced a hang until timeout. This test + // pins the fix: disconnect() rejects pendingConnect synchronously. + const { client, constructed, restore } = await makeClient({ asyncClose: true }); + + try { + const connectPromise = client.connect(); + await waitFor(() => constructed.length === 1, 1000); + await new Promise(r => queueMicrotask(r)); + await new Promise(r => queueMicrotask(r)); + + // Do NOT complete auth. Just disconnect while pendingConnect is live. + // Under the OLD implementation, this would hang until the 5000ms + // connectTimeout fired because the onclose that would reject was + // deferred as a microtask AND then gated away by `this.ws !== ws`. + // Under the fix, disconnect() rejects synchronously. + const start = Date.now(); + client.disconnect(); + await expect(connectPromise).rejects.toThrow(); + const elapsed = Date.now() - start; + expect(elapsed).toBeLessThan(500); // must not wait for 5s timeout + expect(client.getState().connectionStatus).toBe('closed'); + } finally { + restore(); + } + }); + + test('async-close mock: connect timeout rejects and transitions to disconnected even when onclose is deferred', async () => { + const { client, constructed, restore } = await makeClient({ asyncClose: true }); + + try { + // Reach into the instance to shorten the connect timeout for test speed. + // Construct a new client with a short timeout instead. + client.disconnect(); + const roomSecret = generateRoomSecret(); + const { authKey, eventKey, presenceKey } = await deriveRoomKeys(roomSecret); + const roomVerifier = await computeRoomVerifier(authKey, ROOM_ID); + + const WebSocketImpl = class extends MockWebSocket { + constructor(url: string | URL, protocols?: string | string[]) { + super(url, protocols); + constructed.push(this); + } + } as unknown as typeof WebSocket; + + const fastClient = new CollabRoomClient({ + roomId: ROOM_ID, + baseUrl: 'http://localhost:8787', + eventKey, + presenceKey, + adminKey: null, + roomVerifier, + adminVerifier: null, + user: USER, + webSocketImpl: WebSocketImpl, + connectTimeoutMs: 50, // trigger timeout path + reconnect: { maxAttempts: 0 }, + }); + + const connectPromise = fastClient.connect(); + await expect(connectPromise).rejects.toThrow(ConnectTimeoutError); + + // Let the deferred onclose fire (from the timeout's closeSocket call). + await new Promise(r => setTimeout(r, 50)); + + // Critical assertions: the deferred onclose must not clobber the + // already-settled state. Status must be 'disconnected' (not mutated + // back by a late handleSocketClose). + expect(fastClient.getState().connectionStatus).toBe('disconnected'); + } finally { + restore(); + } + }); +}); + +describe('CollabRoomClient — getState() returns immutable snapshot (P2)', () => { + test('mutating returned annotations / remotePresence does not affect internal state', async () => { + const { client, ws, eventKey, presenceKey } = await setup(); + + // Seed an annotation via server echo. + const ann: RoomAnnotation = { + id: 'imm-1', + blockId: 'b1', startOffset: 0, endOffset: 5, + type: 'COMMENT', originalText: 'x', createdA: 1, + startMeta: { parentTagName: 'p', parentIndex: 0, textOffset: 0 }, + }; + const cipher = await encryptEventOp(eventKey, { type: 'annotation.add', annotations: [ann] }); + ws.peer.sendFromServer(JSON.stringify({ + type: 'room.event', seq: 1, receivedAt: Date.now(), + envelope: { clientId: 'other', opId: 'a1', channel: 'event', ciphertext: cipher }, + })); + await waitFor(() => client.getState().annotations.length === 1, 1000); + + // Seed presence. + const presence = { + user: { id: 'u2', name: 'bob', color: '#0f0' }, + cursor: { x: 10, y: 20, coordinateSpace: 'document' as const }, + }; + const pCipher = await encryptPresence(presenceKey, presence); + ws.peer.sendFromServer(JSON.stringify({ + type: 'room.presence', + envelope: { clientId: 'friend', opId: 'p1', channel: 'presence', ciphertext: pCipher }, + })); + await waitFor(() => client.getState().remotePresence.friend !== undefined, 1000); + + // Seed a non-null lastError by pushing a room.error from the server. + ws.peer.sendFromServer(JSON.stringify({ + type: 'room.error', code: 'invalid_state', message: 'Test error message', + })); + await waitFor(() => client.getState().lastError !== null, 1000); + + // Grab the snapshot and MUTATE the returned objects. + const snap1 = client.getState(); + (snap1.annotations[0] as RoomAnnotation).originalText = 'MUTATED'; + snap1.annotations[0].startMeta!.parentTagName = 'HIJACKED'; + snap1.remotePresence.friend.user.name = 'MUTATED'; + snap1.remotePresence.friend.cursor!.x = 9999; + snap1.annotations.push({ ...ann, id: 'injected' }); + snap1.remotePresence.intruder = { + user: { id: 'x', name: 'x', color: '#000' }, + cursor: null, + }; + snap1.lastError!.message = 'MUTATED ERROR'; + + // Fresh snapshot must reflect internal state, not the mutations above. + const snap2 = client.getState(); + expect(snap2.annotations.length).toBe(1); + expect(snap2.annotations[0].id).toBe('imm-1'); + expect(snap2.annotations[0].originalText).toBe('x'); + expect(snap2.annotations[0].startMeta!.parentTagName).toBe('p'); + expect(snap2.remotePresence.friend.user.name).toBe('bob'); + expect(snap2.remotePresence.friend.cursor!.x).toBe(10); + expect(snap2.remotePresence.intruder).toBeUndefined(); + expect(snap2.lastError).not.toBeNull(); + expect(snap2.lastError!.message).toBe('Test error message'); + expect(snap2.lastError!.code).toBe('invalid_state'); + + client.disconnect(); + }); + + test('two getState() calls return distinct object references (no shared mutable refs)', async () => { + const { client, ws, eventKey } = await setup(); + const ann: RoomAnnotation = { + id: 'ref-1', + blockId: 'b1', startOffset: 0, endOffset: 5, + type: 'COMMENT', originalText: 'x', createdA: 1, + }; + const cipher = await encryptEventOp(eventKey, { type: 'annotation.add', annotations: [ann] }); + ws.peer.sendFromServer(JSON.stringify({ + type: 'room.event', seq: 1, receivedAt: Date.now(), + envelope: { clientId: 'other', opId: 'a1', channel: 'event', ciphertext: cipher }, + })); + await waitFor(() => client.getState().annotations.length === 1, 1000); + + const s1 = client.getState(); + const s2 = client.getState(); + expect(s1.annotations).not.toBe(s2.annotations); + expect(s1.annotations[0]).not.toBe(s2.annotations[0]); + expect(s1.remotePresence).not.toBe(s2.remotePresence); + client.disconnect(); + }); +}); + +// NOTE: the auth-proof failure during auto-reconnect code path (handleAuthChallenge +// catch branch when pendingConnect === null) is defensive and hard to exercise +// deterministically without patching WebCrypto. It's covered by code review and the +// existing initial-connect auth-failure path is tested above. + +describe('CollabRoomClient — auth.challenge missing clientId (P3 protocol violation)', () => { + test('initial-connect: rejects pendingConnect and transitions to disconnected (does not hang until timeout)', async () => { + const roomSecret = generateRoomSecret(); + const { authKey, eventKey, presenceKey } = await deriveRoomKeys(roomSecret); + const roomVerifier = await computeRoomVerifier(authKey, ROOM_ID); + + let capturedWs: MockWebSocket | null = null; + const WebSocketImpl = class extends MockWebSocket { + constructor(url: string | URL, protocols?: string | string[]) { + super(url, protocols); + capturedWs = this; + } + } as unknown as typeof WebSocket; + + const client = new CollabRoomClient({ + roomId: ROOM_ID, + baseUrl: 'http://localhost:8787', + eventKey, + presenceKey, + adminKey: null, + roomVerifier, + adminVerifier: null, + user: USER, + webSocketImpl: WebSocketImpl, + connectTimeoutMs: 5000, // intentionally long — test must reject fast + reconnect: { maxAttempts: 0 }, + }); + + const connectPromise = client.connect(); + await waitFor(() => capturedWs !== null, 1000); + await new Promise(r => queueMicrotask(r)); + await new Promise(r => queueMicrotask(r)); + + // Send a challenge WITHOUT clientId (simulates old server / malformed). + const ws = capturedWs!; + ws.peer.sendFromServer(JSON.stringify({ + type: 'auth.challenge', + challengeId: generateChallengeId(), + nonce: generateNonce(), + expiresAt: Date.now() + 30_000, + // clientId: missing + })); + + const start = Date.now(); + await expect(connectPromise).rejects.toThrow(); + const elapsed = Date.now() - start; + expect(elapsed).toBeLessThan(500); // not waiting for connectTimeoutMs + expect(client.getState().connectionStatus).toBe('disconnected'); + }); +}); + +describe('CollabRoomClient — openSocket synchronous throw is cleaned up (P2)', () => { + test('synchronous WebSocket constructor throw: connect rejects, state returns to disconnected, next connect is not blocked', async () => { + const roomSecret = generateRoomSecret(); + const { authKey, eventKey, presenceKey } = await deriveRoomKeys(roomSecret); + const roomVerifier = await computeRoomVerifier(authKey, ROOM_ID); + + // First impl throws synchronously from the constructor. Second impl is + // a normal MockWebSocket so a follow-up connect() can actually proceed. + let attempt = 0; + const boom = new Error('WebSocket constructor exploded'); + const capturedSockets: MockWebSocket[] = []; + const WebSocketImpl = class extends MockWebSocket { + constructor(url: string | URL, protocols?: string | string[]) { + attempt++; + if (attempt === 1) { + // Throw from the constructor so the `new this.WebSocketImpl(wsUrl)` + // expression in openSocket() fails before `this.ws = ws`. + super(url, protocols); + throw boom; + } + super(url, protocols); + capturedSockets.push(this); + } + } as unknown as typeof WebSocket; + + const client = new CollabRoomClient({ + roomId: ROOM_ID, + baseUrl: 'http://localhost:8787', + eventKey, + presenceKey, + adminKey: null, + roomVerifier, + adminVerifier: null, + user: USER, + webSocketImpl: WebSocketImpl, + connectTimeoutMs: 2000, + reconnect: { maxAttempts: 0 }, + }); + + // First connect — constructor throws. Must reject quickly with the + // underlying error, not sit until connectTimeoutMs. + const start = Date.now(); + await expect(client.connect()).rejects.toThrow('WebSocket constructor exploded'); + expect(Date.now() - start).toBeLessThan(500); + + // State must be disconnected — not stuck in 'connecting'. + expect(client.getState().connectionStatus).toBe('disconnected'); + + // A subsequent connect() must NOT be trapped behind stale pendingConnect + // state. Drive it to full auth to prove end-to-end that the pending state + // was cleaned up. + const secondConnect = client.connect(); + await waitFor(() => capturedSockets.length === 1, 1000); + await new Promise(r => queueMicrotask(r)); + await new Promise(r => queueMicrotask(r)); + + const ws = capturedSockets[0]; + ws.peer.sendFromServer(JSON.stringify(makeAuthChallenge())); + await ws.peer.expectFromClient(); + ws.peer.sendFromServer(JSON.stringify({ + type: 'auth.accepted', roomStatus: 'active', + seq: 0, snapshotSeq: 0, snapshotAvailable: false, + })); + await secondConnect; + expect(client.getState().connectionStatus).toBe('authenticated'); + client.disconnect(); + }); +}); + +describe('CollabRoomClient — outbound validation (P2)', () => { + // Helper: a rejected outbound validation must not push a new message onto the + // wire. setup() already drains the auth.response, so compare against baseline. + const assertNoNewSend = (ws: MockWebSocket, sentBefore: number) => { + expect(ws.peer.sent.length).toBe(sentBefore); + }; + + test('sendAnnotationAdd rejects annotation with images before encryption/send', async () => { + const { client, ws } = await setup(); + const sentBefore = ws.peer.sent.length; + const bad = { + id: 'bad-1', + blockId: 'b1', startOffset: 0, endOffset: 5, + type: 'COMMENT' as const, originalText: 'x', createdA: 1, + // images is forbidden in V1 RoomAnnotation + images: [{ path: '/tmp/x', name: 'x.png' }], + } as unknown as RoomAnnotation; + await expect(client.sendAnnotationAdd([bad])).rejects.toThrow(InvalidOutboundPayloadError); + assertNoNewSend(ws, sentBefore); + client.disconnect(); + }); + + test('sendAnnotationAdd rejects annotation with null id before send', async () => { + const { client, ws } = await setup(); + const sentBefore = ws.peer.sent.length; + const bad = { + id: null, + blockId: 'b1', startOffset: 0, endOffset: 5, + type: 'COMMENT', originalText: 'x', createdA: 1, + } as unknown as RoomAnnotation; + await expect(client.sendAnnotationAdd([bad])).rejects.toThrow(InvalidOutboundPayloadError); + assertNoNewSend(ws, sentBefore); + client.disconnect(); + }); + + test('sendAnnotationUpdate rejects patch that tries to mutate id', async () => { + const { client, ws } = await setup(); + const sentBefore = ws.peer.sent.length; + await expect( + client.sendAnnotationUpdate('some-id', { id: 'hijacked' } as Partial), + ).rejects.toThrow(InvalidOutboundPayloadError); + assertNoNewSend(ws, sentBefore); + client.disconnect(); + }); + + test('sendAnnotationRemove rejects non-string / empty ids', async () => { + const { client, ws } = await setup(); + const sentBefore = ws.peer.sent.length; + await expect( + client.sendAnnotationRemove(['valid-id', ''] as string[]), + ).rejects.toThrow(InvalidOutboundPayloadError); + assertNoNewSend(ws, sentBefore); + client.disconnect(); + }); + + test('sendPresence rejects non-finite cursor coordinates', async () => { + const { client, ws } = await setup(); + const sentBefore = ws.peer.sent.length; + const bad = { + user: { id: 'u', name: 'a', color: '#f00' }, + cursor: { x: Infinity, y: 0, coordinateSpace: 'document' as const }, + }; + await expect(client.sendPresence(bad as never)).rejects.toThrow(InvalidOutboundPayloadError); + assertNoNewSend(ws, sentBefore); + client.disconnect(); + }); + + test('lockRoom rejects malformed finalSnapshot before admin challenge', async () => { + const { client, ws } = await setup({ withAdmin: true }); + const sentBefore = ws.peer.sent.length; + const bad = { versionId: 'v99', planMarkdown: 'nope', annotations: [] } as unknown as RoomSnapshot; + await expect(client.lockRoom({ finalSnapshot: bad, finalSnapshotSeq: 0 })).rejects.toThrow(InvalidOutboundPayloadError); + // Must NOT have sent an admin.challenge.request — validation short-circuits. + assertNoNewSend(ws, sentBefore); + client.disconnect(); + }); +}); + +describe('createRoom — success body is not parsed (P2)', () => { + test('resolves after 201 even when the response body is empty', async () => { + const { createRoom } = await import('./create-room'); + const goodSnapshot = { + versionId: 'v1' as const, + planMarkdown: '# Plan', + annotations: [], + }; + const fakeFetch: typeof fetch = async () => new Response('', { status: 201 }); + + const result = await createRoom({ + baseUrl: 'http://localhost:8787', + initialSnapshot: goodSnapshot, + user: USER, + fetchImpl: fakeFetch, + }); + + expect(result.roomId).toBeTruthy(); + expect(result.roomSecret).toBeTruthy(); + expect(result.adminSecret).toBeTruthy(); + expect(result.joinUrl).toContain(result.roomId); + expect(result.adminUrl).toContain(result.roomId); + expect(result.client).toBeDefined(); + }); + + test('resolves after 201 even when the response body is malformed JSON', async () => { + const { createRoom } = await import('./create-room'); + const goodSnapshot = { + versionId: 'v1' as const, + planMarkdown: '# Plan', + annotations: [], + }; + const fakeFetch: typeof fetch = async () => new Response('not-json{{{', { status: 201 }); + + const result = await createRoom({ + baseUrl: 'http://localhost:8787', + initialSnapshot: goodSnapshot, + user: USER, + fetchImpl: fakeFetch, + }); + + expect(result.roomId).toBeTruthy(); + expect(result.adminSecret).toBeTruthy(); + }); +}); + +describe('createRoom — outbound validation (P2)', () => { + test('rejects malformed initialSnapshot before any fetch', async () => { + const { createRoom } = await import('./create-room'); + let fetchCalls = 0; + const fakeFetch: typeof fetch = async () => { + fetchCalls++; + return new Response('{}', { status: 201 }); + }; + const bad = { versionId: 'v99', planMarkdown: 'x', annotations: [] } as unknown as RoomSnapshot; + await expect(createRoom({ + baseUrl: 'http://localhost:8787', + initialSnapshot: bad, + user: USER, + fetchImpl: fakeFetch, + })).rejects.toThrow(InvalidOutboundPayloadError); + expect(fetchCalls).toBe(0); + }); + + test('rejects initialSnapshot containing malformed annotation', async () => { + const { createRoom } = await import('./create-room'); + let fetchCalls = 0; + const fakeFetch: typeof fetch = async () => { + fetchCalls++; + return new Response('{}', { status: 201 }); + }; + const badAnn = { + id: 'x', blockId: 'b', startOffset: 0, endOffset: 0, + type: 'INVALID_TYPE', originalText: 'x', createdA: 1, + }; + const bad = { + versionId: 'v1', planMarkdown: '', annotations: [badAnn], + } as unknown as RoomSnapshot; + await expect(createRoom({ + baseUrl: 'http://localhost:8787', + initialSnapshot: bad, + user: USER, + fetchImpl: fakeFetch, + })).rejects.toThrow(InvalidOutboundPayloadError); + expect(fetchCalls).toBe(0); + }); +}); + +describe('CollabRoomClient — runAdminCommand send() failure (P3)', () => { + test('synchronous send throw clears pendingAdmin; subsequent admin command works', async () => { + const { client, ws } = await setup({ withAdmin: true }); + + const sendMock = ws.send.bind(ws); + let shouldThrow = true; + ws.send = (data: string | ArrayBufferLike | Blob | ArrayBufferView) => { + if (shouldThrow) { shouldThrow = false; throw new Error('simulated admin send failure'); } + return sendMock(data); + }; + + await expect(client.lockRoom()).rejects.toThrow('simulated admin send failure'); + + // pendingAdmin should be cleared — a fresh command must not report "Another admin command is pending". + // Drive a full successful lock now. + const lockPromise = client.lockRoom(); + await ws.peer.expectFromClient(); // admin.challenge.request + + const adminChallenge: AdminChallenge = { + type: 'admin.challenge', + challengeId: generateChallengeId(), + nonce: generateNonce(), + expiresAt: Date.now() + 30_000, + }; + ws.peer.sendFromServer(JSON.stringify(adminChallenge)); + await ws.peer.expectFromClient(); // admin.command + ws.peer.sendFromServer(JSON.stringify({ type: 'room.status', status: 'locked' })); + await lockPromise; + + client.disconnect(); + }); +}); + +describe('CollabRoomClient — socket-generation guards drop stale queued messages', () => { + // These tests simulate a reconnect that rolls the socket while a queued + // snapshot or event is mid-flight. The guard check (gen !== socketGeneration) + // must cause the queued handler to return without mutating state. We bump + // socketGeneration synchronously between enqueue and queue drain to simulate + // a rotation deterministically — the natural race is hard to pin to a single + // microtask boundary, but the guard is the same code path either way. + + test('queued room.event from retired socket does not mutate state after generation advances', async () => { + const { client, ws, eventKey } = await setup(); + + // Sanity: baseline is empty at seq=0. + expect(client.getState().annotations.length).toBe(0); + expect(client.getState().seq).toBe(0); + + // Inject a valid annotation.add room.event — handleSocketMessage enqueues + // handleRoomEvent with the CURRENT socketGeneration captured. + const ann: RoomAnnotation = { + id: 'stale-ev-1', + blockId: 'b1', startOffset: 0, endOffset: 5, + type: 'COMMENT', originalText: 'x', createdA: 1, + startMeta: { parentTagName: 'p', parentIndex: 0, textOffset: 0 }, + }; + const cipher = await encryptEventOp(eventKey, { type: 'annotation.add', annotations: [ann] }); + ws.peer.sendFromServer(JSON.stringify({ + type: 'room.event', seq: 1, receivedAt: Date.now(), + envelope: { clientId: 'other', opId: 'a1', channel: 'event', ciphertext: cipher }, + })); + + // Synchronously simulate a socket rotation: bump the generation counter + // before the queued handler's microtask runs. The next time the handler + // compares `gen !== this.socketGeneration`, it must short-circuit. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (client as any).socketGeneration = (client as any).socketGeneration + 1; + + // Drain the message queue. + await new Promise(r => setTimeout(r, 20)); + + // State must be untouched — no annotation applied, seq not advanced. + expect(client.getState().annotations.length).toBe(0); + expect(client.getState().seq).toBe(0); + // lastError should not be a decrypt/shape error either — the handler + // short-circuited entirely. + expect(client.getState().lastError).toBeNull(); + + // After the stale drop, the newer socket must still accept valid events. + // Restore the counter so the next message's captured gen matches. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (client as any).socketGeneration = (client as any).socketGeneration - 1; + ws.peer.sendFromServer(JSON.stringify({ + type: 'room.event', seq: 1, receivedAt: Date.now(), + envelope: { clientId: 'other', opId: 'a2', channel: 'event', ciphertext: cipher }, + })); + await new Promise(r => setTimeout(r, 20)); + expect(client.getState().annotations.length).toBe(1); + expect(client.getState().seq).toBe(1); + + client.disconnect(); + }); + + test('queued room.snapshot from retired socket does not replace baseline after generation advances', async () => { + const { client, ws, eventKey } = await setup(); + + // Seed a single annotation so we can detect unwanted baseline replacement. + const seedAnn: RoomAnnotation = { + id: 'seed-ann', blockId: 'b1', startOffset: 0, endOffset: 3, + type: 'COMMENT', originalText: 'seed', createdA: 1, + startMeta: { parentTagName: 'p', parentIndex: 0, textOffset: 0 }, + }; + const seedCipher = await encryptEventOp(eventKey, { type: 'annotation.add', annotations: [seedAnn] }); + ws.peer.sendFromServer(JSON.stringify({ + type: 'room.event', seq: 1, receivedAt: Date.now(), + envelope: { clientId: 'other', opId: 'seed', channel: 'event', ciphertext: seedCipher }, + })); + await new Promise(r => setTimeout(r, 20)); + expect(client.getState().annotations.length).toBe(1); + expect(client.getState().seq).toBe(1); + + // Queue a stale snapshot that WOULD wipe the seed annotation and rewind seq + // to 0 if it applied. The generation guard must drop it. + const staleSnapshot: RoomSnapshot = { versionId: 'v-stale', planMarkdown: '# stale', annotations: [] }; + const staleCipher = await encryptSnapshot(eventKey, staleSnapshot); + ws.peer.sendFromServer(JSON.stringify({ + type: 'room.snapshot', snapshotSeq: 0, snapshotCiphertext: staleCipher, + })); + + // Synchronously advance the generation before the queued decrypt task runs. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (client as any).socketGeneration = (client as any).socketGeneration + 1; + + await new Promise(r => setTimeout(r, 20)); + + // Baseline untouched: annotation still present, seq still 1. + expect(client.getState().annotations.length).toBe(1); + expect(client.getState().annotations[0].id).toBe('seed-ann'); + expect(client.getState().seq).toBe(1); + expect(client.getState().planMarkdown).toBe('# Plan'); // unchanged from setup() + // No snapshot-decrypt error surfaced on the newer socket. + expect(client.getState().lastError).toBeNull(); + + client.disconnect(); + }); +}); diff --git a/packages/shared/collab/client-runtime/client.ts b/packages/shared/collab/client-runtime/client.ts new file mode 100644 index 00000000..230ccc58 --- /dev/null +++ b/packages/shared/collab/client-runtime/client.ts @@ -0,0 +1,1652 @@ +/** + * CollabRoomClient — the browser/agent runtime for Plannotator Live Rooms. + * + * Owns WebSocket lifecycle, auth handshake, message dispatch, state management, + * auto-reconnect with backoff, and admin command flow. + * + * V1 state model: server echo is authoritative. Annotation mutations are NOT + * applied optimistically — they are only applied when the server echoes them + * back via room.event. See sendOp() for the rationale (no opId-correlated + * ack/reject in V1, so no safe rollback path). + * + * Zero-knowledge: decrypts server-provided ciphertext locally; encrypts before send. + */ + +import { + computeAuthProof, + computeAdminProof, + encryptEventOp, + decryptEventPayload, + encryptPresence, + decryptPresence, + encryptSnapshot, + decryptSnapshot, +} from '../crypto'; +import { WS_CLOSE_REASON_ROOM_DELETED, WS_CLOSE_REASON_ROOM_EXPIRED, WS_CLOSE_ROOM_UNAVAILABLE } from '../constants'; +import { generateOpId } from '../ids'; +import type { + AdminChallenge, + AdminCommand, + AuthAccepted, + AuthChallenge, + PresenceState, + RoomAnnotation, + RoomEventClientOp, + RoomServerEvent, + RoomSnapshot, + RoomStatus, + RoomTransportMessage, + ServerEnvelope, +} from '../types'; +import { isPresenceState, isRoomEventClientOp, isRoomSnapshot } from '../types'; +// Event channel uses isRoomEventClientOp (event ops ONLY — no presence.update). +// Presence channel uses isPresenceState (validates raw PresenceState payloads). +// This split prevents presence.update from leaking into the durable event log. +import { applyAnnotationEvent, annotationsToArray, cloneRoomAnnotation, cloneRoomAnnotationPatch } from './apply-event'; +import { computeBackoffMs, DEFAULT_BACKOFF } from './backoff'; +import { TypedEventEmitter } from './emitter'; +import type { + CollabRoomEvents, + CollabRoomState, + ConnectionStatus, + InternalClientOptions, + ReconnectOptions, +} from './types'; + +// --------------------------------------------------------------------------- +// Error types +// --------------------------------------------------------------------------- + +export class ConnectTimeoutError extends Error { constructor() { super('WebSocket connect/auth timed out'); this.name = 'ConnectTimeoutError'; } } +export class AuthRejectedError extends Error { constructor(msg = 'Auth rejected') { super(msg); this.name = 'AuthRejectedError'; } } +export class RoomUnavailableError extends Error { constructor(msg = 'Room unavailable') { super(msg); this.name = 'RoomUnavailableError'; } } +export class NotConnectedError extends Error { constructor() { super('Client is not authenticated'); this.name = 'NotConnectedError'; } } +export class AdminNotAuthorizedError extends Error { constructor() { super('No admin capability'); this.name = 'AdminNotAuthorizedError'; } } +export class AdminTimeoutError extends Error { constructor() { super('Admin command timed out'); this.name = 'AdminTimeoutError'; } } +export class AdminInterruptedError extends Error { constructor() { super('Admin command interrupted by socket close'); this.name = 'AdminInterruptedError'; } } +export class AdminRejectedError extends Error { + constructor(public code: string, message: string) { + super(message); + this.name = 'AdminRejectedError'; + } +} + +/** + * Thrown by public mutation methods when the payload fails shape validation + * BEFORE encryption/send. This catches UI bugs early — without it, a bad + * payload would be encrypted, sequenced by the server, echoed, and then + * rejected by every client (including the sender) with no clear signal that + * the original send was the cause. + */ +export class InvalidOutboundPayloadError extends Error { + constructor(message: string) { + super(message); + this.name = 'InvalidOutboundPayloadError'; + } +} + +// --------------------------------------------------------------------------- +// Clone helpers for getState() immutability +// +// V1 state is server-authoritative: internal annotation/presence objects must +// only be mutated by decrypted server events. If getState() exposed internal +// references, UI code could silently corrupt local state by mutating a +// returned annotation or cursor. These helpers keep the public surface +// read-only. +// +// cloneRoomAnnotation is imported from apply-event.ts (single source of truth +// for the nested-meta clone rule — avoids drift if a new nested field is +// added to RoomAnnotation). +// --------------------------------------------------------------------------- + +function clonePresenceState(p: PresenceState): PresenceState { + return { + ...p, + user: { ...p.user }, + cursor: p.cursor ? { ...p.cursor } : null, + }; +} + +/** Clone a decoded RoomServerEvent so emission to subscribers is isolated from internal state. */ +/** + * Clone an outbound RoomEventClientOp so the payload the client queues for + * encryption is immune to caller mutation. Public mutation methods clone + * synchronously before validation + queueing; if the caller mutates the + * annotation/patch/ids array after the call returns, the queued op stays + * pinned to the value at call time. + */ +function cloneRoomEventClientOp(op: RoomEventClientOp): RoomEventClientOp { + switch (op.type) { + case 'annotation.add': + return { type: 'annotation.add', annotations: op.annotations.map(cloneRoomAnnotation) }; + case 'annotation.update': + return { type: 'annotation.update', id: op.id, patch: cloneRoomAnnotationPatch(op.patch) }; + case 'annotation.remove': + return { type: 'annotation.remove', ids: [...op.ids] }; + case 'annotation.clear': + return { type: 'annotation.clear', source: op.source }; + } +} + +function cloneRoomServerEvent(event: RoomServerEvent): RoomServerEvent { + switch (event.type) { + case 'annotation.add': + return { type: 'annotation.add', annotations: event.annotations.map(cloneRoomAnnotation) }; + case 'annotation.update': + return { type: 'annotation.update', id: event.id, patch: cloneRoomAnnotationPatch(event.patch) }; + case 'annotation.remove': + return { type: 'annotation.remove', ids: [...event.ids] }; + case 'annotation.clear': + return { type: 'annotation.clear', source: event.source }; + case 'snapshot': + return { + type: 'snapshot', + snapshotSeq: event.snapshotSeq, + payload: { ...event.payload, annotations: event.payload.annotations.map(cloneRoomAnnotation) }, + }; + case 'presence.update': + return { type: 'presence.update', clientId: event.clientId, presence: clonePresenceState(event.presence) }; + } +} + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const DEFAULT_CONNECT_TIMEOUT_MS = 10_000; +const DEFAULT_PRESENCE_TTL_MS = 30_000; +const DEFAULT_PRESENCE_SWEEP_INTERVAL_MS = 5_000; +const ADMIN_COMMAND_TIMEOUT_MS = 5_000; + +/** + * `room.error` codes that are emitted exclusively from the admin command + * path on the server. A pending admin command rejects ONLY when a room.error + * with one of these codes arrives; other codes (e.g. `room_locked`, + * `validation_error` from an event-channel op) are event-channel failures + * and must not cancel an in-flight admin command. + * + * Keep in sync with server sendError() calls in the admin path. + */ +const ADMIN_SCOPED_ERROR_CODES = new Set([ + 'admin_validation_error', + 'client_id_mismatch', + 'no_admin_challenge', + 'unknown_admin_challenge', + 'admin_challenge_expired', + 'invalid_admin_proof', + 'invalid_state', + 'invalid_snapshot_seq', + 'delete_failed', + 'lock_failed', + 'unlock_failed', +]); + + +// --------------------------------------------------------------------------- +// Internal types +// --------------------------------------------------------------------------- + +interface PendingAdmin { + command: AdminCommand; + resolve: () => void; + reject: (err: Error) => void; + timeoutHandle: ReturnType; +} + +interface PendingConnect { + resolve: () => void; + reject: (err: Error) => void; + timeoutHandle: ReturnType; +} + +// --------------------------------------------------------------------------- +// Class +// --------------------------------------------------------------------------- + +export class CollabRoomClient { + // Identity / keys (stable across reconnects) + private readonly roomId: string; + private readonly baseUrl: string; + private readonly eventKey: CryptoKey; + private readonly presenceKey: CryptoKey; + private readonly adminKey: CryptoKey | null; + private readonly roomVerifier: string; + private readonly adminVerifier: string | null; + + // Runtime state + private ws: WebSocket | null = null; + /** + * Monotonic generation counter. Incremented every time openSocket() + * installs a new WebSocket. Queued async handlers (room.snapshot, + * room.event, room.presence, room.status, room.error) capture the + * generation at dispatch time and re-check it after any async decrypt + * before mutating state — so a late decrypt from a retired socket can + * never clobber the newer socket's state, even though the retired + * socket's onmessage was already short-circuited by the retiredSockets + * gate (the async continuation could still be in flight). + */ + private socketGeneration = 0; + /** + * Sockets we've actively retired. Their onmessage/onclose/onerror handlers + * no-op once a socket is in this set. WeakSet so retired sockets can be + * GC'd once they close. + * + * Two paths add to this set: + * 1. openSocket() when REPLACING a prior socket — the replacement retires + * the predecessor so its late events don't clobber the new socket. + * 2. closeSocket() for INTENTIONAL closes of the current socket + * (disconnect, connect timeout, auth-proof failure). These callers + * do their own synchronous lifecycle cleanup (reject pendingConnect / + * pendingAdmin, set status, clear presence) BEFORE calling closeSocket, + * so the async onclose does not need to run handleSocketClose — and + * must not, or it could clobber state the caller already settled. + * + * Network-initiated closes of the current socket (server close, network + * drop) do NOT go through closeSocket — they reach onclose directly with + * the socket NOT in this set, so handleSocketClose runs as normal and does + * the reconnect / pending-rejection logic itself. + */ + private retiredSockets = new WeakSet(); + private clientId: string = ''; // regenerated per connect + private status: ConnectionStatus = 'disconnected'; + private roomStatus: RoomStatus | null = null; + private seq: number = 0; + private planMarkdown: string = ''; + private annotations = new Map(); + private remotePresence = new Map(); + private lastError: { code: string; message: string } | null = null; + /** + * True when the most-recent snapshot attempt failed (malformed or + * decrypt-failed) and a valid baseline has not yet been re-established. + * While true, inbound room.events are rejected — applying events on top of + * a stale baseline would produce silently-divergent local state. Cleared + * when a valid snapshot is applied or the client reconnects. + */ + private baselineInvalid = false; + + // Admin flow + private pendingAdmin: PendingAdmin | null = null; + + // Lifecycle state + private pendingConnect: PendingConnect | null = null; + private pendingConnectPromise: Promise | null = null; + private userDisconnected = false; + + // Serialized async message processing queue. + // Ensures snapshot/event/presence decrypts apply in wire order regardless + // of decrypt latency variance. Prevents the race where an event's decrypt + // finishes before a concurrent snapshot's decrypt and then gets clobbered. + private messageQueue: Promise = Promise.resolve(); + /** + * Serializes outbound EVENT-channel sends. Encryption is async, so two + * concurrent sendAnnotationAdd()/Remove()/Update()/Clear() calls could + * otherwise race and send in completion order rather than call order — + * a user clicking "add" then "remove" could see remove land first, leaving + * the annotation the remove was supposed to delete. Presence is NOT in + * this queue — it's lossy by design and throughput matters more than + * strict ordering there. + */ + private outboundEventQueue: Promise = Promise.resolve(); + private reconnectAttempt = 0; + private reconnectTimer: ReturnType | null = null; + private presenceSweepTimer: ReturnType | null = null; + /** + * Watchdog for auto-reconnect handshakes. Initial connect() uses + * pendingConnect's own connectTimeoutMs; auto-reconnect does not, so + * without this a reconnect socket that opens but never authenticates would + * hang the client in `connecting` / `authenticating` forever. + */ + private reconnectHandshakeTimer: ReturnType | null = null; + + // Injected / options + private readonly WebSocketImpl: typeof WebSocket; + private readonly reconnectOpts: Required; + private readonly connectTimeoutMs: number; + private readonly presenceTtlMs: number; + private readonly presenceSweepIntervalMs: number; + + // Emitter + private readonly emitter = new TypedEventEmitter(); + + constructor(options: InternalClientOptions) { + this.roomId = options.roomId; + this.baseUrl = options.baseUrl; + this.eventKey = options.eventKey; + this.presenceKey = options.presenceKey; + this.adminKey = options.adminKey; + this.roomVerifier = options.roomVerifier; + this.adminVerifier = options.adminVerifier; + // options.user is reserved for future use (presence auto-construction); not stored. + this.WebSocketImpl = options.webSocketImpl ?? WebSocket; + this.reconnectOpts = { + initialDelayMs: options.reconnect?.initialDelayMs ?? DEFAULT_BACKOFF.initialDelayMs, + maxDelayMs: options.reconnect?.maxDelayMs ?? DEFAULT_BACKOFF.maxDelayMs, + factor: options.reconnect?.factor ?? DEFAULT_BACKOFF.factor, + maxAttempts: options.reconnect?.maxAttempts ?? Number.POSITIVE_INFINITY, + }; + this.connectTimeoutMs = options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS; + this.presenceTtlMs = options.presenceTtlMs ?? DEFAULT_PRESENCE_TTL_MS; + this.presenceSweepIntervalMs = options.presenceSweepIntervalMs ?? DEFAULT_PRESENCE_SWEEP_INTERVAL_MS; + + // Seed initial snapshot if provided (by createRoom). Clone on store so + // a caller mutating their snapshot object later can't reach back into + // the client's internal annotations map. + if (options.initialSnapshot) { + this.planMarkdown = options.initialSnapshot.planMarkdown; + for (const ann of options.initialSnapshot.annotations) { + this.annotations.set(ann.id, cloneRoomAnnotation(ann)); + } + } + } + + // --------------------------------------------------------------------------- + // Public API + // --------------------------------------------------------------------------- + + on( + name: K, + fn: (p: CollabRoomEvents[K]) => void, + ): () => void { + return this.emitter.on(name, fn); + } + + getState(): CollabRoomState { + return this.buildState(); + } + + async connect(): Promise { + // Already authenticated → resolved no-op + if (this.status === 'authenticated') { + return; + } + + // Already in-flight → return the existing pending promise (shared by all callers). + // Invariant: pendingConnect and pendingConnectPromise are always set/cleared + // together. If this fires, it indicates a programming error. + if (this.pendingConnect) { + if (!this.pendingConnectPromise) { + throw new Error('CollabRoomClient connect() invariant violated: pendingConnect set without pendingConnectPromise'); + } + return this.pendingConnectPromise; + } + + // Explicit connect clears any poisoned state from prior disconnect/terminal + this.userDisconnected = false; + this.lastError = null; + this.reconnectAttempt = 0; + this.clearReconnectTimer(); + + // Build the promise FIRST, wire up pendingConnect + pendingConnectPromise, + // and only then open the socket. openSocket() calls setStatus('connecting'), + // which synchronously emits state. If a listener re-enters connect() during + // that emission, both refs must already be consistent — otherwise the + // fallback return would hand out a promise that's disconnected from the + // actual handshake. + let resolve!: () => void; + let reject!: (err: Error) => void; + const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); + const timeoutHandle = setTimeout(() => { + if (this.pendingConnect) { + // Synchronous cleanup: closeSocket retires the socket so its async + // onclose won't re-enter handleSocketClose. All lifecycle transitions + // happen here. + this.pendingConnect = null; + this.pendingConnectPromise = null; + this.closeSocket(1000, 'connect timeout'); + this.setStatus('disconnected'); + reject(new ConnectTimeoutError()); + } + }, this.connectTimeoutMs); + + this.pendingConnect = { resolve, reject, timeoutHandle }; + this.pendingConnectPromise = promise; + // Clean up promise ref when settled — attach BEFORE openSocket so the + // handlers exist if an unusual synchronous rejection path fires. + promise.then( + () => { if (this.pendingConnectPromise === promise) this.pendingConnectPromise = null; }, + () => { if (this.pendingConnectPromise === promise) this.pendingConnectPromise = null; }, + ); + + // openSocket() runs outside the Promise executor (so pendingConnectPromise + // is already assigned before `setStatus('connecting')` emits). That means + // a synchronous throw here — e.g. `new URL(baseUrl)` rejecting a bad base, + // or a WebSocket constructor throwing — would otherwise leave pendingConnect + // and the timeout live, poisoning later connect() calls with a stale + // in-flight promise. Catch, clean up, and reject the connect promise. + try { + this.openSocket(); + } catch (err) { + clearTimeout(timeoutHandle); + if (this.pendingConnect?.timeoutHandle === timeoutHandle) { + this.pendingConnect = null; + } + if (this.pendingConnectPromise === promise) { + this.pendingConnectPromise = null; + } + this.setStatus('disconnected'); + reject(err instanceof Error ? err : new Error(String(err))); + } + return promise; + } + + disconnect(reason?: string): void { + this.userDisconnected = true; + this.clearReconnectTimer(); + this.clearReconnectHandshakeTimer(); + // Synchronous lifecycle cleanup — closeSocket retires the socket, so the + // async onclose will NOT run handleSocketClose to do this cleanup for us. + if (this.pendingConnect) { + clearTimeout(this.pendingConnect.timeoutHandle); + const { reject } = this.pendingConnect; + this.pendingConnect = null; + this.pendingConnectPromise = null; + reject(new AuthRejectedError('Disconnected by user')); + } + if (this.pendingAdmin) { + const pending = this.pendingAdmin; + this.pendingAdmin = null; + clearTimeout(pending.timeoutHandle); + pending.reject(new AdminInterruptedError()); + } + this.remotePresence.clear(); + this.closeSocket(1000, reason ?? 'user disconnect'); + this.stopPresenceSweep(); + this.setStatus('closed'); + } + + // --------------------------------------------------------------------------- + // Mutation contract (V1) + // + // Mutation methods below resolve when the op is SENT, not when local state + // has been updated. Local state updates when the server echoes the op back + // via room.event — subscribe to the `state` event to observe post-echo + // state.annotations. A caller that awaits `sendAnnotationAdd(...)` and then + // reads `getState().annotations` may still see pre-echo state. + // + // Rationale: V1 has no opId-correlated ack/reject (see sendOp comments). + // Applying optimistically would be unsafe; requiring a round-trip before + // resolution would couple send latency to UI responsiveness. Decoupling the + // send ack from the state update matches the wire semantics exactly. + // --------------------------------------------------------------------------- + + /** Resolves when queued/sent to the server. State updates arrive via `state` events after echo. */ + async sendAnnotationAdd(annotations: RoomAnnotation[]): Promise { + // Clone SYNCHRONOUSLY before validation + queueing so the payload is + // immutable with respect to caller mutations after this call returns. + const op = cloneRoomEventClientOp({ type: 'annotation.add', annotations }); + if (!isRoomEventClientOp(op)) { + throw new InvalidOutboundPayloadError('Invalid annotation.add payload'); + } + await this.sendOp(op); + } + + /** Resolves when queued/sent to the server. State updates arrive via `state` events after echo. */ + async sendAnnotationUpdate(id: string, patch: Partial): Promise { + const op = cloneRoomEventClientOp({ type: 'annotation.update', id, patch }); + if (!isRoomEventClientOp(op)) { + throw new InvalidOutboundPayloadError('Invalid annotation.update payload'); + } + await this.sendOp(op); + } + + /** Resolves when queued/sent to the server. State updates arrive via `state` events after echo. */ + async sendAnnotationRemove(ids: string[]): Promise { + const op = cloneRoomEventClientOp({ type: 'annotation.remove', ids }); + if (!isRoomEventClientOp(op)) { + throw new InvalidOutboundPayloadError('Invalid annotation.remove payload'); + } + await this.sendOp(op); + } + + /** Resolves when queued/sent to the server. State updates arrive via `state` events after echo. */ + async sendAnnotationClear(source?: string): Promise { + const op = cloneRoomEventClientOp({ type: 'annotation.clear', source }); + if (!isRoomEventClientOp(op)) { + throw new InvalidOutboundPayloadError('Invalid annotation.clear payload'); + } + await this.sendOp(op); + } + + async sendPresence(presence: PresenceState): Promise { + // Shape validation is a real programming error (caller passed an + // invalid object); surface it even for fire-and-forget callers. + if (!isPresenceState(presence)) { + throw new InvalidOutboundPayloadError('Invalid presence payload'); + } + // Presence is lossy by design — a dropped cursor update is fine; the + // next mouse move fires another. Swallow disconnect-only failures so + // UI code that calls sendPresence() without awaiting (common for cursor + // throttles) doesn't log spurious "not connected" errors during brief + // reconnect windows. Shape errors above still throw. + try { + this.assertConnected(); + } catch { + return; + } + const opId = generateOpId(); + const ciphertext = await encryptPresence(this.presenceKey, presence); + + // Recheck socket after async encryption — it may have closed. + const ws = this.ws; + if (this.status !== 'authenticated' || !ws) { + return; // lossy: see comment above + } + + const envelope: ServerEnvelope = { + clientId: this.clientId, + opId, + channel: 'presence', + ciphertext, + }; + try { + ws.send(JSON.stringify(envelope)); + } catch { + // Socket transitioned to closing between the liveness check and send. + // Still lossy — drop silently. + } + } + + /** + * Lock the room. Optionally include a final snapshot that future fresh joins + * will receive as the baseline. + * + * Two final-snapshot modes: + * + * { includeFinalSnapshot: true } + * Atomic. The client builds a consistent snapshot of its current + * plaintext state labeled at the seq observed at call time. Events + * that arrive AFTER that seq (e.g. during snapshot encryption, or + * between lock and server processing) are NOT captured in this + * snapshot payload — that's fine: the server's authoritative event + * log keeps those events, and future fresh joiners receive the + * snapshot plus a replay of events after snapshotSeq. Snapshot + + * replay is how log-based systems stay correct. + * + * { finalSnapshot, finalSnapshotSeq } + * Advanced. Caller supplies a snapshot that they built from state at + * exactly `finalSnapshotSeq`. Both must be supplied together; the + * client rejects if `finalSnapshotSeq !== this.seq` at call time. This + * catches a different bug than the includeFinalSnapshot path: a caller + * who captured state at seq S, let more events arrive (so this.seq is + * now S+N), and then tries to label that STALE snapshot content with + * a seq the server would interpret as "everything through here". That + * would make fresh joiners skip the in-between events. Rejecting here + * forces the caller to re-capture. + */ + async lockRoom( + options: { + finalSnapshot?: RoomSnapshot; + finalSnapshotSeq?: number; + includeFinalSnapshot?: boolean; + } = {}, + ): Promise { + if (!this.adminKey || !this.adminVerifier) throw new AdminNotAuthorizedError(); + + let snapshot: RoomSnapshot | undefined; + let atSeq: number | undefined; + + if (options.includeFinalSnapshot) { + if (options.finalSnapshot || options.finalSnapshotSeq !== undefined) { + throw new InvalidOutboundPayloadError( + 'lockRoom: use EITHER {includeFinalSnapshot} OR {finalSnapshot, finalSnapshotSeq}, not both', + ); + } + // Edge case: no events consumed yet (fresh room). The initial snapshot + // already represents the seq-0 baseline, and the server rejects + // atSeq <= existingSnapshotSeq (initial snapshot is also seq 0), so + // sending one would always fail. Skip the final-snapshot field entirely + // — the lock itself still proceeds and the initial snapshot remains the + // baseline for future joiners. + if (this.seq > 0) { + snapshot = { + versionId: 'v1', + planMarkdown: this.planMarkdown, + annotations: annotationsToArray(this.annotations).map(cloneRoomAnnotation), + }; + atSeq = this.seq; + } + } else if (options.finalSnapshot !== undefined || options.finalSnapshotSeq !== undefined) { + if (options.finalSnapshot === undefined || options.finalSnapshotSeq === undefined) { + throw new InvalidOutboundPayloadError( + 'lockRoom: finalSnapshot and finalSnapshotSeq must be supplied together', + ); + } + if (!isRoomSnapshot(options.finalSnapshot)) { + throw new InvalidOutboundPayloadError('Invalid finalSnapshot payload'); + } + if (typeof options.finalSnapshotSeq !== 'number' || !Number.isFinite(options.finalSnapshotSeq) || options.finalSnapshotSeq < 0) { + throw new InvalidOutboundPayloadError('Invalid finalSnapshotSeq'); + } + // Caller asserts the snapshot content was built from state at this + // exact seq. Verify the client's consumed seq still matches — if an + // event advanced this.seq between snapshot build and lockRoom(), the + // snapshot is stale and we must refuse to label it. + if (options.finalSnapshotSeq !== this.seq) { + throw new InvalidOutboundPayloadError( + `finalSnapshot is stale: built at seq ${options.finalSnapshotSeq} but client consumed seq is ${this.seq}`, + ); + } + snapshot = options.finalSnapshot; + atSeq = options.finalSnapshotSeq; + } + + const cmd: AdminCommand = { type: 'room.lock' }; + if (snapshot !== undefined && atSeq !== undefined) { + // atSeq is captured BEFORE the async encrypt. We've already verified + // (or atomically set) that snapshot content is built from exactly atSeq; + // incoming events during encryption must not advance the label. + cmd.finalSnapshotCiphertext = await encryptSnapshot(this.eventKey, snapshot); + cmd.finalSnapshotAtSeq = atSeq; + } + await this.runAdminCommand(cmd); + } + + async unlockRoom(): Promise { + if (!this.adminKey || !this.adminVerifier) throw new AdminNotAuthorizedError(); + await this.runAdminCommand({ type: 'room.unlock' }); + } + + async deleteRoom(): Promise { + if (!this.adminKey || !this.adminVerifier) throw new AdminNotAuthorizedError(); + await this.runAdminCommand({ type: 'room.delete' }); + } + + // --------------------------------------------------------------------------- + // Internal: socket lifecycle + // --------------------------------------------------------------------------- + + private openSocket(): void { + // Reset clientId; the authoritative value will come from auth.challenge.clientId. + // We leave a placeholder here for pre-auth logging only. + this.clientId = ''; + + // If a socket is already in-flight (e.g. auto-reconnect opened one and the + // caller immediately invoked connect() again), RETIRE it. Retirement + // marks the socket so its handlers no-op when they eventually fire — + // otherwise the old socket's late onclose/onmessage could clobber state + // belonging to the new socket. + if (this.ws) { + this.retireSocket(this.ws); + } + + const wsUrl = this.buildWebSocketUrl(); + this.setStatus('connecting'); + + // Abort guard: setStatus emits synchronously. A listener could call + // disconnect() during that emission, which sets userDisconnected=true + // and puts status at 'closed'. If we continue, we'd open a dead socket. + // The same applies if another listener cascade rotated status away from + // 'connecting'. + if (this.userDisconnected || this.status !== 'connecting') { + return; + } + + const ws = new this.WebSocketImpl(wsUrl); + this.ws = ws; + this.socketGeneration++; + + // Arm a handshake watchdog when this socket is opened by auto-reconnect + // (pendingConnect is null). Initial connect paths already have their own + // connectTimeoutMs on pendingConnect, so we don't double-arm there. + this.clearReconnectHandshakeTimer(); + if (!this.pendingConnect) { + this.reconnectHandshakeTimer = setTimeout(() => { + this.reconnectHandshakeTimer = null; + // Only act if this is still the current socket and we're not + // authenticated yet — otherwise the watchdog is stale. + if (this.ws !== ws || this.status === 'authenticated') return; + this.closeSocket(1000, 'reconnect handshake timeout'); + this.scheduleReconnectAfterSocketFailure(); + }, this.connectTimeoutMs); + } + + // No ws.onopen handler — we transition to 'authenticating' when the + // server sends auth.challenge, not when the socket opens. + + // Handlers gate on the retiredSockets set rather than on `this.ws !== ws`. + // The reason: network-initiated closes of the current socket must still + // reach handleSocketClose() (for reconnect scheduling and pending-promise + // rejection), while replaced or intentionally retired sockets must no-op. + // A `this.ws !== ws` check would gate out both paths, so we use the + // explicit retiredSockets set to distinguish them. + ws.onmessage = (ev: MessageEvent) => { + if (this.retiredSockets.has(ws)) return; + this.handleSocketMessage(ev.data); + }; + + ws.onclose = (ev: CloseEvent) => { + if (this.retiredSockets.has(ws)) return; + this.handleSocketClose(ev.code, ev.reason); + }; + + ws.onerror = () => { + if (this.retiredSockets.has(ws)) return; + this.emitter.emit('error', { code: 'socket_error', message: 'WebSocket error' }); + }; + } + + /** + * Retire a socket without touching this.ws. Used by openSocket() when a + * replacement is being installed. Stale handlers on this socket no-op. + */ + private retireSocket(ws: WebSocket): void { + this.retiredSockets.add(ws); + try { ws.close(1000, 'replaced by new connection'); } catch { /* ignore */ } + } + + /** + * Intentionally close the CURRENT socket (disconnect, connect timeout, auth + * failure). Retires the socket so its async onclose will not re-enter + * handleSocketClose in a state where this.ws may have been repointed (in + * browsers, ws.close() fires onclose asynchronously — if the caller opens + * a new socket before that fires, the stale onclose would otherwise clobber + * the new socket's state). + * + * Because onclose is short-circuited after retirement, callers of this + * method MUST do their own synchronous lifecycle cleanup — reject + * pendingConnect/pendingAdmin, set status, stop presence sweep — BEFORE + * calling closeSocket. Do NOT rely on handleSocketClose running as a side + * effect of this call. + * + * Network-initiated closes of the current socket (server close, network + * drop) do NOT go through this method and remain handled by handleSocketClose. + */ + private closeSocket(code: number, reason: string): void { + if (!this.ws) return; + const ws = this.ws; + this.retiredSockets.add(ws); + this.ws = null; + try { + ws.close(code, reason); + } catch { + // ignore + } + } + + private handleSocketClose(code: number, reason: string): void { + this.ws = null; + // Socket is gone — any reconnect-handshake watchdog for it is moot. + this.clearReconnectHandshakeTimer(); + + // Map the close reason to roomStatus BEFORE the pendingAdmin and + // terminal-state checks below. If the client missed the preceding + // room.status broadcast (race at delete/expiry time), without this the + // admin-delete resolution and the emitted terminal state would still + // report roomStatus: 'active'. Only canonical server-sourced reasons + // map; generic 'Room unavailable' is intentionally NOT mapped because + // it doesn't identify which terminal state was reached. + if (code === WS_CLOSE_ROOM_UNAVAILABLE) { + if (reason === WS_CLOSE_REASON_ROOM_DELETED) { + this.roomStatus = 'deleted'; + } else if (reason === WS_CLOSE_REASON_ROOM_EXPIRED) { + this.roomStatus = 'expired'; + } + } + + if (this.pendingConnect) { + clearTimeout(this.pendingConnect.timeoutHandle); + const { reject } = this.pendingConnect; + this.pendingConnect = null; + // Clear pendingConnectPromise synchronously too — the microtask-scheduled + // .then cleanup runs later; during that window the invariant + // "pendingConnect <=> pendingConnectPromise" would be broken if read by + // a reentrant caller. + this.pendingConnectPromise = null; + const err = code === WS_CLOSE_ROOM_UNAVAILABLE + ? new RoomUnavailableError(reason || 'Room unavailable') + : new AuthRejectedError(`Socket closed during auth: ${reason}`); + reject(err); + // If disconnect() already fired, respect the terminal intent — don't + // overwrite 'closed' with 'disconnected' just because auth was pending. + if (this.userDisconnected) { + this.stopPresenceSweep(); + this.setStatus('closed'); + } else { + this.setStatus('disconnected'); + } + return; + } + + // Reject pending admin if socket closed mid-flight + if (this.pendingAdmin) { + const pending = this.pendingAdmin; + this.pendingAdmin = null; + clearTimeout(pending.timeoutHandle); + // For delete: only resolve when the close is specifically the server's + // successful-delete close (4006 "Room deleted") OR when this.roomStatus + // was already set to 'deleted' by a preceding room.status broadcast. + // Any other close (network drop, 'Room delete failed', etc.) must reject + // so callers don't mistakenly believe a failed/interrupted delete succeeded. + const isSuccessfulDeleteClose = + pending.command.type === 'room.delete' && + ((code === WS_CLOSE_ROOM_UNAVAILABLE && reason === WS_CLOSE_REASON_ROOM_DELETED) || + this.roomStatus === 'deleted'); + if (isSuccessfulDeleteClose) { + pending.resolve(); + } else { + pending.reject(new AdminInterruptedError()); + } + } + + this.remotePresence.clear(); + this.stopPresenceSweep(); + + // Terminal close or user-initiated? Don't reconnect. + const isTerminal = + this.userDisconnected || + code === WS_CLOSE_ROOM_UNAVAILABLE || + this.roomStatus === 'deleted' || + this.roomStatus === 'expired'; + + if (isTerminal) { + // setStatus already emits `state` on a transition; no trailing emitState + // needed (would cause a redundant React render). + this.setStatus('closed'); + return; + } + + // Auto-reconnect shares implementation with the explicit-failure path. + this.scheduleReconnect(); + } + + /** + * Explicit reconnect scheduling without waiting for onclose. Used by code + * paths that deterministically close the current socket (e.g. auth-proof + * failure during auto-reconnect) and need the client to continue the + * reconnect loop rather than sit in the closing state waiting for a + * deferred close event. + */ + private scheduleReconnectAfterSocketFailure(): void { + this.remotePresence.clear(); + this.stopPresenceSweep(); + + if ( + this.userDisconnected || + this.roomStatus === 'deleted' || + this.roomStatus === 'expired' + ) { + this.setStatus('closed'); + return; + } + + this.scheduleReconnect(); + } + + /** + * Shared reconnect scheduling: checks max-attempts, transitions to + * 'reconnecting' or 'closed', and arms the backoff timer. + */ + private scheduleReconnect(): void { + if (this.reconnectAttempt >= this.reconnectOpts.maxAttempts) { + this.setStatus('closed'); + return; + } + this.setStatus('reconnecting'); + const delay = computeBackoffMs(this.reconnectAttempt++, this.reconnectOpts); + this.reconnectTimer = setTimeout(() => { + this.reconnectTimer = null; + this.openSocket(); + }, delay); + } + + // --------------------------------------------------------------------------- + // Internal: message dispatch + // --------------------------------------------------------------------------- + + private handleSocketMessage(raw: unknown): void { + let msg: Record; + try { + const text = typeof raw === 'string' ? raw : String(raw); + msg = JSON.parse(text); + } catch { + return; // malformed server message — ignore + } + + // Auth phase messages + if (msg.type === 'auth.challenge') { + this.handleAuthChallenge(msg as unknown as AuthChallenge); + return; + } + if (msg.type === 'auth.accepted') { + this.handleAuthAccepted(msg as unknown as AuthAccepted); + return; + } + + // Admin challenge (response to admin.challenge.request) + if (msg.type === 'admin.challenge') { + this.handleAdminChallenge(msg as unknown as AdminChallenge); + return; + } + + // Transport messages — serialize via messageQueue so decrypts apply in wire order. + // Capture the socket generation at dispatch time. The handlers below + // re-check it after any async decrypt and drop the message if the + // generation has rolled (reconnect opened a new socket during the + // decrypt). This prevents stale messages from clobbering newer state. + const gen = this.socketGeneration; + if (msg.type === 'room.snapshot') { + const snap = msg as unknown as Extract; + this.enqueue(() => this.handleRoomSnapshot(snap, gen)); + return; + } + if (msg.type === 'room.event') { + const event = msg as unknown as Extract; + this.enqueue(() => this.handleRoomEvent(event, gen)); + return; + } + if (msg.type === 'room.presence') { + const presence = msg as unknown as Extract; + this.enqueue(() => this.handleRoomPresence(presence, gen)); + return; + } + if (msg.type === 'room.status') { + // Route through the queue so a status broadcast that arrives after a + // still-decrypting event doesn't resolve an admin command (e.g. lock) + // before the preceding event has been applied. + const status = (msg as { status: RoomStatus }).status; + this.enqueue(async () => { this.handleRoomStatus(status, gen); }); + return; + } + if (msg.type === 'room.error') { + // Route through the queue so an error that references a specific event + // (e.g. room_locked for an in-flight op) can't beat the event/status + // messages that preceded it in wire order. + const err = msg as unknown as Extract; + this.enqueue(async () => { this.handleRoomError(err, gen); }); + return; + } + } + + private async handleAuthChallenge(challenge: AuthChallenge): Promise { + this.setStatus('authenticating'); + // The server assigns clientId on the challenge; adopt it here so the + // proof binds to exactly the value the server will verify. Capturing + // `ws` and `clientId` locally lets the post-await guard detect a + // rotation (reconnect opened a new socket mid-proof) and drop the + // stale response. + // + // Protocol-shape validation. Missing/malformed challenge fields come from + // an old server or malformed message and must fail fast — otherwise the + // client would sit in `authenticating` until connectTimeoutMs. + const protocolError = + (typeof challenge.clientId !== 'string' || challenge.clientId.length === 0) + ? 'Missing or invalid clientId in auth.challenge' + : (typeof challenge.challengeId !== 'string' || challenge.challengeId.length === 0) + ? 'Missing or invalid challengeId in auth.challenge' + : (typeof challenge.nonce !== 'string' || challenge.nonce.length === 0) + ? 'Missing or invalid nonce in auth.challenge' + : (typeof challenge.expiresAt !== 'number' || !Number.isFinite(challenge.expiresAt)) + ? 'Missing or invalid expiresAt in auth.challenge' + : null; + if (protocolError) { + this.emitter.emit('error', { code: 'auth_error', message: protocolError }); + const currentWs = this.ws; + if (this.pendingConnect) { + clearTimeout(this.pendingConnect.timeoutHandle); + const { reject } = this.pendingConnect; + this.pendingConnect = null; + this.pendingConnectPromise = null; + this.closeSocket(1000, 'invalid auth.challenge'); + this.setStatus('disconnected'); + reject(new AuthRejectedError(protocolError)); + } else if (currentWs) { + // Auto-reconnect path — close and schedule the next attempt. + this.closeSocket(1000, 'invalid auth.challenge'); + this.scheduleReconnectAfterSocketFailure(); + } + return; + } + this.clientId = challenge.clientId; + const ws = this.ws; + const clientId = challenge.clientId; + try { + const proof = await computeAuthProof( + this.roomVerifier, + this.roomId, + clientId, + challenge.challengeId, + challenge.nonce, + ); + if (this.userDisconnected || !ws || this.ws !== ws || this.clientId !== clientId || this.status !== 'authenticating') { + return; // socket/identity rotated or cleared, or user disconnected during async proof; drop the stale response + } + const response = { + type: 'auth.response', + challengeId: challenge.challengeId, + clientId, + proof, + // When baselineInvalid, our local state is unknown relative to the + // server. Omit lastSeq so the server falls back to the snapshot path + // and re-establishes an authoritative baseline — otherwise it may + // "fast-forward" replay, skip the snapshot, and leave us silently + // stale forever. + lastSeq: !this.baselineInvalid && this.seq > 0 ? this.seq : undefined, + }; + ws.send(JSON.stringify(response)); + } catch (err) { + // Stale-identity drop: mirror the success-path guard. If the socket or + // identity rotated during the failed proof computation, the current + // pending state belongs to a NEW attempt; acting on it would clobber it. + if (this.userDisconnected || !ws || this.ws !== ws || this.clientId !== clientId) { + return; + } + // Reject pendingConnect immediately with the real error rather than waiting for timeout + const authErr = new AuthRejectedError(`Auth proof computation failed: ${String(err)}`); + this.emitter.emit('error', { code: 'auth_error', message: String(err) }); + if (this.pendingConnect) { + // Initial connect path — reject the caller and transition to disconnected. + clearTimeout(this.pendingConnect.timeoutHandle); + const { reject } = this.pendingConnect; + this.pendingConnect = null; + this.pendingConnectPromise = null; + // Synchronous cleanup: closeSocket retires the socket so its async + // onclose won't re-enter handleSocketClose. + this.closeSocket(1000, 'auth proof failed'); + this.setStatus('disconnected'); + reject(authErr); + } else if (ws && this.ws === ws) { + // Auto-reconnect path — pendingConnect is null, but we're still in + // `authenticating` from setStatus() above. Without explicit handling, + // the client would sit in 'authenticating' until the server eventually + // closes the socket. + // + // Retire the socket synchronously (so its deferred onclose no-ops via + // retiredSockets) and explicitly schedule the next reconnect attempt + // instead of waiting for the onclose round-trip. This is deterministic + // and avoids any double-transition emission. + this.closeSocket(1000, 'auth proof failed'); + this.scheduleReconnectAfterSocketFailure(); + } + } + } + + private handleAuthAccepted(accepted: AuthAccepted): void { + // Defense-in-depth: a rotated/disconnected client should not promote + // itself to 'authenticated' on a late auth.accepted from a retired socket. + if (this.userDisconnected) return; + // Handshake complete — disarm any reconnect-phase watchdog. + this.clearReconnectHandshakeTimer(); + // Do NOT clear baselineInvalid here. Authentication itself does not + // establish an authoritative baseline — only a valid snapshot apply + // does (see handleRoomSnapshot). If the previous session ended with a + // bad-snapshot baselineInvalid=true and the reconnect's lastSeq was + // (correctly) omitted, the server will send us a snapshot next; that + // snapshot's apply is what clears the flag. Clearing here would leave + // a window where post-accept events apply on stale local state. + // Settle roomStatus and clear lastError BEFORE transitioning to + // 'authenticated'. setStatus() emits the `state` event; if we flipped to + // 'authenticated' first, subscribers would briefly see + // connectionStatus='authenticated' with roomStatus=null (or stale + // lastError) — a confusing intermediate state for UI consumers. + this.roomStatus = accepted.roomStatus; + this.lastError = null; + this.setStatus('authenticated'); + // this.seq means "last server seq consumed by this client". + // Valid events advance seq after applying state. Malformed or undecryptable + // events may advance seq without state mutation to preserve replay forward + // progress (see handleRoomEvent). + // + // Do NOT advance this.seq from accepted.seq. The server sends the snapshot + // and replayed events *after* auth.accepted. If the socket drops between + // accepted and those events being consumed, the next reconnect's + // auth.response could claim lastSeq = server.seq and skip replay, leaving + // local state stale. seq advances only when an event/snapshot has actually + // been consumed by this client. + + // Start presence sweep + this.startPresenceSweep(); + + // Reset reconnect state + this.reconnectAttempt = 0; + + // Resolve pending connect + if (this.pendingConnect) { + clearTimeout(this.pendingConnect.timeoutHandle); + const { resolve } = this.pendingConnect; + this.pendingConnect = null; + // Keep the invariant literally true: clear the promise ref synchronously + // alongside pendingConnect rather than waiting for the microtask cleanup. + this.pendingConnectPromise = null; + resolve(); + } + + this.emitter.emit('room-status', accepted.roomStatus); + this.emitState(); + } + + private async handleRoomSnapshot( + msg: Extract, + gen: number, + ): Promise { + // Pre-decrypt socket-generation guard: drop snapshots that arrived on a + // now-retired socket BEFORE spending decrypt time on them. Mirrors the + // pre-decrypt check in handleRoomEvent/handleRoomPresence. + if (gen !== this.socketGeneration) return; + try { + const snapshot = await decryptSnapshot(this.eventKey, msg.snapshotCiphertext); + // Post-decrypt re-check: if reconnect rolled the socket while we were + // decrypting, this snapshot belongs to the retired session and must + // not mutate current state or the newer socket's baseline. + if (gen !== this.socketGeneration) return; + // Encryption only proves the sender held the room key. Validate shape + // before replacing state — a malformed snapshot would corrupt the view. + if (!isRoomSnapshot(snapshot)) { + // Snapshot is the highest-impact inbound message (it establishes or + // replaces the entire baseline). Surface failures via lastError + + // state so hook consumers subscribed only to `state` can react. + // Also mark the baseline invalid so subsequent room.events cannot + // apply on top of stale local state until a valid snapshot lands. + this.baselineInvalid = true; + const err = { code: 'snapshot_malformed', message: 'Snapshot payload failed shape validation' }; + this.lastError = err; + this.emitter.emit('error', err); + this.emitState(); + return; + } + // Valid snapshot — baseline is authoritative again. + this.baselineInvalid = false; + this.planMarkdown = snapshot.planMarkdown; + this.annotations.clear(); + // Defensive clone on store: the decrypted snapshot payload is untrusted + // shape-wise AND is a freshly-allocated JSON object that we might also + // emit to external subscribers; cloning here guarantees later mutations + // to the emitted snapshot cannot reach back into our internal map. + for (const ann of snapshot.annotations) { + this.annotations.set(ann.id, cloneRoomAnnotation(ann)); + } + // A received snapshot is the authoritative baseline — set seq to + // msg.snapshotSeq unconditionally. If we only raised seq when + // snapshotSeq > this.seq, a client whose local seq was somehow ahead + // (e.g. a corrupted reconnect state or the server's "future claim" + // fallback) would keep sending that bad lastSeq on subsequent + // reconnects and never self-repair. + this.seq = msg.snapshotSeq; + // Emit a cloned snapshot so direct event subscribers can mutate freely. + this.emitter.emit('snapshot', { + ...snapshot, + annotations: snapshot.annotations.map(cloneRoomAnnotation), + }); + this.emitState(); + } catch (err) { + // Socket-generation guard: decrypt failed on a stale socket; do not + // mark the NEW session's baseline invalid. + if (gen !== this.socketGeneration) return; + // Baseline establishment failed — block event application until the + // next valid snapshot or reconnect clears the flag. + this.baselineInvalid = true; + const payload = { code: 'snapshot_decrypt_failed', message: String(err) }; + this.lastError = payload; + this.emitter.emit('error', payload); + this.emitState(); + } + } + + private async handleRoomEvent( + msg: Extract, + gen: number, + ): Promise { + const { seq, envelope } = msg; + + // Socket-generation guard: drop events that arrived on a now-retired + // socket. This must run BEFORE the stale-seq check because a retired + // socket might deliver seq values that look valid relative to the + // current socket's (possibly-different) seq. + if (gen !== this.socketGeneration) return; + + // Stale-event guard: drop anything at-or-below our consumed seq. This + // can happen on reconnect replay if the server re-sends events we + // already consumed, or on a dup from a server-side hiccup. We must not + // decrypt, validate, apply, OR emit — doing any of those would lie + // about local state having changed. + if (seq <= this.seq) { + return; + } + + // Baseline-invalid guard: a prior snapshot decrypt/shape failure left + // local state in an unknown relation to the server. Applying events on + // top of that is silent divergence. Consume the seq for forward progress + // and keep surfacing the (already-set) snapshot error via state, but do + // not apply. + if (this.baselineInvalid) { + this.seq = seq; + this.emitState(); + return; + } + + // V1: no optimistic apply and no echo dedup. Every room.event (including + // our own echoes) is applied here. The server's event log is the authority; + // replay after reconnect also funnels through this path. + try { + const decrypted = await decryptEventPayload(this.eventKey, envelope.ciphertext); + // Post-decrypt generation guard — reconnect could have rolled the + // socket while we were decrypting. + if (gen !== this.socketGeneration) return; + // Encryption only proves the sender had the room key. Reject malformed + // ops (e.g. annotation.add with null id/type fields) before they hit + // applyAnnotationEvent and corrupt state. + // Narrow validator: event-channel ops only. presence.update must NOT + // be accepted here — it would otherwise land in the durable event log. + if (!isRoomEventClientOp(decrypted)) { + const err = { + code: 'event_malformed', + message: `Malformed event op from clientId=${envelope.clientId} at seq=${seq}`, + }; + this.lastError = err; + this.emitter.emit('error', err); + // V1 forward-progress policy: the server has already sequenced and + // persisted this event, so NOT advancing this.seq would cause the + // same malformed event to replay on every reconnect (and block all + // subsequent valid events 43+). Advance seq, apply nothing, emit the + // error. This makes a single malformed event lossy but keeps the + // replay stream unblocked, and prevents a malicious participant from + // poisoning the client's replay state. + this.seq = seq; + this.emitState(); + return; + } + const op = decrypted; + const event = this.clientOpToServerEvent(op); + const result = applyAnnotationEvent(this.annotations, event); + // Consume the seq regardless — forward-progress (same rationale as the + // malformed-op branch above). + this.seq = seq; + if (!result.applied) { + // Op was shape-valid but produced an invalid final state (e.g. an + // annotation.update merge that violates the cross-field invariants). + // Surface as an error on state; do NOT emit `event` — listeners must + // not see a notification for an op that didn't actually change state. + const err = { + code: 'event_rejected_by_reducer', + message: `Event at seq=${seq} rejected by reducer: ${result.reason ?? 'unknown'}`, + }; + this.lastError = err; + this.emitter.emit('error', err); + this.emitState(); + return; + } + // Emit a cloned event so direct event subscribers can mutate freely + // without reaching into our internal annotations map. + this.emitter.emit('event', cloneRoomServerEvent(event)); + this.emitState(); + } catch (err) { + // Post-decrypt generation guard — don't mutate newer socket's state + // from a stale decrypt failure. + if (gen !== this.socketGeneration) return; + const payload = { code: 'event_decrypt_failed', message: String(err) }; + this.lastError = payload; + this.emitter.emit('error', payload); + // Same forward-progress policy as malformed events — the server has + // already sequenced this event, so we must advance seq or the same + // undecryptable payload will replay on every reconnect indefinitely. + // Stale-seq guard at the top of this method already ruled out seq <= this.seq, + // so unconditional assignment here is safe. + this.seq = seq; + this.emitState(); + } + } + + private async handleRoomPresence( + msg: Extract, + gen: number, + ): Promise { + // Pre-decrypt generation guard: skip presence from retired sockets. + if (gen !== this.socketGeneration) return; + try { + const presence = await decryptPresence(this.presenceKey, msg.envelope.ciphertext); + // Post-decrypt generation guard — reconnect could have rolled during decrypt. + if (gen !== this.socketGeneration) return; + // Encryption only proves the sender has the room key. Validate the shape + // before letting it into client state to prevent malformed-presence attacks + // from crashing UI render code. + if (!isPresenceState(presence)) { + const err = { + code: 'presence_malformed', + message: `Malformed presence from clientId=${msg.envelope.clientId}`, + }; + this.lastError = err; + this.emitter.emit('error', err); + this.emitState(); + return; + } + // Store a clone so subsequent mutations to the decrypted/emitted object + // can't reach back into our internal remotePresence map. + this.remotePresence.set(msg.envelope.clientId, { + presence: clonePresenceState(presence), + lastSeen: Date.now(), + }); + this.emitter.emit('presence', { + clientId: msg.envelope.clientId, + presence: clonePresenceState(presence), + }); + this.emitState(); + } catch (err) { + // Post-decrypt generation guard. + if (gen !== this.socketGeneration) return; + const payload = { code: 'presence_decrypt_failed', message: String(err) }; + this.lastError = payload; + this.emitter.emit('error', payload); + this.emitState(); + } + } + + private handleRoomStatus(status: RoomStatus, gen: number): void { + // Drop status broadcasts from retired sockets. + if (gen !== this.socketGeneration) return; + this.roomStatus = status; + this.emitter.emit('room-status', status); + + // V1 assumes a single creator-held admin capability. We resolve admin commands + // by observing room.status because the server does not emit command-specific + // admin acknowledgements. If multi-admin rooms are supported later, replace this + // with commandId-based admin.result acks from the room service. + if (this.pendingAdmin) { + const pending = this.pendingAdmin; + const cmdType = pending.command.type; + + let shouldResolve = false; + if (cmdType === 'room.lock' && status === 'locked') shouldResolve = true; + else if (cmdType === 'room.unlock' && status === 'active') shouldResolve = true; + else if (cmdType === 'room.delete' && status === 'deleted') shouldResolve = true; + + if (shouldResolve) { + clearTimeout(pending.timeoutHandle); + this.pendingAdmin = null; + pending.resolve(); + } + } + + this.emitState(); + } + + private handleRoomError( + msg: Extract, + gen: number, + ): void { + // Drop errors from retired sockets — they reference operations on a + // session the client has already moved past. + if (gen !== this.socketGeneration) return; + this.lastError = { code: msg.code, message: msg.message }; + this.emitter.emit('error', { code: msg.code, message: msg.message }); + + // Reject pending admin ONLY for admin-scoped error codes. Event-channel + // errors like `room_locked` / `validation_error` can land while an admin + // command is in flight (e.g. a concurrent annotation op hit the locked + // room just after a lock was accepted); rejecting pendingAdmin on those + // would fail a successful admin command whose `room.status: locked` is + // still in-flight. Admin-scoped codes are the ones the server emits + // exclusively from the admin command path. + if (this.pendingAdmin && ADMIN_SCOPED_ERROR_CODES.has(msg.code)) { + const pending = this.pendingAdmin; + clearTimeout(pending.timeoutHandle); + this.pendingAdmin = null; + pending.reject(new AdminRejectedError(msg.code, msg.message)); + } + + this.emitState(); + } + + // --------------------------------------------------------------------------- + // Internal: sending ops + // --------------------------------------------------------------------------- + + private sendOp(op: RoomEventClientOp): Promise { + // Synchronous precondition checks — fail fast before enqueuing. + this.assertConnected(); + if (this.roomStatus !== 'active') { + throw new Error(`Cannot send annotation op in room status "${this.roomStatus ?? 'unknown'}"`); + } + // Chain onto the outbound queue so concurrent calls send in CALL order, + // not encryption-completion order. Without this, a user adding then + // removing an annotation in quick succession could see the remove land + // first (empty payloads encrypt faster), leaving the annotation that + // the remove was meant to delete. + const next = this.outboundEventQueue.then(async () => { + // Re-check liveness inside the queue — a disconnect/lock could have + // landed while we were waiting our turn. + this.assertConnected(); + if (this.roomStatus !== 'active') { + throw new Error(`Cannot send annotation op in room status "${this.roomStatus ?? 'unknown'}"`); + } + + const opId = generateOpId(); + const ciphertext = await encryptEventOp(this.eventKey, op); + + // Recheck socket AND roomStatus after async encryption. A queued + // `room.status: locked` applied during the encrypt would otherwise let + // us send an op the server will reject — the user would see the + // mutation resolve as "sent" and only learn from async lastError. + const ws = this.ws; + if (this.status !== 'authenticated' || !ws) { + throw new NotConnectedError(); + } + if (this.roomStatus !== 'active') { + throw new Error(`Cannot send annotation op in room status "${this.roomStatus ?? 'unknown'}"`); + } + + const envelope: ServerEnvelope = { + clientId: this.clientId, + opId, + channel: 'event', + ciphertext, + }; + + // V1 policy: server echo is authoritative. We do NOT apply annotation + // ops optimistically. See class header for full rationale. + ws.send(JSON.stringify(envelope)); + }); + // Keep the chain alive even if this op rejects — later ops must still + // serialize. The caller's returned promise surfaces the rejection. + this.outboundEventQueue = next.catch(() => { /* swallow; caller sees it */ }); + return next; + } + + // Event-channel ops only. Presence is a separate channel with its own + // encryption and dispatch; it never flows through this converter. + private clientOpToServerEvent(op: RoomEventClientOp): RoomServerEvent { + switch (op.type) { + case 'annotation.add': + return { type: 'annotation.add', annotations: op.annotations }; + case 'annotation.update': + return { type: 'annotation.update', id: op.id, patch: op.patch }; + case 'annotation.remove': + return { type: 'annotation.remove', ids: op.ids }; + case 'annotation.clear': + return { type: 'annotation.clear', source: op.source }; + } + } + + // --------------------------------------------------------------------------- + // Internal: admin flow + // --------------------------------------------------------------------------- + + private async runAdminCommand(command: AdminCommand): Promise { + this.assertConnected(); + if (this.pendingAdmin) { + throw new Error('Another admin command is pending'); + } + + return new Promise((resolve, reject) => { + const ws = this.ws; + if (!ws) { + reject(new NotConnectedError()); + return; + } + + const timeoutHandle = setTimeout(() => { + if (this.pendingAdmin) { + this.pendingAdmin = null; + reject(new AdminTimeoutError()); + } + }, ADMIN_COMMAND_TIMEOUT_MS); + + this.pendingAdmin = { command, resolve, reject, timeoutHandle }; + + // Request admin challenge. If send() throws synchronously, don't leave + // pendingAdmin stuck until timeout — clear it and propagate the error. + try { + ws.send(JSON.stringify({ type: 'admin.challenge.request' })); + } catch (err) { + clearTimeout(timeoutHandle); + this.pendingAdmin = null; + reject(err instanceof Error ? err : new Error(String(err))); + } + }); + } + + private async handleAdminChallenge(challenge: AdminChallenge): Promise { + const pending = this.pendingAdmin; + if (!pending || !this.adminVerifier) return; + + // Capture socket and identity now. Mirror of handleAuthChallenge: if a + // rotation happens mid-await, the stale admin proof (bound to the old + // clientId) must not be sent on the replacement socket. + const ws = this.ws; + const clientId = this.clientId; + + try { + const proof = await computeAdminProof( + this.adminVerifier, + this.roomId, + clientId, + challenge.challengeId, + challenge.nonce, + pending.command, + ); + if (!ws || this.ws !== ws || this.clientId !== clientId || this.pendingAdmin !== pending) { + return; // socket/identity/pending rotated during async proof; drop stale response + } + ws.send(JSON.stringify({ + type: 'admin.command', + challengeId: challenge.challengeId, + clientId, + command: pending.command, + adminProof: proof, + })); + // Promise stays pending — resolves via room.status or socket close + } catch (err) { + // Stale-identity drop: mirror the success-path guard. If the socket, + // identity, or pending-admin slot rotated during the failed proof, the + // current pendingAdmin belongs to a NEW admin command; do not clear it + // and do not reject — the original caller's `pending` promise still + // gets rejected below so nothing is leaked, but current client state + // stays untouched. + if (this.userDisconnected || !ws || this.ws !== ws || this.clientId !== clientId || this.pendingAdmin !== pending) { + // Still clear the ORIGINAL pending's timeout so it doesn't leak, even + // though we're not touching the current pendingAdmin slot (which + // belongs to a NEW command after the rotation). + clearTimeout(pending.timeoutHandle); + pending.reject(err instanceof Error ? err : new Error(String(err))); + return; + } + clearTimeout(pending.timeoutHandle); + this.pendingAdmin = null; + pending.reject(err instanceof Error ? err : new Error(String(err))); + } + } + + // --------------------------------------------------------------------------- + // Internal: helpers + // --------------------------------------------------------------------------- + + private buildWebSocketUrl(): string { + const base = new URL(this.baseUrl); + const wsScheme = base.protocol === 'https:' ? 'wss:' : 'ws:'; + return `${wsScheme}//${base.host}/ws/${this.roomId}`; + } + + private assertConnected(): void { + if (this.status !== 'authenticated' || !this.ws) { + throw new NotConnectedError(); + } + } + + private setStatus(status: ConnectionStatus): void { + if (this.status === status) return; + this.status = status; + this.emitter.emit('status', status); + // Also emit state so consumers subscribed only to `state` (e.g. useCollabRoom) + // see connecting/authenticating/reconnecting transitions. + this.emitter.emit('state', this.buildState()); + } + + private buildState(): CollabRoomState { + // Clone every value exposed through getState()/state events. V1's + // server-authoritative model means local state mutations must come ONLY + // from decrypted server events; if a consumer (UI code) accidentally + // mutated a returned annotation or cursor, they'd corrupt local state + // with no server echo. Returning fresh clones makes getState() an + // isolated snapshot — it is not frozen, but mutation by the caller + // does not reach back into the client's internal state. + const presence: Record = {}; + for (const [clientId, entry] of this.remotePresence) { + presence[clientId] = clonePresenceState(entry.presence); + } + return { + connectionStatus: this.status, + roomStatus: this.roomStatus, + roomId: this.roomId, + clientId: this.clientId, + seq: this.seq, + planMarkdown: this.planMarkdown, + annotations: annotationsToArray(this.annotations).map(cloneRoomAnnotation), + remotePresence: presence, + hasAdminCapability: this.adminKey !== null, + lastError: this.lastError ? { ...this.lastError } : null, + }; + } + + private emitState(): void { + this.emitter.emit('state', this.buildState()); + } + + private startPresenceSweep(): void { + if (this.presenceSweepTimer) return; + this.presenceSweepTimer = setInterval(() => { + const now = Date.now(); + let pruned = false; + for (const [clientId, entry] of this.remotePresence) { + if (now - entry.lastSeen > this.presenceTtlMs) { + this.remotePresence.delete(clientId); + pruned = true; + } + } + if (pruned) this.emitState(); + }, this.presenceSweepIntervalMs); + } + + private stopPresenceSweep(): void { + if (this.presenceSweepTimer) { + clearInterval(this.presenceSweepTimer); + this.presenceSweepTimer = null; + } + } + + private clearReconnectTimer(): void { + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } + } + + private clearReconnectHandshakeTimer(): void { + if (this.reconnectHandshakeTimer) { + clearTimeout(this.reconnectHandshakeTimer); + this.reconnectHandshakeTimer = null; + } + } + + /** + * Chain an async task on the serialized message queue. + * + * Two-arg .then(task, task) is intentional: if the previous queue entry + * rejected, we still want the NEXT task to run (forward progress — we're + * serializing for ordering, not coupling failures). The trailing .catch + * then swallows any rejection from the task itself so one failed handler + * doesn't permanently poison the chain with an unhandled rejection. + * Individual task errors are already surfaced via `error` events inside + * the handlers themselves. + */ + private enqueue(task: () => Promise): void { + this.messageQueue = this.messageQueue.then(task, task).catch(() => { /* swallow */ }); + } +} diff --git a/packages/shared/collab/client-runtime/create-room.test.ts b/packages/shared/collab/client-runtime/create-room.test.ts new file mode 100644 index 00000000..2dec3079 --- /dev/null +++ b/packages/shared/collab/client-runtime/create-room.test.ts @@ -0,0 +1,92 @@ +/** + * Unit tests for createRoom() — focuses on the timeout + AbortSignal behavior. + * Happy-path round-trips are covered by integration.test.ts against wrangler dev. + */ + +import { describe, expect, test } from 'bun:test'; +import { createRoom, CreateRoomError } from './create-room'; +import type { CollabRoomUser } from './types'; +import type { RoomSnapshot } from '../types'; + +const USER: CollabRoomUser = { id: 'u1', name: 'alice', color: '#f00' }; +const SNAPSHOT: RoomSnapshot = { versionId: 'v1', planMarkdown: '# Plan', annotations: [] }; + +// A fetch impl that never resolves until its signal aborts. Mirrors the real +// AbortSignal wiring: when aborted, reject with an AbortError-like error. +function hangingFetch(): typeof fetch { + return ((_input: RequestInfo | URL, init?: RequestInit) => { + return new Promise((_resolve, reject) => { + const signal = init?.signal; + if (!signal) return; // without a signal, hang forever (caller bug) + if (signal.aborted) { + reject(signal.reason ?? new Error('aborted')); + return; + } + signal.addEventListener('abort', () => { + reject(signal.reason ?? new Error('aborted')); + }, { once: true }); + }); + }) as typeof fetch; +} + +describe('createRoom() — timeout and AbortSignal', () => { + test('rejects with CreateRoomError when the server does not respond within timeoutMs', async () => { + const start = Date.now(); + const promise = createRoom({ + baseUrl: 'http://localhost:9', + initialSnapshot: SNAPSHOT, + user: USER, + fetchImpl: hangingFetch(), + timeoutMs: 100, + }); + + // Timeout must fire — no stuck promise. Error message mentions 'timed out' + // so callers can distinguish it from a transport failure. + await expect(promise).rejects.toBeInstanceOf(CreateRoomError); + await expect(promise).rejects.toMatchObject({ message: expect.stringContaining('timed out') }); + + const elapsed = Date.now() - start; + expect(elapsed).toBeGreaterThanOrEqual(90); // near timeoutMs + expect(elapsed).toBeLessThan(1000); // definitely not hanging + }); + + test('rejects immediately when the external signal is already aborted', async () => { + const controller = new AbortController(); + controller.abort(); // pre-aborted + + const start = Date.now(); + const promise = createRoom({ + baseUrl: 'http://localhost:9', + initialSnapshot: SNAPSHOT, + user: USER, + fetchImpl: hangingFetch(), + signal: controller.signal, + timeoutMs: 60_000, // high — signal must short-circuit well before this + }); + + await expect(promise).rejects.toBeInstanceOf(CreateRoomError); + const elapsed = Date.now() - start; + expect(elapsed).toBeLessThan(100); // synchronous early rejection + }); + + test('rejects when the external signal aborts mid-fetch', async () => { + const controller = new AbortController(); + const start = Date.now(); + const promise = createRoom({ + baseUrl: 'http://localhost:9', + initialSnapshot: SNAPSHOT, + user: USER, + fetchImpl: hangingFetch(), + signal: controller.signal, + timeoutMs: 60_000, + }); + + // Abort after a short delay — must interrupt the hanging fetch. + setTimeout(() => controller.abort(new Error('user cancelled')), 50); + + await expect(promise).rejects.toBeInstanceOf(CreateRoomError); + const elapsed = Date.now() - start; + expect(elapsed).toBeGreaterThanOrEqual(40); + expect(elapsed).toBeLessThan(1000); + }); +}); diff --git a/packages/shared/collab/client-runtime/create-room.ts b/packages/shared/collab/client-runtime/create-room.ts new file mode 100644 index 00000000..f3dada8d --- /dev/null +++ b/packages/shared/collab/client-runtime/create-room.ts @@ -0,0 +1,130 @@ +/** + * createRoom — HTTP helper that creates a room on room-service and returns + * a ready-to-connect CollabRoomClient plus the URLs and raw secrets. + * + * Client-side only. Runs in browsers, Bun, and direct-agent environments. + */ + +import { + deriveRoomKeys, + deriveAdminKey, + computeRoomVerifier, + computeAdminVerifier, + encryptSnapshot, +} from '../crypto'; +import { generateRoomId, generateRoomSecret, generateAdminSecret } from '../ids'; +import { isRoomSnapshot } from '../types'; +import { buildRoomJoinUrl, buildAdminRoomUrl } from '../url'; +import type { CreateRoomRequest } from '../types'; +import { CollabRoomClient, InvalidOutboundPayloadError } from './client'; +import type { CreateRoomOptions, CreateRoomResult } from './types'; + +export class CreateRoomError extends Error { + constructor(public status: number, message: string) { + super(message); + this.name = 'CreateRoomError'; + } +} + +export async function createRoom(options: CreateRoomOptions): Promise { + // Validate the initial snapshot BEFORE any network/crypto work. A UI bug + // that passes a malformed snapshot should fail immediately and clearly + // instead of after a fetch round-trip the server will reject. + if (!isRoomSnapshot(options.initialSnapshot)) { + throw new InvalidOutboundPayloadError('Invalid initialSnapshot payload'); + } + + const roomId = generateRoomId(); + const roomSecret = generateRoomSecret(); + const adminSecret = generateAdminSecret(); + + const { authKey, eventKey, presenceKey } = await deriveRoomKeys(roomSecret); + const adminKey = await deriveAdminKey(adminSecret); + const roomVerifier = await computeRoomVerifier(authKey, roomId); + const adminVerifier = await computeAdminVerifier(adminKey, roomId); + const initialSnapshotCiphertext = await encryptSnapshot(eventKey, options.initialSnapshot); + + const body: CreateRoomRequest = { + roomId, + roomVerifier, + adminVerifier, + initialSnapshotCiphertext, + expiresInDays: options.expiresInDays, + }; + + const fetchFn = options.fetchImpl ?? fetch; + // new URL() handles trailing slashes correctly regardless of caller hygiene. + const apiUrl = new URL('/api/rooms', options.baseUrl).toString(); + + // Timeout + external-signal cancellation. Without this, a server hang or + // a dropped connection would leave createRoom() pending indefinitely, and + // the caller has no way to bail. Compose the two signals via AbortController + // so either source aborts the fetch. + const timeoutMs = options.timeoutMs ?? 10_000; + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(new CreateRoomError(0, 'createRoom timed out')), timeoutMs); + const externalAbort = () => controller.abort(options.signal?.reason); + if (options.signal) { + if (options.signal.aborted) { + clearTimeout(timeoutId); + throw new CreateRoomError(0, 'createRoom aborted before start'); + } + options.signal.addEventListener('abort', externalAbort, { once: true }); + } + + let res: Response; + try { + res = await fetchFn(apiUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + signal: controller.signal, + }); + } catch (err) { + // Distinguish timeout / external abort / transport failure for the caller. + if (controller.signal.aborted) { + const reason = controller.signal.reason; + throw reason instanceof CreateRoomError + ? reason + : new CreateRoomError(0, `createRoom aborted: ${String(reason ?? err)}`); + } + throw new CreateRoomError(0, `createRoom fetch failed: ${String(err)}`); + } finally { + clearTimeout(timeoutId); + options.signal?.removeEventListener('abort', externalAbort); + } + + if (res.status !== 201) { + let message = `createRoom failed with status ${res.status}`; + try { + const errBody = await res.json() as { error?: string }; + if (errBody.error) message = errBody.error; + } catch { /* ignore */ } + throw new CreateRoomError(res.status, message); + } + + // Success. Do NOT parse the response body — we already have everything + // needed (roomId, secrets, locally-built URLs, derived keys). Parsing an + // empty, malformed, or future-format body could strand the user from a + // room that already exists and whose only admin secret lives in memory. + // Protocol neatness is less important than not losing recovery material. + + const joinUrl = buildRoomJoinUrl(roomId, roomSecret, options.baseUrl); + const adminUrl = buildAdminRoomUrl(roomId, roomSecret, adminSecret, options.baseUrl); + + const client = new CollabRoomClient({ + roomId, + baseUrl: options.baseUrl, + eventKey, + presenceKey, + adminKey, + roomVerifier, + adminVerifier, + user: options.user, + initialSnapshot: options.initialSnapshot, + webSocketImpl: options.webSocketImpl, + reconnect: options.reconnect, + }); + + return { roomId, roomSecret, adminSecret, joinUrl, adminUrl, client }; +} diff --git a/packages/shared/collab/client-runtime/emitter.test.ts b/packages/shared/collab/client-runtime/emitter.test.ts new file mode 100644 index 00000000..17d57ffb --- /dev/null +++ b/packages/shared/collab/client-runtime/emitter.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, test, mock } from 'bun:test'; +import { TypedEventEmitter } from './emitter'; + +interface Events { + foo: number; + bar: { message: string }; +} + +describe('TypedEventEmitter', () => { + test('emits to subscribed listeners', () => { + const e = new TypedEventEmitter(); + const fn = mock(() => {}); + e.on('foo', fn); + e.emit('foo', 42); + expect(fn).toHaveBeenCalledWith(42); + }); + + test('unsubscribe function removes listener', () => { + const e = new TypedEventEmitter(); + const fn = mock(() => {}); + const unsub = e.on('foo', fn); + e.emit('foo', 1); + unsub(); + e.emit('foo', 2); + expect(fn).toHaveBeenCalledTimes(1); + }); + + test('off removes a specific listener', () => { + const e = new TypedEventEmitter(); + const fn1 = mock(() => {}); + const fn2 = mock(() => {}); + e.on('foo', fn1); + e.on('foo', fn2); + e.off('foo', fn1); + e.emit('foo', 1); + expect(fn1).not.toHaveBeenCalled(); + expect(fn2).toHaveBeenCalled(); + }); + + test('isolates listener errors', () => { + const e = new TypedEventEmitter(); + const fn1 = mock(() => { throw new Error('boom'); }); + const fn2 = mock(() => {}); + e.on('foo', fn1); + e.on('foo', fn2); + // Should not throw + e.emit('foo', 1); + expect(fn2).toHaveBeenCalled(); + }); + + test('removeAll clears listeners', () => { + const e = new TypedEventEmitter(); + const fn = mock(() => {}); + e.on('foo', fn); + e.removeAll(); + e.emit('foo', 1); + expect(fn).not.toHaveBeenCalled(); + }); + + test('emitting with no listeners is safe', () => { + const e = new TypedEventEmitter(); + // Should not throw + e.emit('foo', 1); + }); + + test('supports multiple event types', () => { + const e = new TypedEventEmitter(); + const fooFn = mock(() => {}); + const barFn = mock(() => {}); + e.on('foo', fooFn); + e.on('bar', barFn); + e.emit('foo', 1); + e.emit('bar', { message: 'hi' }); + expect(fooFn).toHaveBeenCalledWith(1); + expect(barFn).toHaveBeenCalledWith({ message: 'hi' }); + }); +}); diff --git a/packages/shared/collab/client-runtime/emitter.ts b/packages/shared/collab/client-runtime/emitter.ts new file mode 100644 index 00000000..9ea7da13 --- /dev/null +++ b/packages/shared/collab/client-runtime/emitter.ts @@ -0,0 +1,50 @@ +/** + * Tiny typed event emitter for the collab room client runtime. + * + * Returns an unsubscribe function from `on()` for clean React useEffect teardown. + * Wraps listener calls in try/catch so one throwing listener doesn't break others. + */ + +export class TypedEventEmitter> { + private listeners: { [K in keyof M]?: Set<(payload: M[K]) => void> } = {}; + + /** Subscribe to an event. Returns an unsubscribe function. */ + on(name: K, fn: (payload: M[K]) => void): () => void { + let set = this.listeners[name]; + if (!set) { + set = new Set(); + this.listeners[name] = set; + } + set.add(fn); + return () => this.off(name, fn); + } + + /** Remove a specific listener. */ + off(name: K, fn: (payload: M[K]) => void): void { + this.listeners[name]?.delete(fn); + } + + /** Emit an event. Listener errors are isolated. */ + emit(name: K, payload: M[K]): void { + const set = this.listeners[name]; + if (!set) return; + // Snapshot listeners BEFORE iterating so listeners added during emission + // don't fire in the same pass (surprising semantics) and listeners + // removed during emission don't throw the iterator. + const snapshot = [...set]; + for (const fn of snapshot) { + try { + fn(payload); + } catch (err) { + // Isolate listener errors so one bad listener doesn't break others. + // Log but don't re-throw. + console.error(`[TypedEventEmitter] listener for "${String(name)}" threw:`, err); + } + } + } + + /** Remove all listeners (useful for teardown). */ + removeAll(): void { + this.listeners = {}; + } +} diff --git a/packages/shared/collab/client-runtime/index.ts b/packages/shared/collab/client-runtime/index.ts new file mode 100644 index 00000000..f0c89b96 --- /dev/null +++ b/packages/shared/collab/client-runtime/index.ts @@ -0,0 +1,20 @@ +/** + * Client runtime barrel. Re-exports public API for browser + direct-agent clients. + * + * Consumers typically import from `@plannotator/shared/collab/client`, which + * re-exports this barrel plus base types. + */ + +export * from './client'; +export * from './create-room'; +export * from './join-room'; +export type { + ConnectionStatus, + CollabRoomUser, + CollabRoomState, + CollabRoomEvents, + CreateRoomOptions, + CreateRoomResult, + JoinRoomOptions, + ReconnectOptions, +} from './types'; diff --git a/packages/shared/collab/client-runtime/integration.test.ts b/packages/shared/collab/client-runtime/integration.test.ts new file mode 100644 index 00000000..6b96b28b --- /dev/null +++ b/packages/shared/collab/client-runtime/integration.test.ts @@ -0,0 +1,303 @@ +/** + * Integration tests for the collab client runtime against a live wrangler dev Worker. + * + * Gated by SMOKE_BASE_URL env var. Skipped when unset. + * + * Usage: + * cd apps/room-service && bunx wrangler dev + * # In another terminal: + * SMOKE_BASE_URL=http://localhost:8787 bun test packages/shared/collab/client-runtime/integration.test.ts + */ + +import { describe, expect, test } from 'bun:test'; +import { createRoom, joinRoom } from './index'; +import type { CollabRoomClient } from './client'; +import type { RoomSnapshot } from '../types'; + +const BASE_URL = process.env.SMOKE_BASE_URL; +const shouldRun = !!BASE_URL; + +const USER_A = { id: 'user-a', name: 'alice', color: '#f00' }; +const USER_B = { id: 'user-b', name: 'bob', color: '#0f0' }; + +const describeFn = shouldRun ? describe : describe.skip; + +function safeDisconnect(client: CollabRoomClient | null): void { + if (!client) return; + try { client.disconnect(); } catch { /* ignore — best-effort cleanup */ } +} + +describeFn('CollabRoomClient integration (against wrangler dev)', () => { + test('createRoom, two clients exchange event + presence, admin lock/unlock/delete', async () => { + let creator: CollabRoomClient | null = null; + let participant: CollabRoomClient | null = null; + let adminClient: CollabRoomClient | null = null; + + try { + const snapshot: RoomSnapshot = { + versionId: 'v1', + planMarkdown: '# Integration test', + annotations: [], + }; + + // Creator creates the room and connects + const created = await createRoom({ + baseUrl: BASE_URL!, + initialSnapshot: snapshot, + user: USER_A, + }); + creator = created.client; + const { joinUrl, adminUrl } = created; + await creator.connect(); + expect(creator.getState().connectionStatus).toBe('authenticated'); + + // Second participant joins via joinUrl + participant = await joinRoom({ + url: joinUrl, + user: USER_B, + autoConnect: true, + }); + expect(participant.getState().connectionStatus).toBe('authenticated'); + + // Creator sends an annotation — participant should see it + const ann = { + id: 'int-ann-1', + blockId: 'b1', + startOffset: 0, + endOffset: 5, + type: 'COMMENT' as const, + originalText: 'hello', + createdA: Date.now(), + text: 'from creator', + }; + await creator.sendAnnotationAdd([ann]); + await new Promise(r => setTimeout(r, 500)); + expect(participant.getState().annotations.map(a => a.id)).toContain('int-ann-1'); + + // Admin (creator) joins via adminUrl to exercise admin capability + adminClient = await joinRoom({ url: adminUrl, user: USER_A, autoConnect: true }); + expect(adminClient.getState().hasAdminCapability).toBe(true); + + // Admin locks the room + await adminClient.lockRoom(); + await new Promise(r => setTimeout(r, 200)); + expect(adminClient.getState().roomStatus).toBe('locked'); + expect(creator.getState().roomStatus).toBe('locked'); + expect(participant.getState().roomStatus).toBe('locked'); + + // Admin unlocks + await adminClient.unlockRoom(); + await new Promise(r => setTimeout(r, 200)); + expect(adminClient.getState().roomStatus).toBe('active'); + + // Admin deletes + await adminClient.deleteRoom(); + await new Promise(r => setTimeout(r, 500)); + } finally { + safeDisconnect(creator); + safeDisconnect(participant); + safeDisconnect(adminClient); + } + }, 30_000); + + test('lock → unlock → lock with includeFinalSnapshot at the same seq succeeds (no same-seq regression)', async () => { + // Regression: prior server rule `atSeq <= existingSnapshotSeq` rejected + // the second lock when no events arrived between unlock and re-lock. + // New rule: same-seq is allowed but the stored snapshot is preserved + // (no split-brain overwrite). + let creator: CollabRoomClient | null = null; + let participant: CollabRoomClient | null = null; + let adminUrl: string | null = null; + + try { + const snapshot: RoomSnapshot = { + versionId: 'v1', + planMarkdown: '# Lock cycle test', + annotations: [], + }; + const created = await createRoom({ + baseUrl: BASE_URL!, + initialSnapshot: snapshot, + user: USER_A, + }); + creator = created.client; + adminUrl = created.adminUrl; + await creator.connect(); + + // Participant joins so creator can send an event and advance seq. + participant = await joinRoom({ url: created.joinUrl, user: USER_B, autoConnect: true }); + + const ann = { + id: 'lock-cycle-1', + blockId: 'b1', startOffset: 0, endOffset: 5, + type: 'COMMENT' as const, + originalText: 'x', createdA: Date.now(), text: 'before first lock', + }; + await creator.sendAnnotationAdd([ann]); + await waitFor(() => + creator!.getState().annotations.some(a => a.id === ann.id), + 3000, + ); + const seqAtFirstLock = creator.getState().seq; + expect(seqAtFirstLock).toBeGreaterThan(0); + + // First lock with atomic final snapshot. + await creator.lockRoom({ includeFinalSnapshot: true }); + await waitFor(() => creator!.getState().roomStatus === 'locked', 3000); + + // Unlock — server preserves snapshotSeq. + await creator.unlockRoom(); + await waitFor(() => creator!.getState().roomStatus === 'active', 3000); + + // Second lock at the SAME seq (no new events arrived). Previously this + // failed with invalid_snapshot_seq; now it succeeds. + expect(creator.getState().seq).toBe(seqAtFirstLock); + await creator.lockRoom({ includeFinalSnapshot: true }); + await waitFor(() => creator!.getState().roomStatus === 'locked', 3000); + } finally { + safeDisconnect(participant); + if (adminUrl) { + let cleanup: CollabRoomClient | null = null; + try { + // Room is locked; unlock first so deleteRoom can proceed, or just + // delete directly — admin.command.delete works from any state. + cleanup = await joinRoom({ url: adminUrl, user: USER_A, autoConnect: true }); + await cleanup.deleteRoom(); + } catch { /* ignore */ } + finally { safeDisconnect(cleanup); } + } + safeDisconnect(creator); + } + }, 30_000); + + test('manual reconnect replays events missed while a participant was offline', async () => { + // NOTE: this exercises the MANUAL reconnect path — participant calls + // disconnect() and then connect() again. The automatic network-drop + // reconnect path (auto-reconnect timer with preserved seq) is covered by + // the unit-test socket lifecycle suite; a live test of that path would + // require simulating a server-side socket close, which wrangler dev does + // not cleanly expose. + let creator: CollabRoomClient | null = null; + let participant: CollabRoomClient | null = null; + // Admin URL captured for server-side cleanup in finally. If SMOKE_BASE_URL + // ever points at a shared/staging room-service, leaving rooms around until + // expiry is noisy; deleting explicitly keeps the target clean. + let adminUrl: string | null = null; + + try { + const snapshot: RoomSnapshot = { + versionId: 'v1', + planMarkdown: '# Reconnect replay test', + annotations: [], + }; + + const created = await createRoom({ + baseUrl: BASE_URL!, + initialSnapshot: snapshot, + user: USER_A, + }); + creator = created.client; + adminUrl = created.adminUrl; + await creator.connect(); + expect(creator.getState().connectionStatus).toBe('authenticated'); + + participant = await joinRoom({ + url: created.joinUrl, + user: USER_B, + autoConnect: true, + }); + expect(participant.getState().connectionStatus).toBe('authenticated'); + + // Both clients see an initial annotation round-trip (baseline sanity). + const firstAnn = { + id: 'replay-ann-1', + blockId: 'b1', + startOffset: 0, + endOffset: 5, + type: 'COMMENT' as const, + originalText: 'hello', + createdA: Date.now(), + text: 'before drop', + }; + await creator.sendAnnotationAdd([firstAnn]); + await waitFor(() => + participant!.getState().annotations.some(a => a.id === firstAnn.id), + 3000, + ); + const seqAtDisconnect = participant.getState().seq; + expect(seqAtDisconnect).toBeGreaterThan(0); + + // Participant disconnects. While offline, the creator makes two more ops. + participant.disconnect(); + expect(participant.getState().connectionStatus).toBe('closed'); + + const missedAnn1 = { + id: 'replay-ann-missed-1', + blockId: 'b1', + startOffset: 10, + endOffset: 15, + type: 'COMMENT' as const, + originalText: 'missed-1', + createdA: Date.now(), + text: 'sent while offline', + }; + const missedAnn2 = { + id: 'replay-ann-missed-2', + blockId: 'b1', + startOffset: 20, + endOffset: 25, + type: 'COMMENT' as const, + originalText: 'missed-2', + createdA: Date.now(), + text: 'also sent while offline', + }; + await creator.sendAnnotationAdd([missedAnn1]); + await creator.sendAnnotationAdd([missedAnn2]); + await waitFor(() => { + const ids = creator!.getState().annotations.map(a => a.id); + return ids.includes(missedAnn1.id) && ids.includes(missedAnn2.id); + }, 3000); + + // Participant reconnects. The client sends its preserved seq as lastSeq + // and the server replays the missed events. + await participant.connect(); + expect(participant.getState().connectionStatus).toBe('authenticated'); + + await waitFor(() => { + const ids = participant!.getState().annotations.map(a => a.id); + return ids.includes(missedAnn1.id) && ids.includes(missedAnn2.id); + }, 5000); + + // Participant's seq must have advanced past seqAtDisconnect. + expect(participant.getState().seq).toBeGreaterThan(seqAtDisconnect); + + // And the baseline annotation is still there. + expect(participant.getState().annotations.map(a => a.id)).toContain(firstAnn.id); + } finally { + // Server-side cleanup: delete the room so shared/staging SMOKE_BASE_URL + // targets don't accumulate smoke rooms until expiry. Disconnect the + // participant first — delete will close remaining sockets, but a clean + // pre-disconnect avoids noisy AdminInterruptedError on the participant. + safeDisconnect(participant); + if (adminUrl) { + let adminClient: CollabRoomClient | null = null; + try { + adminClient = await joinRoom({ url: adminUrl, user: USER_A, autoConnect: true }); + await adminClient.deleteRoom(); + } catch { /* ignore cleanup errors */ } + finally { safeDisconnect(adminClient); } + } + safeDisconnect(creator); + } + }, 30_000); +}); + +async function waitFor(cond: () => boolean, timeoutMs = 3000): Promise { + const start = Date.now(); + while (!cond()) { + if (Date.now() - start > timeoutMs) { + throw new Error(`waitFor timed out after ${timeoutMs}ms`); + } + await new Promise(r => setTimeout(r, 25)); + } +} diff --git a/packages/shared/collab/client-runtime/join-room.test.ts b/packages/shared/collab/client-runtime/join-room.test.ts new file mode 100644 index 00000000..76518eea --- /dev/null +++ b/packages/shared/collab/client-runtime/join-room.test.ts @@ -0,0 +1,65 @@ +/** + * Unit tests for joinRoom — focus on admin secret override length validation (P3). + */ + +import { describe, expect, test } from 'bun:test'; +import { joinRoom, InvalidAdminSecretError, InvalidRoomUrlError } from './join-room'; +import { buildRoomJoinUrl } from '../url'; +import { generateRoomSecret } from '../ids'; +import { bytesToBase64url } from '../encoding'; +import type { CollabRoomUser } from './types'; + +const USER: CollabRoomUser = { id: 'u1', name: 'alice', color: '#f00' }; + +describe('joinRoom — admin secret override validation (P3)', () => { + test('rejects Uint8Array admin override with wrong length', async () => { + const roomSecret = generateRoomSecret(); + const url = buildRoomJoinUrl('roomA', roomSecret); + const badAdmin = new Uint8Array(16); // 16 bytes, not 32 + + await expect(joinRoom({ url, adminSecret: badAdmin, user: USER })) + .rejects.toThrow(InvalidAdminSecretError); + }); + + test('rejects string admin override that decodes to wrong length', async () => { + const roomSecret = generateRoomSecret(); + const url = buildRoomJoinUrl('roomA', roomSecret); + const badAdminStr = bytesToBase64url(new Uint8Array(16)); + + await expect(joinRoom({ url, adminSecret: badAdminStr, user: USER })) + .rejects.toThrow(InvalidAdminSecretError); + }); + + test('rejects malformed base64url admin override', async () => { + const roomSecret = generateRoomSecret(); + const url = buildRoomJoinUrl('roomA', roomSecret); + + await expect(joinRoom({ url, adminSecret: 'not-valid-base64url!', user: USER })) + .rejects.toThrow(InvalidAdminSecretError); + }); + + test('accepts valid 32-byte Uint8Array admin override', async () => { + const roomSecret = generateRoomSecret(); + const url = buildRoomJoinUrl('roomA', roomSecret); + const validAdmin = new Uint8Array(32); + validAdmin[0] = 1; // non-zero + + const client = await joinRoom({ url, adminSecret: validAdmin, user: USER }); + expect(client.getState().hasAdminCapability).toBe(true); + }); + + test('accepts valid 32-byte string admin override', async () => { + const roomSecret = generateRoomSecret(); + const url = buildRoomJoinUrl('roomA', roomSecret); + const validAdminStr = bytesToBase64url(new Uint8Array(32)); + + const client = await joinRoom({ url, adminSecret: validAdminStr, user: USER }); + expect(client.getState().hasAdminCapability).toBe(true); + }); + + test('InvalidRoomUrlError is still thrown for malformed URL regardless of admin override', async () => { + const validAdmin = new Uint8Array(32); + await expect(joinRoom({ url: 'not-a-url', adminSecret: validAdmin, user: USER })) + .rejects.toThrow(InvalidRoomUrlError); + }); +}); diff --git a/packages/shared/collab/client-runtime/join-room.ts b/packages/shared/collab/client-runtime/join-room.ts new file mode 100644 index 00000000..08040fc6 --- /dev/null +++ b/packages/shared/collab/client-runtime/join-room.ts @@ -0,0 +1,100 @@ +/** + * joinRoom — factory that parses a room URL, derives keys locally, + * and constructs a CollabRoomClient ready to connect. + * + * Client-side only. The URL fragment is client-private. + */ + +import { + deriveRoomKeys, + deriveAdminKey, + computeRoomVerifier, + computeAdminVerifier, +} from '../crypto'; +import { ADMIN_SECRET_LENGTH_BYTES } from '../constants'; +import { base64urlToBytes } from '../encoding'; +import { parseRoomUrl } from '../url'; +import { CollabRoomClient } from './client'; +import type { JoinRoomOptions } from './types'; + +export class InvalidRoomUrlError extends Error { + constructor() { super('Room URL is malformed or missing required fragment'); this.name = 'InvalidRoomUrlError'; } +} + +export class InvalidAdminSecretError extends Error { + constructor(detail: string) { + super(`Invalid admin secret override: ${detail}`); + this.name = 'InvalidAdminSecretError'; + } +} + +export async function joinRoom(options: JoinRoomOptions): Promise { + const parsed = parseRoomUrl(options.url); + if (!parsed) throw new InvalidRoomUrlError(); + + const { roomId, roomSecret } = parsed; + const adminSecretBytes = resolveAdminSecret(options.adminSecret, parsed.adminSecret); + + const { authKey, eventKey, presenceKey } = await deriveRoomKeys(roomSecret); + const adminKey = adminSecretBytes ? await deriveAdminKey(adminSecretBytes) : null; + const roomVerifier = await computeRoomVerifier(authKey, roomId); + const adminVerifier = adminKey ? await computeAdminVerifier(adminKey, roomId) : null; + + const baseUrl = originFromUrl(options.url); + + const client = new CollabRoomClient({ + roomId, + baseUrl, + eventKey, + presenceKey, + adminKey, + roomVerifier, + adminVerifier, + user: options.user, + webSocketImpl: options.webSocketImpl, + reconnect: options.reconnect, + }); + + if (options.autoConnect) { + await client.connect(); + } + + return client; +} + +function resolveAdminSecret( + override: Uint8Array | string | undefined, + fromUrl: Uint8Array | undefined, +): Uint8Array | null { + // URL-derived admin secrets are length-validated inside parseRoomUrl(). + // Overrides bypass that path, so validate explicitly here. + if (override instanceof Uint8Array) { + if (override.length !== ADMIN_SECRET_LENGTH_BYTES) { + throw new InvalidAdminSecretError( + `expected ${ADMIN_SECRET_LENGTH_BYTES} bytes, got ${override.length}`, + ); + } + return override; + } + if (typeof override === 'string') { + let bytes: Uint8Array; + try { + bytes = base64urlToBytes(override); + } catch (err) { + throw new InvalidAdminSecretError(`base64url decode failed: ${String(err)}`); + } + if (bytes.length !== ADMIN_SECRET_LENGTH_BYTES) { + throw new InvalidAdminSecretError( + `expected ${ADMIN_SECRET_LENGTH_BYTES} bytes, got ${bytes.length}`, + ); + } + return bytes; + } + if (fromUrl) return fromUrl; + return null; +} + +function originFromUrl(url: string): string { + const parsed = new URL(url); + return parsed.origin; +} diff --git a/packages/shared/collab/client-runtime/mock-websocket.ts b/packages/shared/collab/client-runtime/mock-websocket.ts new file mode 100644 index 00000000..e9c17dfc --- /dev/null +++ b/packages/shared/collab/client-runtime/mock-websocket.ts @@ -0,0 +1,156 @@ +/** + * In-memory WebSocket mock for unit testing the CollabRoomClient. + * + * Implements enough of the WebSocket interface to satisfy the client runtime. + * Exposes a `peer` handle for test code to script server-side behavior: + * - peer.sendFromServer(msg) — simulate a server message + * - peer.expectFromClient() — await next message the client sends + * - peer.simulateClose(code, reason) — simulate server-initiated close + * - peer.simulateError() — trigger onerror + */ + +export interface MockWebSocketPeer { + /** Send a message from "the server" to the client. */ + sendFromServer(message: string): void; + /** Await the next message the client sends. Rejects after `timeoutMs`. */ + expectFromClient(timeoutMs?: number): Promise; + /** Close the socket from the server side. */ + simulateClose(code?: number, reason?: string): void; + /** Trigger the onerror handler. */ + simulateError(): void; + /** All messages the client has sent, in order. */ + readonly sent: string[]; + /** Whether the client has called close(). */ + readonly closedByClient: boolean; +} + +interface PendingExpect { + resolve: (msg: string) => void; + reject: (err: Error) => void; + timeoutHandle: ReturnType; +} + +const OPEN = 1; +const CLOSING = 2; +const CLOSED = 3; + +export class MockWebSocket implements EventTarget { + static CONNECTING = 0; + static OPEN = 1; + static CLOSING = 2; + static CLOSED = 3; + + readonly CONNECTING = 0; + readonly OPEN = 1; + readonly CLOSING = 2; + readonly CLOSED = 3; + + url: string; + readyState: number = 0; + binaryType: BinaryType = 'blob'; + bufferedAmount = 0; + extensions = ''; + protocol = ''; + + onopen: ((ev: Event) => void) | null = null; + onclose: ((ev: CloseEvent) => void) | null = null; + onmessage: ((ev: MessageEvent) => void) | null = null; + onerror: ((ev: Event) => void) | null = null; + + public readonly peer: MockWebSocketPeer; + private readonly sentMessages: string[] = []; + private readonly expectQueue: PendingExpect[] = []; + private readonly bufferedSent: string[] = []; + private isClosedByClient = false; + + constructor(url: string | URL, _protocols?: string | string[]) { + this.url = typeof url === 'string' ? url : url.toString(); + + const self = this; + this.peer = { + sendFromServer(message: string) { + if (self.readyState !== OPEN) return; + self.onmessage?.(new MessageEvent('message', { data: message })); + }, + expectFromClient(timeoutMs = 2000): Promise { + if (self.bufferedSent.length > 0) { + const msg = self.bufferedSent.shift()!; + return Promise.resolve(msg); + } + return new Promise((resolve, reject) => { + const timeoutHandle = setTimeout(() => { + const idx = self.expectQueue.findIndex(p => p.resolve === resolve); + if (idx >= 0) self.expectQueue.splice(idx, 1); + reject(new Error(`expectFromClient timed out after ${timeoutMs}ms`)); + }, timeoutMs); + self.expectQueue.push({ resolve, reject, timeoutHandle }); + }); + }, + simulateClose(code = 1000, reason = '') { + if (self.readyState === CLOSED) return; + self.readyState = CLOSED; + self.onclose?.(new CloseEvent('close', { code, reason, wasClean: true })); + }, + simulateError() { + self.onerror?.(new Event('error')); + }, + get sent() { return self.sentMessages; }, + get closedByClient() { return self.isClosedByClient; }, + }; + + // Open asynchronously (like a real WebSocket) + queueMicrotask(() => { + if (this.readyState === 0) { + this.readyState = OPEN; + this.onopen?.(new Event('open')); + } + }); + } + + send(data: string | ArrayBufferLike | Blob | ArrayBufferView): void { + if (this.readyState !== OPEN) { + throw new Error(`MockWebSocket.send called in state ${this.readyState}`); + } + const msg = typeof data === 'string' ? data : String(data); + this.sentMessages.push(msg); + + // Satisfy a pending expectFromClient if any + const pending = this.expectQueue.shift(); + if (pending) { + clearTimeout(pending.timeoutHandle); + pending.resolve(msg); + } else { + this.bufferedSent.push(msg); + } + } + + /** + * When true, close() defers the onclose handler to a microtask instead of + * firing it synchronously. This mirrors real browser behavior, where + * ws.close() returns immediately and onclose fires asynchronously. Set via + * `MockWebSocket.asyncCloseMode` to exercise code paths that assume async + * close semantics. + */ + static asyncCloseMode = false; + + close(code?: number, reason?: string): void { + if (this.readyState === CLOSED) return; + this.isClosedByClient = true; + this.readyState = CLOSED; + const closeEvent = new CloseEvent('close', { + code: code ?? 1000, + reason: reason ?? '', + wasClean: true, + }); + if (MockWebSocket.asyncCloseMode) { + queueMicrotask(() => this.onclose?.(closeEvent)); + } else { + this.onclose?.(closeEvent); + } + } + + // EventTarget stubs (not used by client, but required by type) + addEventListener(): void {} + removeEventListener(): void {} + dispatchEvent(_ev: Event): boolean { return true; } +} diff --git a/packages/shared/collab/client-runtime/types.ts b/packages/shared/collab/client-runtime/types.ts new file mode 100644 index 00000000..dfa41c96 --- /dev/null +++ b/packages/shared/collab/client-runtime/types.ts @@ -0,0 +1,186 @@ +/** + * Types for the collab room client runtime. + * + * Client-side state shape, options, and event map. Distinct from wire protocol + * types (which live in ../types.ts). + */ + +import type { + PresenceState, + RoomAnnotation, + RoomServerEvent, + RoomSnapshot, + RoomStatus, +} from '../types'; + +// Forward type-only import to break the cycle between types.ts and client.ts. +// `import type` is erased at compile time — no runtime dependency created. +import type { CollabRoomClient } from './client'; + +// --------------------------------------------------------------------------- +// Connection status +// --------------------------------------------------------------------------- + +export type ConnectionStatus = + | 'disconnected' + | 'connecting' + | 'authenticating' + | 'authenticated' + | 'reconnecting' + | 'closed'; + +// --------------------------------------------------------------------------- +// User identity carried in encrypted presence +// --------------------------------------------------------------------------- + +export interface CollabRoomUser { + /** Stable across reconnects — lives inside encrypted PresenceState.user.id. */ + id: string; + name: string; + color: string; +} + +// --------------------------------------------------------------------------- +// Client state snapshot +// --------------------------------------------------------------------------- + +export interface CollabRoomState { + connectionStatus: ConnectionStatus; + roomStatus: RoomStatus | null; + roomId: string; + /** Random per WebSocket connection — not a stable participant identifier. */ + clientId: string; + /** + * Last server seq consumed by this client. Valid events advance seq after + * applying state. Malformed or undecryptable events also advance seq without + * mutating annotation state so reconnect replay does not loop on a bad event. + * Used as `lastSeq` on reconnect. + */ + seq: number; + planMarkdown: string; + /** Ordered view of internal annotations Map. */ + annotations: RoomAnnotation[]; + /** Keyed by sender clientId. Stale entries are pruned by lastSeen TTL. */ + remotePresence: Record; + /** + * V1 assumes a single creator-held admin capability. The normal participant + * share URL is `#key=...` only. The `#key=...&admin=...` URL is a sensitive + * creator/recovery URL and is not intentionally shared with participants. + * Because of this, admin commands resolve by observing room.status transitions + * rather than command-specific acks — acceptable for V1, not multi-admin safe. + */ + hasAdminCapability: boolean; + lastError: { code: string; message: string } | null; +} + +// --------------------------------------------------------------------------- +// Event map (subscribed via CollabRoomClient.on) +// --------------------------------------------------------------------------- + +export type CollabRoomEvents = { + status: ConnectionStatus; + 'room-status': RoomStatus; + snapshot: RoomSnapshot; + event: RoomServerEvent; + presence: { clientId: string; presence: PresenceState }; + error: { code: string; message: string }; + /** Fires on any state mutation — React hooks subscribe here. */ + state: CollabRoomState; +}; + +// --------------------------------------------------------------------------- +// createRoom options + result +// --------------------------------------------------------------------------- + +export interface CreateRoomOptions { + /** e.g. https://room.plannotator.ai or http://localhost:8787 (no trailing slash). */ + baseUrl: string; + initialSnapshot: RoomSnapshot; + expiresInDays?: number; + user: CollabRoomUser; + /** Test injection. */ + webSocketImpl?: typeof WebSocket; + /** Test injection. */ + fetchImpl?: typeof fetch; + /** Optional reconnect tuning for the returned client. */ + reconnect?: ReconnectOptions; + /** + * Abort the fetch to the room service. If the signal is already aborted + * when createRoom() is called, it rejects immediately. If the signal + * aborts mid-fetch, the fetch is cancelled and createRoom rejects with + * a CreateRoomError. + */ + signal?: AbortSignal; + /** + * Cap for the room-creation fetch in ms. If neither the signal fires nor + * the server responds within this window, createRoom() rejects with a + * CreateRoomError. Default: 10_000 ms. + */ + timeoutMs?: number; +} + +export interface CreateRoomResult { + roomId: string; + /** 32-byte raw secret. Callers may discard after building URLs. */ + roomSecret: Uint8Array; + /** 32-byte raw admin secret. Callers should persist carefully (creator-only). */ + adminSecret: Uint8Array; + /** #key-only URL. Safe to share with participants. */ + joinUrl: string; + /** #key + #admin URL. Creator/recovery only — never the default share target. */ + adminUrl: string; + /** Constructed but NOT connected. Caller invokes client.connect(). */ + client: CollabRoomClient; +} + +// --------------------------------------------------------------------------- +// joinRoom options +// --------------------------------------------------------------------------- + +export interface JoinRoomOptions { + /** Full room URL including fragment. */ + url: string; + /** Override if admin capability is not in URL fragment. base64url string or raw bytes. */ + adminSecret?: Uint8Array | string; + user: CollabRoomUser; + webSocketImpl?: typeof WebSocket; + reconnect?: ReconnectOptions; + /** If true, awaits connect() before returning. Default: false. */ + autoConnect?: boolean; +} + +export interface ReconnectOptions { + initialDelayMs?: number; + maxDelayMs?: number; + /** Exponential backoff multiplier per attempt. Default: 2. */ + factor?: number; + /** 0 disables auto-reconnect entirely (useful in tests). Default: Infinity. */ + maxAttempts?: number; +} + +// --------------------------------------------------------------------------- +// Internal client constructor options (used by createRoom/joinRoom) +// --------------------------------------------------------------------------- + +export interface InternalClientOptions { + roomId: string; + baseUrl: string; + eventKey: CryptoKey; + presenceKey: CryptoKey; + adminKey: CryptoKey | null; + roomVerifier: string; + adminVerifier: string | null; + user: CollabRoomUser; + /** Seed initial state from known snapshot (used by createRoom). */ + initialSnapshot?: RoomSnapshot; + webSocketImpl?: typeof WebSocket; + reconnect?: ReconnectOptions; + /** Connect timeout in milliseconds. Default: 10_000. */ + connectTimeoutMs?: number; + /** Presence TTL in milliseconds. Default: 30_000. */ + presenceTtlMs?: number; + /** Presence sweep interval. Default: 5_000. */ + presenceSweepIntervalMs?: number; +} + +// Note: concrete CollabRoomClient class lives in client.ts to avoid forward-reference cycles. diff --git a/packages/shared/collab/client.ts b/packages/shared/collab/client.ts index c39e4902..419349bd 100644 --- a/packages/shared/collab/client.ts +++ b/packages/shared/collab/client.ts @@ -8,3 +8,9 @@ export * from './index'; export * from './url'; + +// Client runtime (WebSocket + stateful client) +export * from './client-runtime/client'; +export * from './client-runtime/create-room'; +export * from './client-runtime/join-room'; +export * from './client-runtime/types'; diff --git a/packages/shared/collab/constants.ts b/packages/shared/collab/constants.ts index 8124b03b..6a1a9da3 100644 --- a/packages/shared/collab/constants.ts +++ b/packages/shared/collab/constants.ts @@ -1,4 +1,28 @@ /** Plannotator Live Rooms protocol constants. */ -/** Room and admin secrets are 256-bit raw byte values. */ +/** Room secret is a 256-bit raw byte value. */ export const ROOM_SECRET_LENGTH_BYTES = 32; + +/** Admin secret is a 256-bit raw byte value. Distinct symbol from the room + * secret so the intent at each call site is explicit (even though the V1 + * protocol uses the same length for both). */ +export const ADMIN_SECRET_LENGTH_BYTES = 32; + +/** + * WebSocket close code the server uses when the room is no longer available + * (deleted, expired). Client code treats this as a terminal close. + */ +export const WS_CLOSE_ROOM_UNAVAILABLE = 4006; + +/** + * Close reason string the server sets after a successful admin-initiated + * delete. The client treats (code === WS_CLOSE_ROOM_UNAVAILABLE && reason === + * WS_CLOSE_REASON_ROOM_DELETED) as the canonical "delete succeeded" signal. + * Both server and client MUST import from here to avoid drift. + */ +export const WS_CLOSE_REASON_ROOM_DELETED = 'Room deleted'; + +/** Close reason string the server sets when a room has expired. Mapped to + * roomStatus = 'expired' on the client when the client missed the preceding + * room.status broadcast. */ +export const WS_CLOSE_REASON_ROOM_EXPIRED = 'Room expired'; diff --git a/packages/shared/collab/crypto.test.ts b/packages/shared/collab/crypto.test.ts index 7a8f3176..c7ba9494 100644 --- a/packages/shared/collab/crypto.test.ts +++ b/packages/shared/collab/crypto.test.ts @@ -17,7 +17,7 @@ import { encryptSnapshot, decryptSnapshot, } from './crypto'; -import type { AdminCommand, PresenceState, RoomClientOp, RoomSnapshot } from './types'; +import type { AdminCommand, PresenceState, RoomEventClientOp, RoomSnapshot } from './types'; // Stable test secret (32 bytes) const TEST_SECRET = new Uint8Array(32); @@ -281,7 +281,7 @@ describe('encryptPayload / decryptPayload', () => { describe('encryptEventOp / decryptEventPayload', () => { test('round-trip with annotation.add', async () => { const { eventKey } = await deriveRoomKeys(TEST_SECRET); - const op: RoomClientOp = { + const op: RoomEventClientOp = { type: 'annotation.add', annotations: [{ id: 'ann-1', diff --git a/packages/shared/collab/crypto.ts b/packages/shared/collab/crypto.ts index f1c44bab..7956b5db 100644 --- a/packages/shared/collab/crypto.ts +++ b/packages/shared/collab/crypto.ts @@ -17,8 +17,8 @@ import { bytesToBase64url, base64urlToBytes } from './encoding'; import { canonicalJson } from './canonical-json'; -import { ROOM_SECRET_LENGTH_BYTES } from './constants'; -import type { AdminCommand, PresenceState, RoomClientOp, RoomSnapshot } from './types'; +import { ADMIN_SECRET_LENGTH_BYTES, ROOM_SECRET_LENGTH_BYTES } from './constants'; +import type { AdminCommand, PresenceState, RoomEventClientOp, RoomSnapshot } from './types'; // --------------------------------------------------------------------------- // Constants @@ -135,8 +135,8 @@ export async function deriveRoomKeys(roomSecret: Uint8Array): Promise<{ /** Derive the admin HMAC key from an admin secret. */ export async function deriveAdminKey(adminSecret: Uint8Array): Promise { - if (adminSecret.length !== ROOM_SECRET_LENGTH_BYTES) { - throw new Error(`Invalid admin secret: expected ${ROOM_SECRET_LENGTH_BYTES} bytes`); + if (adminSecret.length !== ADMIN_SECRET_LENGTH_BYTES) { + throw new Error(`Invalid admin secret: expected ${ADMIN_SECRET_LENGTH_BYTES} bytes`); } const material = await importKeyMaterial(adminSecret); return deriveHmacKey(material, LABELS.admin); @@ -265,8 +265,10 @@ export async function decryptPayload(key: CryptoKey, ciphertext: string): Promis // Channel convenience wrappers // --------------------------------------------------------------------------- -/** Encrypt a RoomClientOp for the event channel. */ -export async function encryptEventOp(eventKey: CryptoKey, op: RoomClientOp): Promise { +/** Encrypt a RoomEventClientOp for the event channel. + * Presence is intentionally NOT accepted here — the presence channel ships + * a raw PresenceState via encryptPresence(). */ +export async function encryptEventOp(eventKey: CryptoKey, op: RoomEventClientOp): Promise { return encryptPayload(eventKey, JSON.stringify(op)); } @@ -281,10 +283,14 @@ export async function encryptPresence(presenceKey: CryptoKey, presence: Presence return encryptPayload(presenceKey, JSON.stringify(presence)); } -/** Decrypt a presence channel ciphertext. */ -export async function decryptPresence(presenceKey: CryptoKey, ciphertext: string): Promise { +/** + * Decrypt a presence channel ciphertext. Returns `unknown` — encryption only + * proves the sender had the presence key. Callers MUST validate the shape + * (via isPresenceState) before entering state. + */ +export async function decryptPresence(presenceKey: CryptoKey, ciphertext: string): Promise { const plaintext = await decryptPayload(presenceKey, ciphertext); - return JSON.parse(plaintext) as PresenceState; + return JSON.parse(plaintext); } /** Encrypt a RoomSnapshot with the event key. */ @@ -292,8 +298,11 @@ export async function encryptSnapshot(eventKey: CryptoKey, snapshot: RoomSnapsho return encryptPayload(eventKey, JSON.stringify(snapshot)); } -/** Decrypt a snapshot ciphertext. */ -export async function decryptSnapshot(eventKey: CryptoKey, ciphertext: string): Promise { +/** + * Decrypt a snapshot ciphertext. Returns `unknown` — same reasoning as + * decryptPresence. Callers MUST validate via isRoomSnapshot before use. + */ +export async function decryptSnapshot(eventKey: CryptoKey, ciphertext: string): Promise { const plaintext = await decryptPayload(eventKey, ciphertext); - return JSON.parse(plaintext) as RoomSnapshot; + return JSON.parse(plaintext); } diff --git a/packages/shared/collab/types.test.ts b/packages/shared/collab/types.test.ts new file mode 100644 index 00000000..c260beb8 --- /dev/null +++ b/packages/shared/collab/types.test.ts @@ -0,0 +1,355 @@ +/** + * Unit tests for runtime validators — isPresenceState, isRoomAnnotation, + * isRoomAnnotationPatch, isRoomClientOp, isRoomSnapshot. + * + * These validators run on the client after decryption to reject structurally + * malformed payloads before they enter client state. Coverage focuses on + * edge cases that could crash UI render paths. + */ + +import { describe, expect, test } from 'bun:test'; +import { + isPresenceState, + isRoomAnnotation, + isRoomAnnotationPatch, + isRoomClientOp, + isRoomSnapshot, + type RoomAnnotation, +} from './types'; + +const GOOD_ANN: RoomAnnotation = { + id: 'ann-1', + blockId: 'b1', + startOffset: 0, + endOffset: 5, + type: 'COMMENT', + originalText: 'hello', + createdA: 1234, +}; + +describe('isRoomAnnotation', () => { + test('accepts a minimal valid annotation', () => { + expect(isRoomAnnotation(GOOD_ANN)).toBe(true); + }); + + test('accepts an annotation with all optional fields', () => { + expect(isRoomAnnotation({ + ...GOOD_ANN, + text: 'my comment', + author: 'alice', + source: 'eslint', + isQuickLabel: true, + quickLabelTip: 'tip', + diffContext: 'added', + startMeta: { parentTagName: 'p', parentIndex: 0, textOffset: 3 }, + endMeta: { parentTagName: 'p', parentIndex: 0, textOffset: 8 }, + })).toBe(true); + }); + + test.each([ + ['null', null], + ['undefined', undefined], + ['string', 'not-an-obj'], + ['number', 42], + ['array', [GOOD_ANN]], + ])('rejects non-object: %s', (_label, input) => { + expect(isRoomAnnotation(input)).toBe(false); + }); + + test('rejects null id', () => { + expect(isRoomAnnotation({ ...GOOD_ANN, id: null })).toBe(false); + }); + + test('rejects empty id', () => { + expect(isRoomAnnotation({ ...GOOD_ANN, id: '' })).toBe(false); + }); + + test('rejects null type', () => { + expect(isRoomAnnotation({ ...GOOD_ANN, type: null })).toBe(false); + }); + + test('rejects unknown type enum', () => { + expect(isRoomAnnotation({ ...GOOD_ANN, type: 'SOMETHING_ELSE' })).toBe(false); + }); + + test('rejects non-string originalText', () => { + expect(isRoomAnnotation({ ...GOOD_ANN, originalText: 42 })).toBe(false); + expect(isRoomAnnotation({ ...GOOD_ANN, originalText: null })).toBe(false); + }); + + test('rejects non-finite offsets', () => { + expect(isRoomAnnotation({ ...GOOD_ANN, startOffset: NaN })).toBe(false); + expect(isRoomAnnotation({ ...GOOD_ANN, endOffset: Infinity })).toBe(false); + }); + + test('rejects wrong-typed optionals', () => { + expect(isRoomAnnotation({ ...GOOD_ANN, text: 42 })).toBe(false); + expect(isRoomAnnotation({ ...GOOD_ANN, author: true })).toBe(false); + expect(isRoomAnnotation({ ...GOOD_ANN, isQuickLabel: 'yes' })).toBe(false); + expect(isRoomAnnotation({ ...GOOD_ANN, diffContext: 'unexpected' })).toBe(false); + }); + + test('rejects malformed startMeta', () => { + expect(isRoomAnnotation({ + ...GOOD_ANN, + startMeta: { parentTagName: 'p', parentIndex: 'x', textOffset: 3 }, + })).toBe(false); + }); + + test('rejects presence of images field (V1 room annotations have no images)', () => { + expect(isRoomAnnotation({ ...GOOD_ANN, images: [{ path: '/t', name: 'n' }] })).toBe(false); + }); + + test('inline annotations (COMMENT, DELETION) require non-empty blockId', () => { + expect(isRoomAnnotation({ ...GOOD_ANN, type: 'COMMENT', blockId: '' })).toBe(false); + expect(isRoomAnnotation({ ...GOOD_ANN, type: 'DELETION', blockId: '' })).toBe(false); + }); + + test('GLOBAL_COMMENT is allowed to carry blockId: "" (matches existing UI convention)', () => { + expect(isRoomAnnotation({ + ...GOOD_ANN, + type: 'GLOBAL_COMMENT', + blockId: '', + })).toBe(true); + }); +}); + +describe('isRoomAnnotationPatch', () => { + test('rejects empty patch (no defined allowed fields — would burn a seq for a no-op)', () => { + expect(isRoomAnnotationPatch({})).toBe(false); + }); + + test('rejects patch where every field is explicitly undefined', () => { + expect(isRoomAnnotationPatch({ text: undefined })).toBe(false); + expect(isRoomAnnotationPatch({ text: undefined, author: undefined })).toBe(false); + }); + + test('accepts single-field patches', () => { + expect(isRoomAnnotationPatch({ text: 'new' })).toBe(true); + expect(isRoomAnnotationPatch({ type: 'DELETION' })).toBe(true); + expect(isRoomAnnotationPatch({ diffContext: 'modified' })).toBe(true); + }); + + test('rejects patch that sets required field to invalid value', () => { + expect(isRoomAnnotationPatch({ type: null })).toBe(false); + expect(isRoomAnnotationPatch({ originalText: 42 })).toBe(false); + expect(isRoomAnnotationPatch({ startOffset: NaN })).toBe(false); + }); + + test('rejects patch that tries to mutate annotation id (identity-mutation attack)', () => { + // Even a well-formed string id is rejected — an annotation.update must + // never change the id of an existing annotation. + expect(isRoomAnnotationPatch({ id: 'other-id' })).toBe(false); + expect(isRoomAnnotationPatch({ id: '' })).toBe(false); + }); + + test('rejects patch that tries to add images field', () => { + expect(isRoomAnnotationPatch({ images: [{ path: '/x', name: 'x' }] })).toBe(false); + }); +}); + +describe('isRoomClientOp', () => { + test('accepts annotation.add with valid annotations', () => { + expect(isRoomClientOp({ type: 'annotation.add', annotations: [GOOD_ANN] })).toBe(true); + }); + + test('rejects annotation.add with malformed annotation', () => { + expect(isRoomClientOp({ + type: 'annotation.add', + annotations: [{ ...GOOD_ANN, type: null }], + })).toBe(false); + }); + + test('rejects annotation.add with non-array annotations', () => { + expect(isRoomClientOp({ type: 'annotation.add', annotations: GOOD_ANN })).toBe(false); + }); + + test('rejects annotation.add with empty array (no-op would burn a seq)', () => { + expect(isRoomClientOp({ type: 'annotation.add', annotations: [] })).toBe(false); + }); + + test('rejects annotation.remove with empty ids array (no-op would burn a seq)', () => { + expect(isRoomClientOp({ type: 'annotation.remove', ids: [] })).toBe(false); + }); + + test('accepts annotation.update with valid patch', () => { + expect(isRoomClientOp({ + type: 'annotation.update', id: 'ann-1', patch: { text: 'new' }, + })).toBe(true); + }); + + test('rejects annotation.update with empty id', () => { + expect(isRoomClientOp({ + type: 'annotation.update', id: '', patch: {}, + })).toBe(false); + }); + + test('rejects annotation.update with invalid patch', () => { + expect(isRoomClientOp({ + type: 'annotation.update', id: 'ann-1', patch: { type: 'BAD' }, + })).toBe(false); + }); + + test('accepts annotation.remove with string ids', () => { + expect(isRoomClientOp({ type: 'annotation.remove', ids: ['a', 'b'] })).toBe(true); + }); + + test('rejects annotation.remove with empty-string id', () => { + expect(isRoomClientOp({ type: 'annotation.remove', ids: [''] })).toBe(false); + }); + + test('accepts annotation.clear with and without source', () => { + expect(isRoomClientOp({ type: 'annotation.clear' })).toBe(true); + expect(isRoomClientOp({ type: 'annotation.clear', source: 'eslint' })).toBe(true); + }); + + test('rejects unknown op type', () => { + expect(isRoomClientOp({ type: 'annotation.explode' })).toBe(false); + }); + + test('accepts presence.update with valid PresenceState', () => { + expect(isRoomClientOp({ + type: 'presence.update', + presence: { user: { id: 'u', name: 'n', color: '#f00' }, cursor: null }, + })).toBe(true); + }); + + test('rejects presence.update with malformed presence', () => { + expect(isRoomClientOp({ + type: 'presence.update', + presence: { user: { id: 'u', name: 42, color: '#f00' }, cursor: null }, + })).toBe(false); + }); +}); + +describe('isRoomSnapshot', () => { + test('accepts a minimal valid snapshot', () => { + expect(isRoomSnapshot({ versionId: 'v1', planMarkdown: '# Plan', annotations: [] })).toBe(true); + }); + + test('accepts a snapshot with annotations', () => { + expect(isRoomSnapshot({ + versionId: 'v1', planMarkdown: '# Plan', annotations: [GOOD_ANN], + })).toBe(true); + }); + + test('rejects wrong versionId', () => { + expect(isRoomSnapshot({ versionId: 'v2', planMarkdown: '', annotations: [] })).toBe(false); + expect(isRoomSnapshot({ versionId: null, planMarkdown: '', annotations: [] })).toBe(false); + }); + + test('rejects non-string planMarkdown', () => { + expect(isRoomSnapshot({ versionId: 'v1', planMarkdown: 42, annotations: [] })).toBe(false); + }); + + test('rejects non-array annotations', () => { + expect(isRoomSnapshot({ versionId: 'v1', planMarkdown: '', annotations: 'not-array' })).toBe(false); + }); + + test('rejects if any annotation is malformed', () => { + expect(isRoomSnapshot({ + versionId: 'v1', planMarkdown: '', annotations: [{ ...GOOD_ANN, type: null }], + })).toBe(false); + }); + + test('rejects snapshot with unknown top-level keys', () => { + expect(isRoomSnapshot({ + versionId: 'v1', planMarkdown: '', annotations: [], future: 'smuggled', + })).toBe(false); + }); +}); + +describe('isPresenceState', () => { + test('accepts minimal presence (null cursor)', () => { + expect(isPresenceState({ + user: { id: 'u', name: 'alice', color: '#f00' }, cursor: null, + })).toBe(true); + }); + + test('accepts presence with cursor', () => { + expect(isPresenceState({ + user: { id: 'u', name: 'alice', color: '#f00' }, + cursor: { x: 1, y: 2, coordinateSpace: 'document' }, + })).toBe(true); + }); + + test('rejects non-string name', () => { + expect(isPresenceState({ + user: { id: 'u', name: 42, color: '#f00' }, cursor: null, + })).toBe(false); + }); + + test('rejects payload missing required user field', () => { + expect(isPresenceState({ cursor: null })).toBe(false); + }); + + test('rejects payload missing required cursor field (must be explicit, not absent)', () => { + expect(isPresenceState({ user: { id: 'u', name: 'a', color: '#f00' } })).toBe(false); + }); + + test('rejects cursor without required fields', () => { + expect(isPresenceState({ + user: { id: 'u', name: 'a', color: '#f00' }, + cursor: { x: 1 }, + })).toBe(false); + }); + + test('rejects cursor with unknown coordinateSpace', () => { + expect(isPresenceState({ + user: { id: 'u', name: 'a', color: '#f00' }, + cursor: { x: 1, y: 2, coordinateSpace: 'galaxy' }, + })).toBe(false); + }); + + test('rejects non-finite cursor coordinates', () => { + const base = { user: { id: 'u', name: 'a', color: '#f00' } }; + expect(isPresenceState({ ...base, cursor: { x: Infinity, y: 2, coordinateSpace: 'document' } })).toBe(false); + expect(isPresenceState({ ...base, cursor: { x: -Infinity, y: 2, coordinateSpace: 'document' } })).toBe(false); + expect(isPresenceState({ ...base, cursor: { x: NaN, y: 2, coordinateSpace: 'document' } })).toBe(false); + expect(isPresenceState({ ...base, cursor: { x: 1, y: Infinity, coordinateSpace: 'document' } })).toBe(false); + expect(isPresenceState({ ...base, cursor: { x: 1, y: NaN, coordinateSpace: 'document' } })).toBe(false); + }); + + test('rejects unknown top-level keys', () => { + expect(isPresenceState({ + user: { id: 'u', name: 'a', color: '#f00' }, + cursor: null, + extra: 'smuggled', + })).toBe(false); + }); + + test('rejects unknown keys on user', () => { + expect(isPresenceState({ + user: { id: 'u', name: 'a', color: '#f00', email: 'leak@example.com' }, + cursor: null, + })).toBe(false); + }); + + test('rejects unknown keys on cursor', () => { + expect(isPresenceState({ + user: { id: 'u', name: 'a', color: '#f00' }, + cursor: { x: 1, y: 2, coordinateSpace: 'document', z: 3 }, + })).toBe(false); + }); +}); + +describe('nested annotation meta — strict key allowlist', () => { + test('rejects annotation meta with unknown keys', () => { + expect(isRoomAnnotation({ + ...GOOD_ANN, + startMeta: { parentTagName: 'p', parentIndex: 0, textOffset: 0, sneaky: true }, + })).toBe(false); + expect(isRoomAnnotation({ + ...GOOD_ANN, + endMeta: { parentTagName: 'p', parentIndex: 0, textOffset: 0, sneaky: true }, + })).toBe(false); + }); + + test('accepts annotation meta with exactly the allowlisted keys', () => { + expect(isRoomAnnotation({ + ...GOOD_ANN, + startMeta: { parentTagName: 'p', parentIndex: 0, textOffset: 3 }, + endMeta: { parentTagName: 'p', parentIndex: 0, textOffset: 8 }, + })).toBe(true); + }); +}); diff --git a/packages/shared/collab/types.ts b/packages/shared/collab/types.ts index a1807465..07015c97 100644 --- a/packages/shared/collab/types.ts +++ b/packages/shared/collab/types.ts @@ -31,6 +31,12 @@ export interface RoomAnnotation { type: RoomAnnotationType; text?: string; originalText: string; + /** + * Creation timestamp in ms. Field name intentionally mirrors the existing + * UI `Annotation.createdA` (see `packages/ui/types.ts`). DO NOT rename — + * existing UI code, existing annotations persisted to disk, and share-URL + * payloads all use this exact key. + */ createdA: number; author?: string; source?: string; @@ -42,6 +48,141 @@ export interface RoomAnnotation { images?: never; } +const ANNOTATION_META_KEYS = new Set(['parentTagName', 'parentIndex', 'textOffset']); +function isAnnotationMeta(x: unknown): boolean { + if (x === null || typeof x !== 'object') return false; + const m = x as Record; + // Strict boundary: reject unknown nested keys so the validator doesn't drift. + for (const key of Object.keys(m)) { + if (!ANNOTATION_META_KEYS.has(key)) return false; + } + return ( + typeof m.parentTagName === 'string' && + typeof m.parentIndex === 'number' && Number.isFinite(m.parentIndex) && + typeof m.textOffset === 'number' && Number.isFinite(m.textOffset) + ); +} + +/** + * Centralized per-field validators for RoomAnnotation. Both isRoomAnnotation + * and isRoomAnnotationPatch delegate to this so field definitions don't drift + * when annotation fields are added. Each entry returns true if the value is + * acceptable for that field (either as a full-annotation required field or as + * a patch override, depending on the caller). + * + * The `satisfies` constraint forces this map to cover every RoomAnnotation key + * except `images` (which V1 rejects outright). Adding a new field to + * RoomAnnotation without a matching validator here is a compile error. + */ +const ROOM_ANNOTATION_FIELD_VALIDATORS = { + id: (v) => typeof v === 'string' && v.length > 0, + blockId: (v) => typeof v === 'string', + startOffset: (v) => typeof v === 'number' && Number.isFinite(v), + endOffset: (v) => typeof v === 'number' && Number.isFinite(v), + type: (v) => v === 'DELETION' || v === 'COMMENT' || v === 'GLOBAL_COMMENT', + originalText: (v) => typeof v === 'string', + createdA: (v) => typeof v === 'number' && Number.isFinite(v), + text: (v) => typeof v === 'string', + author: (v) => typeof v === 'string', + source: (v) => typeof v === 'string', + isQuickLabel: (v) => typeof v === 'boolean', + quickLabelTip: (v) => typeof v === 'string', + diffContext: (v) => v === 'added' || v === 'removed' || v === 'modified', + startMeta: isAnnotationMeta, + endMeta: isAnnotationMeta, +} satisfies Record, (v: unknown) => boolean>; + +const ROOM_ANNOTATION_KNOWN_FIELDS = new Set([ + ...Object.keys(ROOM_ANNOTATION_FIELD_VALIDATORS), + 'images', // known-but-forbidden +]); + +const ROOM_ANNOTATION_REQUIRED_FIELDS = [ + 'id', 'blockId', 'startOffset', 'endOffset', 'type', 'originalText', 'createdA', +] as const; + +/** Fast membership check for optional-field iteration in isRoomAnnotation. */ +const ROOM_ANNOTATION_REQUIRED_FIELD_SET = new Set(ROOM_ANNOTATION_REQUIRED_FIELDS); + +/** + * Fields that are NOT accepted in an annotation.update patch. `id` is the + * critical one: letting a patch replace the id lets a malicious sender store + * an annotation under map key `old-id` whose object reports `id: "new-id"`. + * Later removes/updates by the visible id would miss it. `images` is excluded + * because V1 room annotations cannot carry images. + */ +const ROOM_ANNOTATION_PATCH_FORBIDDEN_FIELDS = new Set(['id', 'images']); + +/** + * Runtime validator for a decrypted RoomAnnotation. Encryption proves only + * that the sender held the room key; payload shape is not proven. Any room + * participant can encrypt arbitrary JSON, so annotations that are about to + * enter client state must be shape-checked first. Without this, malformed + * annotations can crash UI render paths that assume well-formed fields. + */ +export function isRoomAnnotation(x: unknown): x is RoomAnnotation { + if (x === null || typeof x !== 'object') return false; + const a = x as Record; + // Strict boundary: reject unknown keys. The validators are the contract — + // anything outside ROOM_ANNOTATION_KNOWN_FIELDS would silently pass through + // otherwise, defeating the purpose of the validation pass. + for (const key of Object.keys(a)) { + if (!ROOM_ANNOTATION_KNOWN_FIELDS.has(key)) return false; + } + // Single pass over the validator map: required fields must pass validation + // regardless of value; optional fields only run validation when present. + for (const [field, validate] of Object.entries(ROOM_ANNOTATION_FIELD_VALIDATORS)) { + const required = ROOM_ANNOTATION_REQUIRED_FIELD_SET.has(field); + if (required) { + if (!validate(a[field])) return false; + } else if (a[field] !== undefined) { + if (!validate(a[field])) return false; + } + } + // Cross-field invariant: inline annotations (COMMENT, DELETION) must have a + // non-empty blockId — they attach to a block in the rendered plan. Only + // GLOBAL_COMMENT is allowed to carry blockId: '' (it's a top-level comment + // with no block anchor, matching the existing UI convention). + if ((a.type === 'COMMENT' || a.type === 'DELETION') && (a.blockId as string).length === 0) { + return false; + } + // images must be absent in V1 room annotations + if ('images' in a && a.images !== undefined) return false; + return true; +} + +/** + * Runtime validator for a partial RoomAnnotation patch (annotation.update). + * Allows any subset of fields but each present field must be well-typed. + * Forbids mutating required fields into invalid values (e.g. type=null) and + * forbids the `id` and `images` fields entirely (see + * ROOM_ANNOTATION_PATCH_FORBIDDEN_FIELDS for rationale). + */ +export function isRoomAnnotationPatch(x: unknown): x is Partial { + if (x === null || typeof x !== 'object') return false; + const p = x as Record; + // Strict boundary: reject unknown keys. Patches must not smuggle in + // fields the type doesn't know about. + for (const key of Object.keys(p)) { + if (!ROOM_ANNOTATION_KNOWN_FIELDS.has(key)) return false; + } + for (const forbidden of ROOM_ANNOTATION_PATCH_FORBIDDEN_FIELDS) { + if (forbidden in p && p[forbidden] !== undefined) return false; + } + // Reject effectively-empty patches. A patch with no allowed/defined fields + // (including `{}` and `{ text: undefined }`) would burn a durable seq for + // a guaranteed no-op when sent — avoidable log noise with no effect. + let hasDefinedAllowedField = false; + for (const [field, validate] of Object.entries(ROOM_ANNOTATION_FIELD_VALIDATORS)) { + if (ROOM_ANNOTATION_PATCH_FORBIDDEN_FIELDS.has(field)) continue; + if (p[field] === undefined) continue; + if (!validate(p[field])) return false; + hasDefinedAllowedField = true; + } + if (!hasDefinedAllowedField) return false; + return true; +} + // --------------------------------------------------------------------------- // Presence // --------------------------------------------------------------------------- @@ -60,6 +201,59 @@ export interface PresenceState { idle?: boolean; } +/** + * Runtime validator for decrypted PresenceState. Encryption only proves the + * sender holds the room key; it does not prove payload shape. Without this, + * a malicious participant could ship a valid-encrypted but malformed presence + * and crash UI render code that assumes `user.name` is a string, etc. + */ +const PRESENCE_STATE_KEYS = new Set(['user', 'cursor', 'activeAnnotationId', 'idle']); +const PRESENCE_USER_KEYS = new Set(['id', 'name', 'color']); +const CURSOR_STATE_KEYS = new Set(['blockId', 'x', 'y', 'coordinateSpace']); + +export function isPresenceState(x: unknown): x is PresenceState { + if (x === null || typeof x !== 'object') return false; + const p = x as Record; + + // Required-field intent made explicit (previously relied on subsequent + // typeof checks to reject missing fields via undefined). + if (!('user' in p) || !('cursor' in p)) return false; + + // Strict boundary: reject unknown top-level keys. + for (const key of Object.keys(p)) { + if (!PRESENCE_STATE_KEYS.has(key)) return false; + } + + const user = p.user; + if (user === null || typeof user !== 'object') return false; + const u = user as Record; + for (const key of Object.keys(u)) { + if (!PRESENCE_USER_KEYS.has(key)) return false; + } + if (typeof u.id !== 'string' || typeof u.name !== 'string' || typeof u.color !== 'string') return false; + + // cursor: null OR CursorState + if (p.cursor !== null) { + if (p.cursor === undefined || typeof p.cursor !== 'object') return false; + const c = p.cursor as Record; + for (const key of Object.keys(c)) { + if (!CURSOR_STATE_KEYS.has(key)) return false; + } + // Require finite coordinates — JSON can encode Infinity/NaN via non-standard + // parsers or adversarial payloads, and non-finite cursors would corrupt + // remote-cursor rendering math downstream. + if (typeof c.x !== 'number' || !Number.isFinite(c.x)) return false; + if (typeof c.y !== 'number' || !Number.isFinite(c.y)) return false; + if (c.coordinateSpace !== 'block' && c.coordinateSpace !== 'document' && c.coordinateSpace !== 'viewport') return false; + if (c.blockId !== undefined && typeof c.blockId !== 'string') return false; + } + + if (p.activeAnnotationId !== undefined && p.activeAnnotationId !== null && typeof p.activeAnnotationId !== 'string') return false; + if (p.idle !== undefined && typeof p.idle !== 'boolean') return false; + + return true; +} + // --------------------------------------------------------------------------- // Server Envelope // --------------------------------------------------------------------------- @@ -80,15 +274,79 @@ export interface ServerEnvelope { // --------------------------------------------------------------------------- // Client Operations (encrypted inside envelope ciphertext) +// +// Event channel payloads are annotation ops only. Presence is encrypted as a +// raw PresenceState on the presence channel (the wire envelope's `channel` +// field already discriminates). Keeping presence OUT of the event-channel +// type and validator prevents clients from writing durable no-op presence +// events into the sequenced event log. // --------------------------------------------------------------------------- -export type RoomClientOp = +/** Ops valid on the event channel. */ +export type RoomEventClientOp = | { type: 'annotation.add'; annotations: RoomAnnotation[] } | { type: 'annotation.update'; id: string; patch: Partial } | { type: 'annotation.remove'; ids: string[] } - | { type: 'annotation.clear'; source?: string } + | { type: 'annotation.clear'; source?: string }; + +/** + * Superset union retained for protocol-level typing and tests that want one + * client-op union. Runtime event-channel code uses RoomEventClientOp; presence + * is sent as raw PresenceState on the presence channel. + */ +export type RoomClientOp = + | RoomEventClientOp | { type: 'presence.update'; presence: PresenceState }; +/** + * Runtime validator for a decrypted EVENT-channel op. Does NOT accept + * presence.update — presence ops flow through the presence channel with a + * raw PresenceState payload validated by isPresenceState. + */ +export function isRoomEventClientOp(x: unknown): x is RoomEventClientOp { + if (x === null || typeof x !== 'object') return false; + const op = x as Record; + switch (op.type) { + case 'annotation.add': + // Empty-array adds would burn a durable seq for a no-op; reject. + return ( + Array.isArray(op.annotations) && + op.annotations.length > 0 && + op.annotations.every(isRoomAnnotation) + ); + case 'annotation.update': + return ( + typeof op.id === 'string' && op.id.length > 0 && + isRoomAnnotationPatch(op.patch) + ); + case 'annotation.remove': + // Empty-array removes would burn a durable seq for a no-op; reject. + return ( + Array.isArray(op.ids) && + op.ids.length > 0 && + op.ids.every((id: unknown) => typeof id === 'string' && id.length > 0) + ); + case 'annotation.clear': + return op.source === undefined || typeof op.source === 'string'; + default: + return false; + } +} + +/** + * Superset validator — accepts event-channel ops OR presence.update. Not + * currently used by the runtime (outbound mutation methods validate event + * ops via isRoomEventClientOp, and presence via isPresenceState directly). + * Retained for completeness; inbound event-channel validation should always + * use isRoomEventClientOp so presence.update cannot pollute the durable log. + */ +export function isRoomClientOp(x: unknown): x is RoomClientOp { + if (isRoomEventClientOp(x)) return true; + if (x === null || typeof x !== 'object') return false; + const op = x as Record; + return op.type === 'presence.update' && isPresenceState(op.presence); +} + // --------------------------------------------------------------------------- // Server Events (decrypted by client from envelope ciphertext) // --------------------------------------------------------------------------- @@ -111,6 +369,27 @@ export interface RoomSnapshot { annotations: RoomAnnotation[]; } +/** + * Runtime validator for a decrypted RoomSnapshot. A malformed snapshot must + * not enter client state — it clears and re-seeds the annotations map plus + * planMarkdown, so garbage here corrupts the whole view. + */ +const ROOM_SNAPSHOT_KEYS = new Set(['versionId', 'planMarkdown', 'annotations']); + +export function isRoomSnapshot(x: unknown): x is RoomSnapshot { + if (x === null || typeof x !== 'object') return false; + const s = x as Record; + // Strict boundary: reject unknown keys so future protocol drift fails + // loudly instead of silently slipping fields past the validator. + for (const key of Object.keys(s)) { + if (!ROOM_SNAPSHOT_KEYS.has(key)) return false; + } + if (s.versionId !== 'v1') return false; + if (typeof s.planMarkdown !== 'string') return false; + if (!Array.isArray(s.annotations)) return false; + return s.annotations.every(isRoomAnnotation); +} + // --------------------------------------------------------------------------- // Transport Messages (server-to-client, pre-decryption) // --------------------------------------------------------------------------- @@ -126,7 +405,10 @@ export type RoomTransportMessage = // Room Status // --------------------------------------------------------------------------- -export type RoomStatus = 'created' | 'active' | 'locked' | 'deleted' | 'expired'; +// 'created' was in this union historically, but the DO initializes rooms +// directly to 'active' on creation and never transitions through 'created'. +// Keeping it in the type would imply an unused lifecycle step. +export type RoomStatus = 'active' | 'locked' | 'deleted' | 'expired'; // --------------------------------------------------------------------------- // Sequenced Envelope (for event log storage) @@ -147,11 +429,20 @@ export interface AuthChallenge { challengeId: string; nonce: string; expiresAt: number; + /** + * Server-assigned ephemeral clientId for this WebSocket. Binding the auth + * proof to it prevents a malicious participant from choosing another active + * connection's visible clientId and overwriting their presence slot after + * auth. Clients MUST use this value as their clientId for the connection + * (not self-generate one). + */ + clientId: string; } export interface AuthResponse { type: 'auth.response'; challengeId: string; + /** Must equal the server-assigned clientId from the corresponding AuthChallenge. */ clientId: string; proof: string; lastSeq?: number; diff --git a/packages/shared/collab/url.test.ts b/packages/shared/collab/url.test.ts index ad8cc991..80c20a48 100644 --- a/packages/shared/collab/url.test.ts +++ b/packages/shared/collab/url.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from 'bun:test'; -import { parseRoomUrl, buildRoomJoinUrl } from './url'; -import { generateRoomSecret, generateRoomId } from './ids'; +import { parseRoomUrl, buildRoomJoinUrl, buildAdminRoomUrl } from './url'; +import { generateRoomSecret, generateAdminSecret, generateRoomId } from './ids'; describe('parseRoomUrl', () => { test('parses valid room URL', () => { @@ -90,3 +90,78 @@ describe('round-trip', () => { expect(parsed!.roomSecret).toEqual(secret); }); }); + +describe('buildAdminRoomUrl', () => { + test('constructs URL with both key and admin', () => { + const secret = generateRoomSecret(); + const adminSecret = generateAdminSecret(); + const url = buildAdminRoomUrl('my-room', secret, adminSecret); + expect(url).toContain('/c/my-room#key='); + expect(url).toContain('&admin='); + }); + + test('rejects non-32-byte admin secret', () => { + expect(() => buildAdminRoomUrl('room', generateRoomSecret(), new Uint8Array(31))) + .toThrow('Invalid admin secret'); + }); + + test('rejects non-32-byte room secret', () => { + expect(() => buildAdminRoomUrl('room', new Uint8Array(31), generateAdminSecret())) + .toThrow('Invalid room secret'); + }); + + test('round-trip: parseRoomUrl recovers admin secret', () => { + const roomId = generateRoomId(); + const secret = generateRoomSecret(); + const adminSecret = generateAdminSecret(); + const url = buildAdminRoomUrl(roomId, secret, adminSecret); + const parsed = parseRoomUrl(url); + + expect(parsed).not.toBeNull(); + expect(parsed!.roomId).toBe(roomId); + expect(parsed!.roomSecret).toEqual(secret); + expect(parsed!.adminSecret).toEqual(adminSecret); + }); + + test('parseRoomUrl without admin leaves adminSecret undefined', () => { + const secret = generateRoomSecret(); + const url = buildRoomJoinUrl('room-abc', secret); + const parsed = parseRoomUrl(url); + expect(parsed!.adminSecret).toBeUndefined(); + }); + + test('parseRoomUrl rejects malformed admin (wrong length)', () => { + // Manually construct URL with 1-byte admin + const secret = generateRoomSecret(); + const url = buildRoomJoinUrl('room', secret) + '&admin=AQ'; + expect(parseRoomUrl(url)).toBeNull(); + }); +}); + +describe('URL building — trailing slash hygiene (P3)', () => { + test('buildRoomJoinUrl strips trailing slash from baseUrl', () => { + const roomSecret = generateRoomSecret(); + const withSlash = buildRoomJoinUrl('room-42', roomSecret, 'https://example.com/'); + const withoutSlash = buildRoomJoinUrl('room-42', roomSecret, 'https://example.com'); + expect(withSlash).toBe(withoutSlash); + expect(withSlash).not.toContain('com//c/'); + }); + + test('buildAdminRoomUrl strips trailing slash from baseUrl', () => { + const roomSecret = generateRoomSecret(); + const adminSecret = generateAdminSecret(); + const withSlash = buildAdminRoomUrl('r', roomSecret, adminSecret, 'https://example.com/'); + const withoutSlash = buildAdminRoomUrl('r', roomSecret, adminSecret, 'https://example.com'); + expect(withSlash).toBe(withoutSlash); + }); + + test('round-trips the constructed URL through parseRoomUrl regardless of trailing slash', () => { + const roomSecret = generateRoomSecret(); + const roomId = generateRoomId(); + const url = buildRoomJoinUrl(roomId, roomSecret, 'https://example.com/'); + const parsed = parseRoomUrl(url); + expect(parsed).not.toBeNull(); + expect(parsed!.roomId).toBe(roomId); + expect(parsed!.roomSecret).toEqual(roomSecret); + }); +}); diff --git a/packages/shared/collab/url.ts b/packages/shared/collab/url.ts index 3997d425..486f85c4 100644 --- a/packages/shared/collab/url.ts +++ b/packages/shared/collab/url.ts @@ -10,20 +10,31 @@ */ import { bytesToBase64url, base64urlToBytes } from './encoding'; -import { ROOM_SECRET_LENGTH_BYTES } from './constants'; +import { ADMIN_SECRET_LENGTH_BYTES, ROOM_SECRET_LENGTH_BYTES } from './constants'; const DEFAULT_BASE_URL = 'https://room.plannotator.ai'; +/** Strip a single trailing slash from a base URL so path concatenation is safe. */ +function normalizeBaseUrl(baseUrl: string): string { + return baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl; +} + export interface ParsedRoomUrl { roomId: string; roomSecret: Uint8Array; + /** Present only if the URL fragment includes `&admin=...` (creator/recovery URLs). */ + adminSecret?: Uint8Array; } /** - * Parse a room join URL. Extracts roomId and roomSecret from the fragment. - * Returns null if the URL is malformed, missing a fragment, or not a valid room URL. + * Parse a room join URL. Extracts roomId, roomSecret, and optional adminSecret. + * Returns null if the URL is malformed. + * + * Expected formats: + * https://room.plannotator.ai/c/#key= + * https://room.plannotator.ai/c/#key=&admin= * - * Expected format: https://room.plannotator.ai/c/#key= + * If `admin=` is present but malformed (wrong length, bad encoding), returns null. */ export function parseRoomUrl(url: string): ParsedRoomUrl | null { try { @@ -47,14 +58,24 @@ export function parseRoomUrl(url: string): ParsedRoomUrl | null { const roomSecret = base64urlToBytes(keyParam); if (roomSecret.length !== ROOM_SECRET_LENGTH_BYTES) return null; - return { roomId, roomSecret }; + const result: ParsedRoomUrl = { roomId, roomSecret }; + + // Optional admin capability + const adminParam = params.get('admin'); + if (adminParam !== null) { + const adminSecret = base64urlToBytes(adminParam); + if (adminSecret.length !== ADMIN_SECRET_LENGTH_BYTES) return null; + result.adminSecret = adminSecret; + } + + return result; } catch { return null; } } /** - * Construct a room join URL with the secret in the fragment. + * Construct a #key-only room join URL (safe to share with participants). * * @param roomId - The room identifier * @param roomSecret - The 256-bit room secret (raw bytes) @@ -68,5 +89,26 @@ export function buildRoomJoinUrl( if (roomSecret.length !== ROOM_SECRET_LENGTH_BYTES) { throw new Error(`Invalid room secret: expected ${ROOM_SECRET_LENGTH_BYTES} bytes`); } - return `${baseUrl}/c/${roomId}#key=${bytesToBase64url(roomSecret)}`; + return `${normalizeBaseUrl(baseUrl)}/c/${roomId}#key=${bytesToBase64url(roomSecret)}`; +} + +/** + * Construct a room URL that includes admin capability (creator-only / recovery). + * + * WARNING: adminUrl grants lock/unlock/delete capability. It must NOT be the + * default share target. Use `buildRoomJoinUrl()` for normal participant sharing. + */ +export function buildAdminRoomUrl( + roomId: string, + roomSecret: Uint8Array, + adminSecret: Uint8Array, + baseUrl: string = DEFAULT_BASE_URL, +): string { + if (roomSecret.length !== ROOM_SECRET_LENGTH_BYTES) { + throw new Error(`Invalid room secret: expected ${ROOM_SECRET_LENGTH_BYTES} bytes`); + } + if (adminSecret.length !== ADMIN_SECRET_LENGTH_BYTES) { + throw new Error(`Invalid admin secret: expected ${ADMIN_SECRET_LENGTH_BYTES} bytes`); + } + return `${normalizeBaseUrl(baseUrl)}/c/${roomId}#key=${bytesToBase64url(roomSecret)}&admin=${bytesToBase64url(adminSecret)}`; } diff --git a/packages/shared/package.json b/packages/shared/package.json index 4b8a9b2f..da8fc151 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -27,7 +27,8 @@ "./html-to-markdown": "./html-to-markdown.ts", "./url-to-markdown": "./url-to-markdown.ts", "./collab": "./collab/index.ts", - "./collab/client": "./collab/client.ts" + "./collab/client": "./collab/client.ts", + "./collab/constants": "./collab/constants.ts" }, "dependencies": { "@joplin/turndown-plugin-gfm": "^1.0.64", diff --git a/packages/ui/hooks/useCollabRoom.ts b/packages/ui/hooks/useCollabRoom.ts new file mode 100644 index 00000000..836fe8c5 --- /dev/null +++ b/packages/ui/hooks/useCollabRoom.ts @@ -0,0 +1,300 @@ +/** + * React hook wrapping CollabRoomClient for editor/component consumption. + * + * Usage: + * const room = useCollabRoom({ url, user, adminSecret? }); + * room.addAnnotations([ann]); + * + * Effect deps: [url, adminSecret, user.id, enabled]. Change any and the hook + * tears down the client and creates a new one. For stable connections, consumers + * should memoize `user` (used by value) and avoid mutating `url`/`adminSecret`. + * + * Changes to user.name/color propagate via the next sendPresence() call without + * reconnecting. + * + * === Key-gated client === + * The effect runs AFTER the render commits. So when url/adminSecret/user.id/ + * enabled change, React could otherwise return a render of the previous + * authenticated state and old client before the effect fires. To prevent a + * click in that window sending to the wrong room, every read (state + + * requireClient) compares the CURRENT render's prop key against the key the + * stored client was created for. Mismatch returns DISCONNECTED_STATE / + * client: null / mutations throw unavailable-client. + * + * === Mutation contract (V1) === + * Mutation methods (`addAnnotations`, `updateAnnotation`, `removeAnnotations`, + * `clearAnnotations`) resolve when the op is SENT to the server, not when + * local state has been updated. The returned `annotations` array reflects + * server-echoed state — awaiting `addAnnotations(...)` and then reading + * `annotations` synchronously may still show pre-echo state. + * + * For reactive UI, render from the returned `annotations` field; it updates + * via React state when the server echo arrives and is applied by the client. + * This mirrors `CollabRoomClient`'s `state` event contract. + */ + +import { useCallback, useEffect, useRef, useState } from 'react'; +import { + joinRoom, + InvalidRoomUrlError, + InvalidAdminSecretError, + ConnectTimeoutError, + AuthRejectedError, + RoomUnavailableError, + type CollabRoomClient, + type CollabRoomState, + type CollabRoomUser, + type ConnectionStatus, +} from '@plannotator/shared/collab/client'; +import type { + PresenceState, + RoomAnnotation, + RoomStatus, +} from '@plannotator/shared/collab'; + +export interface UseCollabRoomOptions { + /** Full room URL including #key= fragment. */ + url: string; + /** base64url admin secret if not carried in URL. Hook does NOT persist. */ + adminSecret?: string; + /** User identity. Consumer should memoize for stable reconnect behavior. */ + user: CollabRoomUser; + /** Default true. When false, no connection is established. */ + enabled?: boolean; +} + +export interface UseCollabRoomReturn { + connectionStatus: ConnectionStatus; + roomStatus: RoomStatus | null; + planMarkdown: string; + annotations: RoomAnnotation[]; + remotePresence: Record; + hasAdminCapability: boolean; + lastError: { code: string; message: string } | null; + + addAnnotations: (a: RoomAnnotation[]) => Promise; + updateAnnotation: (id: string, patch: Partial) => Promise; + removeAnnotations: (ids: string[]) => Promise; + clearAnnotations: (source?: string) => Promise; + updatePresence: (p: PresenceState) => Promise; + + /** + * Lock the room. Optionally include a final snapshot as the baseline for + * future fresh joiners. For product UI use, prefer `includeFinalSnapshot: true` + * — the client builds the snapshot and its atSeq atomically from current + * internal state, so there is no caller race. Advanced callers can supply + * `{ finalSnapshot, finalSnapshotSeq }` directly via the escape hatch client. + */ + lock: (opts?: { includeFinalSnapshot?: boolean }) => Promise; + unlock: () => Promise; + deleteRoom: () => Promise; + + /** + * Escape hatch for advanced consumers. May be non-null before authentication + * completes (e.g. during the `connecting` / `authenticating` window); gate + * mutations on `connectionStatus === 'authenticated'` rather than on this + * field being non-null. + * + * Additionally, the hook key-gates this reference — if the current render's + * props (url/adminSecret/user.id/enabled) don't match the props the stored + * client was created for, this returns null to prevent sending to a stale + * room between render and the next effect run. + */ + client: CollabRoomClient | null; +} + +const DISCONNECTED_STATE: CollabRoomState = { + connectionStatus: 'disconnected', + roomStatus: null, + roomId: '', + clientId: '', + seq: 0, + planMarkdown: '', + annotations: [], + remotePresence: {}, + hasAdminCapability: false, + lastError: null, +}; + +/** + * Map joinRoom() / connect() errors to stable, UI-friendly codes so consumers + * can render actionable messages without string-matching `err.message`. + */ +function mapJoinFailure(err: unknown): { code: string; message: string } { + if (err instanceof InvalidRoomUrlError) return { code: 'invalid_room_url', message: err.message }; + if (err instanceof InvalidAdminSecretError) return { code: 'invalid_admin_secret', message: err.message }; + if (err instanceof ConnectTimeoutError) return { code: 'connect_timeout', message: err.message }; + if (err instanceof AuthRejectedError) return { code: 'auth_rejected', message: err.message }; + if (err instanceof RoomUnavailableError) return { code: 'room_unavailable', message: err.message }; + return { code: 'join_failed', message: err instanceof Error ? err.message : String(err) }; +} + +/** + * Serializable identity of the current hook props for comparison. + * JSON-array encoding avoids ambiguity around delimiters in url/user.id — + * this key is the ONLY barrier preventing a stale-room send, so it must be + * collision-proof regardless of what the caller passes. + */ +function roomKeyFor(url: string, adminSecret: string | undefined, userId: string, enabled: boolean): string { + return JSON.stringify([enabled, userId, adminSecret ?? null, url]); +} + +export function useCollabRoom(options: UseCollabRoomOptions): UseCollabRoomReturn { + const { url, adminSecret, user, enabled = true } = options; + const currentKey = roomKeyFor(url, adminSecret, user.id, enabled); + + const [state, setState] = useState(DISCONNECTED_STATE); + const [stateKey, setStateKey] = useState(''); // key the state belongs to + const clientRef = useRef(null); + const clientKeyRef = useRef(''); // key the stored client was created for + + // Keep user in a ref so mutation callbacks see latest name/color without + // triggering a reconnect. Reconnect only fires when user.id changes. + const userRef = useRef(user); + userRef.current = user; + + useEffect(() => { + // Reset synchronously on every dep change BEFORE any async setup. Between + // render and the async setup completing, key-gated reads below see + // DISCONNECTED_STATE / client: null for the new key so consumers can't + // send to the previous room. + clientRef.current = null; + clientKeyRef.current = ''; + setState(DISCONNECTED_STATE); + setStateKey(currentKey); + + if (!enabled) { + return; + } + + const effectKey = currentKey; + let cancelled = false; + let unsubscribe: (() => void) | null = null; + let createdClient: CollabRoomClient | null = null; + + (async () => { + try { + const client = await joinRoom({ + url, + adminSecret, + user: userRef.current, + autoConnect: false, + }); + createdClient = client; + + if (cancelled) { + client.disconnect(); + return; + } + + clientRef.current = client; + clientKeyRef.current = effectKey; + unsubscribe = client.on('state', (s) => { + // React Strict Mode runs effects twice in dev: mount → cleanup → mount. + // The outgoing cleanup sets `cancelled = true` but does not unsubscribe + // listeners from the previous client synchronously before disconnect, + // and disconnect() may emit a final 'closed' state event on this + // listener's tick. Without this guard, that late emission would call + // React setters on a cleaned-up effect and produce noisy state/flicker + // (and is a teardown race under unmount-during-reconnect in prod too). + if (cancelled) return; + setState(s); + setStateKey(effectKey); + }); + + // Push initial state + setState(client.getState()); + setStateKey(effectKey); + + await client.connect(); + } catch (err) { + // Unsubscribe BEFORE disconnecting so we don't receive a spurious + // 'closed' state event during teardown between the failure and the + // error surface below. + unsubscribe?.(); + unsubscribe = null; + if (createdClient) { + try { createdClient.disconnect(); } catch { /* ignore */ } + if (clientRef.current === createdClient) { + clientRef.current = null; + clientKeyRef.current = ''; + } + } + if (!cancelled) { + setState({ + ...DISCONNECTED_STATE, + lastError: mapJoinFailure(err), + }); + setStateKey(effectKey); + } + } + })(); + + return () => { + cancelled = true; + unsubscribe?.(); + clientRef.current?.disconnect(); + clientRef.current = null; + clientKeyRef.current = ''; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [url, adminSecret, user.id, enabled]); + + const requireClient = useCallback((): CollabRoomClient => { + const c = clientRef.current; + if (!c || clientKeyRef.current !== currentKey) { + throw new Error('Collab room client is not available (disabled, not yet connected, or room identity changed)'); + } + return c; + }, [currentKey]); + + const addAnnotations = useCallback(async (a: RoomAnnotation[]) => { + await requireClient().sendAnnotationAdd(a); + }, [requireClient]); + const updateAnnotation = useCallback(async (id: string, patch: Partial) => { + await requireClient().sendAnnotationUpdate(id, patch); + }, [requireClient]); + const removeAnnotations = useCallback(async (ids: string[]) => { + await requireClient().sendAnnotationRemove(ids); + }, [requireClient]); + const clearAnnotations = useCallback(async (source?: string) => { + await requireClient().sendAnnotationClear(source); + }, [requireClient]); + const updatePresence = useCallback(async (p: PresenceState) => { + await requireClient().sendPresence(p); + }, [requireClient]); + const lock = useCallback(async (opts?: { includeFinalSnapshot?: boolean }) => { + await requireClient().lockRoom(opts); + }, [requireClient]); + const unlock = useCallback(async () => { + await requireClient().unlockRoom(); + }, [requireClient]); + const deleteRoom = useCallback(async () => { + await requireClient().deleteRoom(); + }, [requireClient]); + + // Key-gate the returned state. If the hook's props have changed this render + // but the state was written against the previous key, return DISCONNECTED. + // Also gate the client escape hatch against the same key. + const stateForRender = stateKey === currentKey ? state : DISCONNECTED_STATE; + const clientForRender = clientKeyRef.current === currentKey ? clientRef.current : null; + + return { + connectionStatus: stateForRender.connectionStatus, + roomStatus: stateForRender.roomStatus, + planMarkdown: stateForRender.planMarkdown, + annotations: stateForRender.annotations, + remotePresence: stateForRender.remotePresence, + hasAdminCapability: stateForRender.hasAdminCapability, + lastError: stateForRender.lastError, + addAnnotations, + updateAnnotation, + removeAnnotations, + clearAnnotations, + updatePresence, + lock, + unlock, + deleteRoom, + client: clientForRender, + }; +} diff --git a/packages/ui/tsconfig.collab.json b/packages/ui/tsconfig.collab.json new file mode 100644 index 00000000..b7295755 --- /dev/null +++ b/packages/ui/tsconfig.collab.json @@ -0,0 +1,37 @@ +// Scoped Slice 4 verification config — typechecks only `hooks/useCollabRoom.ts`. +// Full `packages/ui` typecheck is intentionally deferred: the UI package has +// pre-existing type debt (strict-mode violations, missing CSS side-effect decls, +// bun:test lib gaps) unrelated to Slice 4. This config verifies the new hook +// without pulling that debt into a feature PR. +{ + "compilerOptions": { + "target": "ES2022", + "experimentalDecorators": true, + "useDefineForClassFields": false, + "module": "ESNext", + "lib": [ + "ES2022", + "DOM", + "DOM.Iterable" + ], + "skipLibCheck": true, + "types": [ + "node" + ], + "moduleResolution": "bundler", + "isolatedModules": true, + "moduleDetection": "force", + "allowJs": true, + "jsx": "react-jsx", + "paths": { + "@plannotator/ui/*": ["./*"], + "@plannotator/shared": ["../shared/index.ts"], + "@plannotator/shared/*": ["../shared/*"] + }, + "allowImportingTsExtensions": true, + "noEmit": true + }, + "include": [ + "hooks/useCollabRoom.ts" + ] +} diff --git a/specs/v1-decisionbridge-local-clarity.md b/specs/v1-decisionbridge-local-clarity.md new file mode 100644 index 00000000..03f06d5f --- /dev/null +++ b/specs/v1-decisionbridge-local-clarity.md @@ -0,0 +1,57 @@ +# V1 Local Bridge Trust Boundary + +This note clarifies the security boundary for local bridge mode. It is a companion to `specs/v1.md` and `specs/v1-decisionbridge.md`. + +## Local Bridge Flow + +```text +agent + -> localhost:/api/external-annotations + -> localhost SSE + -> browser receives plaintext annotation + -> browser encrypts with eventKey + -> browser sends ciphertext to room.plannotator.ai +``` + +The browser briefly handles plaintext. That is expected and consistent with the zero-knowledge model. The browser is the endpoint that decrypts and renders the plan and annotations for the user. + +Zero-knowledge means: + +```text +the remote room server cannot read the content +``` + +It does not mean: + +```text +the local browser never sees plaintext +``` + +## Trusted And Untrusted Components + +Trusted in local bridge mode: + +- the user's browser +- the user's local machine +- the user's chosen local agent +- the local Plannotator server on `localhost:` + +Untrusted / zero-knowledge: + +- `room.plannotator.ai` +- Durable Object storage +- room-service logs and observability + +Local bridge mode trusts the local agent and local Plannotator server because they receive or generate plaintext annotations. That is intentional when the user asks their own agent to review the plan. + +If a participant gives their own agent the room URL, that agent is a direct room client and can decrypt the plan and annotations. This is equivalent to inviting another participant. + +## Comparison To Excalidraw + +This is not weaker than the relevant Excalidraw model. Excalidraw participants' browsers also hold plaintext scene data and encryption keys while the collaboration server relays encrypted data. + +Plannotator's remote room service should still see only ciphertext. The key invariant is: + +```text +clients can read plaintext; the remote room server cannot +``` diff --git a/specs/v1-decisionbridge.md b/specs/v1-decisionbridge.md new file mode 100644 index 00000000..ca083064 --- /dev/null +++ b/specs/v1-decisionbridge.md @@ -0,0 +1,177 @@ +# V1 Decision Bridge and External Annotation Compatibility + +This document explains how Plannotator Live Rooms interoperate with the existing local external-annotations SSE model and with direct agent clients. The complete room-service architecture lives in `specs/v1.md`; this file narrows in on the bridge behavior. + +## Existing Local Flow + +The current external annotation path is local and plaintext: + +```text +agent/tool + -> localhost:/api/external-annotations + -> local server in-memory annotation store + -> SSE /api/external-annotations/stream + -> browser useExternalAnnotations() + -> editor allAnnotations +``` + +The relevant existing pieces are: + +- `packages/shared/external-annotation.ts`: shared event vocabulary, validation, and in-memory mutation store. +- `packages/server/external-annotations.ts`: Bun HTTP + SSE adapter. +- `apps/pi-extension/server/external-annotations.ts`: Node/http mirror for Pi. +- `packages/ui/hooks/useExternalAnnotations.ts`: React EventSource consumer with polling fallback. +- `packages/editor/App.tsx`: merges local annotations with SSE-delivered external annotations into `allAnnotations`. +- `packages/ui/utils/planAgentInstructions.ts`: agent-facing instructions for reading `/api/plan` and posting `/api/external-annotations`. + +## What Transfers + +Transfer these concepts to room collaboration: + +- snapshot/add/update/remove/clear event vocabulary +- source-based cleanup semantics +- batch annotation input shape +- `COMMENT` vs `GLOBAL_COMMENT` validation semantics +- stable annotation IDs and server-authoritative echo reconciliation +- agent-facing instruction style +- polling fallback pattern for local API environments + +The decrypted room event vocabulary should mirror the useful pieces while using room-specific names and stable IDs. The canonical types live in `specs/v1.md` as `RoomClientOp` and `RoomServerEvent`; this file only restates the bridge-relevant annotation operations: + +```ts +type RoomAnnotationOp = + | { type: "annotation.add"; annotations: RoomAnnotation[] } + | { type: "annotation.update"; id: string; patch: Partial } + | { type: "annotation.remove"; ids: string[] } + | { type: "annotation.clear"; source?: string }; +``` + +`opId` is part of the encrypted `ServerEnvelope` metadata, not the decrypted operation payload. Room snapshots use the full encrypted `RoomSnapshot` shape from `specs/v1.md` so the plan markdown and stable annotation IDs travel together. + +## What Does Not Transfer + +Do not transfer these parts to `room-service`: + +- plaintext server-side annotation storage +- SSE as the live room transport +- server-generated annotation IDs +- content-based dedupe as the primary dedupe strategy +- unauthenticated public room mutation endpoints + +The existing local server may continue to see plaintext because it is running on the user's machine. The remote room service must store and relay ciphertext only. + +## Local Bridge Mode + +Local bridge mode preserves the current Plannotator model while adding encrypted room replication: + +```text +agent/tool + -> localhost:/api/external-annotations + -> local Plannotator SSE store + -> creator/participant browser + -> encrypt with eventKey + -> room.plannotator.ai Durable Object + -> other room clients +``` + +The existing local API remains valid: + +```text +GET /api/plan +GET /api/external-annotations +GET /api/external-annotations/stream +POST /api/external-annotations +PATCH /api/external-annotations?id= +DELETE /api/external-annotations?id= +DELETE /api/external-annotations?source= +``` + +When a browser is joined to a room, annotations received from local SSE should be converted into encrypted room ops: + +```text +SSE snapshot/add/update/remove/clear + -> local browser reducer + -> room annotation.add/update/remove/clear op + -> encrypted ServerEnvelope + -> room-service +``` + +The browser must prevent duplicates when an annotation received from local SSE is forwarded into the room and later echoed back. Use stable annotation IDs and the server-authoritative echo path: room-backed annotation state updates from the echoed event, not from a second local optimistic apply. `opId` remains useful protocol metadata for future ack/reject support, but V1 does not maintain an own-echo dedupe cache. + +If a local SSE annotation includes image attachments, the bridge should strip the image fields before forwarding it as a `RoomAnnotation` op. V1 room annotations use `RoomAnnotation`, which excludes `images` because existing image attachments are local paths rather than portable encrypted assets. The annotation text content is still forwarded. + +## Direct Agent Client Mode + +Agents can also participate without a local Plannotator server if the user gives them the room URL: + +```text +agent + -> wss://room.plannotator.ai/ws/ + -> challenge-response auth + -> decrypt latest snapshot + -> read plan + annotations + -> send encrypted annotation ops +``` + +A direct agent client receives: + +```text +https://room.plannotator.ai/c/#key= +``` + +The agent derives the same room keys as a browser client and uses shared collab helpers for: + +- `parseRoomUrl` +- `deriveRoomKeys` +- `authenticateRoomSocket` +- `decryptSnapshot` +- `subscribeRoomEvents` +- `addAnnotation` +- `updateAnnotation` +- `removeAnnotation` +- `clearAnnotationsBySource` + +Agents are clients. Giving an agent the full room URL grants it the ability to read the plan and annotations and submit encrypted annotations. + +## Creator Agent Decision Bridge + +The creator's browser is usually the bridge back to the primary local agent because it holds both: + +- the encrypted room WebSocket/session +- `localhost:` access to the running Plannotator server + +Creator/admin-only controls: + +- Approve +- Deny / Send Feedback +- Lock review +- Unlock review +- Delete room from Plannotator servers + +Approve flow: + +1. Browser consolidates all room annotations into `annotationsOutput`. +2. Browser POSTs to `localhost:/api/approve`. +3. If approve succeeds, browser locks the room. +4. Room remains readable as a frozen review snapshot. + +Deny flow: + +1. Browser consolidates all room annotations into `annotationsOutput`. +2. Browser POSTs to `localhost:/api/deny`. +3. Room remains active by default for the next revision cycle. + +All participants, not just the creator, may export, copy, or download consolidated feedback from the encrypted room state. Only the creator/admin can submit approve/deny to their local agent bridge or lock/delete the room. + +## Trust Boundary + +In local bridge mode, the browser performs plaintext-to-encrypted translation: it receives plaintext annotations from localhost SSE, encrypts them with `eventKey`, and sends encrypted room envelopes to `room.plannotator.ai`. + +This is not a server-side zero-knowledge break. The trusted boundary is: + +```text +trusted: user's browser, user's local machine, user's chosen local agent +untrusted/zero-knowledge: room.plannotator.ai +``` + +Clients can read plaintext; the remote room server cannot. diff --git a/specs/v1-implementation-approach.md b/specs/v1-implementation-approach.md new file mode 100644 index 00000000..b5ed50fd --- /dev/null +++ b/specs/v1-implementation-approach.md @@ -0,0 +1,253 @@ +# Plannotator Live Rooms V1 Implementation Approach + +This document describes how to implement Plannotator Live Rooms without turning the work into one oversized change. The product rationale lives in `specs/v1-prd.md`. The protocol and room-service details live in `specs/v1.md`. The agent/SSE bridge details live in `specs/v1-decisionbridge.md`. + +The implementation should be a stack of reviewable slices. Each slice should create a real, testable building block that the next slice imports instead of reimplementing. + +## Guiding Constraints + +The spine of the implementation is `packages/shared/collab`. It owns the canonical protocol types, key derivation helpers, encryption helpers, challenge-response helpers, URL helpers for clients, image-stripping helpers, and test vectors. Later slices should import from this package rather than creating local copies of the protocol. + +The room server must not parse full room URLs. URL fragments are client-only. `parseRoomUrl()` is for browser and direct-agent clients that receive: + +```text +https://room.plannotator.ai/c/#key= +``` + +The Worker and Durable Object never receive the fragment and must not accept full room URLs as input. They receive only `roomId` through `/api/rooms` request bodies or `/ws/` routes, plus verifiers, proofs, ciphertext, and non-secret metadata in request or WebSocket message bodies. + +Each slice should preserve these invariants: + +- no `roomSecret`, `adminSecret`, `authKey`, `eventKey`, `presenceKey`, or `adminKey` crosses the network +- no WebSocket query-string auth +- no server-side plaintext plan, annotation, comment, cursor, display name, color, or selected annotation state +- no room-service dependency on the existing paste-service KV storage model +- no CRDT or document editing work in V1 +- no encrypted room assets in V1; strip image attachments and notify +- no local SSE concepts copied into the remote room service except the compatible operation vocabulary + +## Slice 1: Shared Collab Contract + +Create `packages/shared/collab` as the canonical protocol package. + +This slice should include: + +- room schemas and discriminated unions for transport messages, `ServerEnvelope`, `RoomClientOp`, `RoomServerEvent`, `RoomSnapshot`, `RoomAnnotation`, `PresenceState`, room auth messages, and admin command messages +- `roomId`, `opId`, `clientId`, challenge ID, and nonce helpers +- HKDF/HMAC key derivation helpers for `authKey`, `eventKey`, `presenceKey`, and `adminKey` +- verifier/proof helpers for room auth and admin command auth +- `canonicalJson()` for admin command proof binding +- encryption/decryption helpers for event payloads, presence payloads, and snapshots +- client-only room URL helpers such as `parseRoomUrl()` and `buildRoomJoinUrl()` +- helpers to convert existing `Annotation` values into V1 `RoomAnnotation` values by stripping image attachments +- test vectors for key derivation, verifiers, proofs, canonical JSON, encrypted envelopes, and URL parsing + +This slice should not include a Worker, Durable Object, React hook, or editor integration. It is the contract everything else builds on. + +Verification gate: + +- unit tests pass for schema validation, key derivation, verifier/proof generation, canonical JSON stability, encryption/decryption round trips, image stripping, and URL parsing +- a negative test proves that malformed room URLs and missing fragments are rejected by client helpers +- a server-oriented import test or lint boundary makes clear that server code imports schema/proof helpers but not browser-only URL parsing +- manual review confirms there is one canonical set of operation names: `annotation.add`, `annotation.update`, `annotation.remove`, `annotation.clear`, and `presence.update` + +## Slice 2: Room-Service Skeleton + +Add `apps/room-service` as a Cloudflare Worker with a Durable Object namespace, but keep behavior narrow. + +This slice should include: + +- Cloudflare Worker entrypoint and configuration +- routing for: + ```text + GET /health + GET /c/ + GET /assets/* + POST /api/rooms + GET /ws/ + ``` +- room creation that stores `roomVerifier`, `adminVerifier`, encrypted initial snapshot, `seq = 0`, `snapshotSeq = 0`, status `active`, and fixed expiry +- duplicate `roomId` rejection with `409 Conflict` +- create response that returns `joinUrl` without a fragment and `websocketUrl` without query-string auth +- WebSocket upgrade into the room Durable Object +- connection challenge-response auth using the shared proof helpers +- `auth.accepted` after successful auth +- redaction for proofs, verifiers, ciphertext, message bodies, and request bodies in logs + +This slice should not implement annotation sequencing, replay, presence relay, admin commands, or editor UI. Its job is to prove the service boundary, creation path, and authentication path. + +Verification gate: + +- Worker tests can create a room and receive a fragmentless `joinUrl` +- duplicate room creation returns `409 Conflict` +- invalid proofs are rejected and valid proofs are accepted +- challenges expire and cannot be reused +- WebSocket URLs do not contain auth material +- server tests pass using only `roomId`, verifier/proof helpers, and encrypted snapshot inputs; no server test should need a full `https://...#key=...` room URL + +## Slice 3: Durable Room Engine + +Fill in the Durable Object room behavior. + +This slice should include: + +- durable sequencing for `channel: "event"` envelopes +- volatile relay for `channel: "presence"` envelopes +- encrypted event log storage with `seq` and `receivedAt` +- encrypted snapshot storage with `snapshotSeq` +- V1 does NOT implement active compaction. The DO retains all durable events for the room's lifetime (`earliestRetainedSeq` stays at 1). Admin-initiated `lockRoom({ finalSnapshot })` writes a final snapshot used by future joins. Post-V1 work may introduce a snapshot-after-N-ops / snapshot-after-N-KiB rule; the replay path already checks `lastSeq >= earliestRetainedSeq` and falls back to snapshot replay when compaction advances that cursor. +- reconnect behavior using `lastSeq`: replay if retained, otherwise send latest encrypted snapshot and retained events after `snapshotSeq` +- `active`, `locked`, `deleted`, and `expired` lifecycle enforcement +- admin challenge-response per command for lock, unlock, and delete +- `410 Gone` behavior for deleted and expired rooms +- retention cleanup for expired rooms with distinct `expired` status, not creator-initiated `deleted` + +This slice should still be test-client driven. It should not depend on React or editor code. + +Verification gate: + +- two test clients can authenticate to the same room and exchange encrypted durable events +- only durable event envelopes receive `seq`; presence is broadcast but not persisted +- reconnect from a retained `lastSeq` replays the correct events +- reconnect from a compacted `lastSeq` sends snapshot plus retained events +- locked rooms reject annotation mutations but still allow snapshot reads and presence by the V1 policy +- unlock returns the room to active state +- delete closes or rejects room access and future joins return `410 Gone` +- expiry closes or rejects room access and future joins return `410 Gone` with an expired-room reason +- invalid admin proofs, reused admin challenges, and expired admin challenges are rejected + +## Slice 4: Browser And Agent Collab Client + +Build the client runtime on top of `packages/shared/collab` and the room-service API. + +This slice should include: + +- `useCollabRoom` under `packages/ui/hooks` +- a lower-level collab client usable by browser code and direct-agent clients +- client-side parsing of `/c/#key=...` +- room key derivation in the client only +- room creation helper that generates `roomId`, `roomSecret`, `adminSecret`, verifiers, and encrypted initial snapshot +- join helper that authenticates, receives `auth.accepted`, requests replay/snapshot based on `lastSeq`, and decrypts room messages +- server-authoritative annotation apply: send encrypted ops, then update local room state from the echoed `room.event` +- stable `clientId` per WebSocket connection and stable encrypted `PresenceState.user.id` +- reconnect behavior with `lastSeq` +- methods for `sendAnnotationAdd`, `sendAnnotationUpdate`, `sendAnnotationRemove`, `sendAnnotationClear`, `sendPresence`, `lockRoom`, `unlockRoom`, and `deleteRoom` +- a direct-agent client surface that exposes decrypted room state and annotation mutation helpers after the user gives the agent the full room URL + +This slice should not yet modify the main editor UI. It should be runnable against the Slice 3 room service using test harnesses or a minimal dev page. + +Verification gate: + +- client tests prove full room URLs are parsed only in client/direct-agent code +- browser client can create a room, join it, decrypt the initial snapshot, and send encrypted ops against the Slice 3 service +- direct-agent client can receive a full room URL, derive keys locally, authenticate, decrypt the snapshot, and submit an encrypted annotation op +- reconnect tests cover retained replay and snapshot fallback +- echo tests prove mutation sends do not update local room state until the server echo, and own echoes apply through the same server-authoritative path as peer events + +## Slice 5: Editor Product Integration + +Wire the browser client into the existing Plannotator editor. + +This slice should include: + +- “Start live room” in the share UI, separate from hash and paste links +- room link copy using `https://room.plannotator.ai/c/#key=` +- room status badge and connected participant presence +- remote cursor overlay in `Viewer`, not as annotations +- annotation create/update/remove/clear paths that emit encrypted room ops when in a room, show lightweight pending feedback, and update room-backed annotation state from the server echo +- remote annotation ops applied without regenerating IDs +- image attachment stripping when entering a room, with clear user notice +- creator/admin controls for lock, unlock, and “Delete room from Plannotator servers” +- approve flow that consolidates room annotations, POSTs to the local approve endpoint, and locks the room after success +- deny flow that consolidates room annotations and POSTs to the local deny endpoint while leaving the old room active for the old plan +- export/copy consolidated feedback for all participants +- no changes to existing static sharing behavior + +This slice should produce the first human-usable live room. + +### Production hardening: rate-limit `POST /api/rooms` + +Room creation is intentionally unauthenticated in the V1 protocol — a room is a capability token pair (roomSecret + adminSecret) the creator generates locally, and `POST /api/rooms` only asserts existence, not identity. Before public deployment, the route MUST be protected by one of: + +- Cloudflare rate limiting / WAF rule keyed on source IP + path +- an application-level throttle at the Worker entry (e.g. a shared Durable Object counter or KV-based token bucket) +- an authenticated proxy in front of the Worker (plannotator.ai app calls it on behalf of signed-in users) + +CORS is NOT abuse protection — it's a browser same-origin policy that does nothing to a direct HTTP client. This is a production requirement, not a Slice 4 runtime gap; the V1 protocol is designed to allow this additive gating without client changes. Recorded here so future reviewers do not re-flag it as a protocol issue. + +### URL-fragment credential hygiene + +Room credentials live in the URL fragment (`#key=…&admin=…`) and never touch the network as query params. Browsers already strip URL fragments from outbound `Referer` headers, so the fragment itself is not what escapes; the server sets `Referrer-Policy: no-referrer` on `/c/:roomId` as belt-and-braces — it strips the path (which carries the room id) and the origin from any outbound `Referer` the page triggers, reducing third-party exposure. The real credential-leak channel on this page is JavaScript reading `window.location.href`. The editor code that ships in this slice MUST: + +- not send `window.location.href`, `document.referrer`, or any serialized URL to telemetry, error-reporting, analytics, or third-party logging services without first scrubbing the `#key=` and `#admin=` fragment params. +- treat any tool that captures "page URL" (Sentry, Datadog RUM, custom ingestion) as a secret-leak channel until a scrubbing layer is in place. +- prefer redacting to the `pathname` + non-credential fragment params only, or to a stable route identifier. + +Verification gate: + +- local dev can start the editor and room service together +- two browser sessions can join the same room and see annotations appear in both sessions +- cursor presence renders without being stored as annotations +- locked room prevents new annotation mutations and remains readable +- unlock restores annotation ability +- delete removes server-side room state and later joins fail +- approve sends consolidated feedback to the local agent bridge and locks the room on success +- deny sends consolidated feedback to the local agent bridge and leaves the old room active for the old plan +- static hash sharing and paste-service short links still work +- room creation with image attachments strips images, preserves text annotations, and shows the notice + +## Slice 6: Agent Bridge And Direct-Agent Hardening + +Wire room collaboration into the existing local external-annotations flow and document direct-agent usage. + +This slice should include: + +- bridge from existing `/api/external-annotations` SSE events into encrypted room ops when the browser is joined to a room +- mapping for snapshot/add/update/remove/clear into `annotation.add`, `annotation.update`, `annotation.remove`, and `annotation.clear` +- source-based cleanup semantics for agent reruns +- image stripping for local SSE annotations before forwarding as `RoomAnnotation` +- duplicate prevention between local SSE annotations and echoed room events using stable IDs and the single server-authoritative apply path +- agent-facing instructions for direct room clients that make the security model explicit +- examples or tests showing an agent can connect as an end client when the user gives it the full room URL + +This slice should not make the room service aware of Claude Code, OpenCode, Codex, or any local agent loop. The room service remains an encrypted coordination service only. + +Verification gate: + +- existing local external annotations still work outside rooms +- while joined to a room, local SSE `add`, `update`, `remove`, and `clear` produce encrypted room ops +- source-based clear removes the intended agent annotations across room participants +- image-bearing local annotations forward text content and strip image fields +- direct-agent client can read the decrypted plan and submit encrypted annotations after being given the full room URL +- room-service logs and server APIs still never receive room secrets or plaintext annotations + +## Stack Discipline + +Treat these as stacked PRs: + +1. shared collab contract +2. room-service skeleton +3. durable room engine +4. browser/direct-agent client +5. editor product integration +6. agent bridge and direct-agent hardening + +Do not merge a later slice by inventing temporary protocol shapes that disagree with earlier slices. If a later slice finds a flaw in the shared protocol, update `packages/shared/collab` and its tests first, then adjust dependent slices. + +Each slice should include enough tests or harnesses to prove it works without waiting for the whole feature. The goal is not to ship six independent product features; the goal is to make one product feature reviewable in six coherent layers. + +## Known Post-V1 Follow-Ups + +Keep these out of V1 unless a V1 decision explicitly changes: + +- multi-version room documents and version tabs +- participant-submitted plan versions +- annotation carry-forward/resolution across versions +- encrypted image/blob assets, likely backed by R2 +- room key rotation and revocation +- login-backed identity or verified participant identity +- role-based moderation beyond creator/admin lock, unlock, and delete +- client-side hash chains for tamper evidence +- activity-based TTL extension +- multi-admin support — V1 assumes a single creator-held admin capability, so the client resolves admin commands by observing `room.status` transitions rather than command-specific acks. Multi-admin requires a protocol change: add `commandId` to `AdminCommandEnvelope` and an `admin.result { commandId, ok, error? }` message from the room service so concurrent admin commands can be disambiguated. +- presence performance — **must be addressed in Slice 5, not documentation-only.** Every remote cursor update calls `emitState()`, which clones the full annotations array. With large plans and typical cursor-update frequencies (30–60 Hz on active typing), this causes visible render jank. Slice 5 must at minimum throttle `sendPresence()` emissions (50–100ms, trailing-edge) before wiring remote cursors into the editor UI. Longer-term fix: split presence from the annotation `state` event entirely (so presence-only updates don't trigger annotation-array cloning) or preserve stable annotation-array references across presence-only state snapshots. Don't ship the editor integration until one of these lands. diff --git a/specs/v1-prd.md b/specs/v1-prd.md new file mode 100644 index 00000000..70efe47e --- /dev/null +++ b/specs/v1-prd.md @@ -0,0 +1,126 @@ +# Plannotator Live Rooms PRD + +## Problem + +Plannotator's review workflow is single-player. One person reviews the plan, annotates it, and approves or denies. If a team of three needs to review the same plan, the current options are: + +1. **Sequential handoff.** One person reviews, shares a URL, the next person imports those annotations, adds their own, re-shares. This is slow and loses context between rounds. + +2. **Parallel share links.** Each reviewer gets their own copy via a share URL, annotates independently, and sends their link back. The plan author then manually reconciles N sets of feedback. Annotations conflict, duplicate, and lose the conversation between reviewers. + +3. **Out-of-band discussion.** Reviewers talk in Slack or a call while one person drives the Plannotator UI. The other reviewers' input is verbal, not captured as structured annotations. + +None of these are collaboration. They're workarounds for the absence of it. + +This matters because plan review is inherently a team activity. An implementation plan touches multiple people's domains -- backend, frontend, infrastructure, product. The person who wrote the plan is rarely the only person whose input determines whether it's good. When Plannotator forces single-player review, it pushes the multi-party conversation out of the tool and into unstructured channels where feedback is lost. + +[PR #316](https://github.com/backnotprop/plannotator/pull/316) from the community attempted to solve this by adding collaborative sessions backed by the paste service. [PR #52](https://github.com/backnotprop/plannotator/pull/52) tried earlier with Supabase real-time sync. PR #52 was closed; PR #316 remains open but raised design and security concerns that this spec addresses. The demand is clear and recurring. The previous attempts failed not because the idea was wrong, but because the infrastructure wasn't right: KV-based optimistic locking is racy under concurrent writes, polling-based sync creates stale state, and the approaches didn't preserve Plannotator's zero-knowledge encryption model. + +## Who This Is For + +**Team leads and senior engineers** who use Plannotator to review Claude Code or OpenCode plans before approving them. They want input from teammates before making the approve/deny decision, but today they either review alone or leave the tool to gather feedback. + +**Teammates and domain experts** who are asked "can you look at this plan?" and currently receive a share link, open it, annotate in isolation, and send it back. They have no way to see what others have already said or build on each other's feedback. + +**Agents as reviewers.** Claude, Codex, and other agents can already post annotations via the external annotations API. In a live room, an agent with the room URL could participate as a first-class reviewer alongside human teammates, reading the plan and submitting structured feedback in real time. + +## What This Enables + +**One URL, one room, everyone annotates together.** The plan creator starts a live room, copies the link, and shares it. Everyone who opens the link sees the same plan, sees each other's cursors, and sees annotations appear in real time. No import/export cycle. No reconciliation. + +**The creator retains the decision.** Only the person who started the room (and whose agent is waiting for a response) can approve or deny the plan. Everyone else contributes annotations. This matches the existing Plannotator model: one decision-maker, now with collaborative input. + +**Privacy by default.** The room server coordinates traffic but cannot read plans, annotations, comments, cursor positions, or participant names. All application data is encrypted client-side with a key that lives only in the URL fragment. This is the same zero-knowledge model as Plannotator's existing share links, extended to live collaboration. + +**No accounts, no setup.** Collaboration starts with a link. No login, no team workspace, no invitation flow. If you have the link, you're in the room. This matches Plannotator's existing product style and the Excalidraw model that users already understand. + +## User Flows + +### Starting a Live Room + +The plan creator is already in the Plannotator review UI with a plan from their agent. Today they see "Share" with options for hash links and short URLs. With live rooms, they also see "Start live room." + +Clicking "Start live room" creates the room, uploads the encrypted plan, and produces a room URL. The creator copies and shares this URL however they normally share links -- Slack, email, a call. + +The creator's review UI transitions into room mode. They see a presence indicator showing connected participants and a room status badge. Their Approve and Deny buttons remain. They can annotate as usual, and their annotations appear for everyone. + +If the plan includes image attachments, the room is created normally but images are stripped from annotations and global attachments. The UI shows a notice that image attachments aren't supported in live rooms yet and that encrypted room assets are on the roadmap. + +### Joining a Room + +A teammate clicks the room URL. The browser opens, derives encryption keys from the URL fragment, authenticates with the room server, and loads the plan with all existing annotations. The teammate sees other participants' cursors and self-identified names from the existing Plannotator display name system, can read all annotations, and can add their own. + +The teammate does not see Approve or Deny buttons. They see annotation tools and an export button. Their role is to contribute feedback, not make the decision. + +### Annotating Together + +When any participant creates an annotation -- a comment, a deletion mark, a quick label -- it appears for everyone within moments. Participants can see each other's cursors moving through the document. If someone is focused on a particular section, others can see that and either contribute there or work elsewhere. + +Annotations from agents (via the external annotations API or direct room connection) appear the same way, attributed to their source. + +### Approving with Consolidated Feedback + +When the creator is satisfied with the review, they click Approve. The browser gathers all annotations from all participants into a single feedback payload and submits it to their local Plannotator server, which returns the decision to the waiting agent. The room automatically locks -- participants can still read the frozen snapshot but can no longer add annotations. + +If the creator denies instead, the room stays active for the current plan version while the agent revises outside the room. In V1, reviewing the revised plan requires starting a new live room. The room model intentionally carries `versionId: "v1"` so a future release can support multiple plan versions in the same room without migrating the data model. + +### Locking and Closing + +The creator can lock the room at any time to freeze annotations without approving or denying. This is useful when the review discussion is complete but the decision isn't ready yet, or when the creator wants to read through consolidated feedback without new annotations appearing. + +Locking is reversible. If the creator locked too early, they unlock and the room returns to active. + +When the room is no longer needed, the creator can delete it. This removes all encrypted data from Plannotator's servers. Participants who already received the data may still have it locally -- deletion is a server-side cleanup, not a revocation. + +Rooms that are never explicitly deleted expire after 30 days. + +## How This Relates to Existing Features + +**Static sharing is unchanged.** Hash-based URLs and paste-service short links continue to work exactly as they do today. They remain the right choice for async, one-way sharing where live presence isn't needed. + +**The external annotations API is unchanged.** Agents that post to `localhost:/api/external-annotations` continue to work. When the creator's browser is in a live room, locally received annotations are forwarded into the room as encrypted operations. Annotations that include image attachments are forwarded without the images, since V1 rooms don't support encrypted assets. The agent doesn't need to know about rooms. + +**The approve/deny flow is unchanged.** The creator's browser still POSTs to their local Plannotator server. `waitForDecision()` still resolves the same way. The agent feedback loop is untouched. The room is a collaboration layer on top of the existing decision flow, not a replacement for it. + +**The plan review UI is the same editor.** Room mode adds presence indicators, a room status badge, and the lock/unlock/delete controls. The annotation tools, markdown renderer, sidebar, settings, and themes are the same. + +## What's Not in V1 + +**No image attachments in rooms.** The current image model uses local file paths. Sharing images across participants requires encrypted blob storage, which is a meaningful infrastructure addition. V1 strips image attachments from annotations when entering a room and notifies the user. Encrypted room assets are planned as a fast follow. + +**No document editing.** The plan is fixed for the life of the room (one version). Participants annotate it but don't modify the underlying markdown. Future versions will support the creator publishing revised plans after a deny cycle, with version tabs and annotation carry-forward. + +**No accounts or roles.** Access is link-based. There's a creator (who has the admin capability) and participants (who have the room link). There's no invite list, no viewer-vs-commenter distinction, no team management. + +**No CRDTs or conflict-free text editing.** Annotations are discrete objects with stable IDs, not collaborative text ranges. The server sequences operations. This is simpler than a collaborative editor because annotations don't overlap or merge in complex ways. + +**No room key rotation or revocation.** If the room link leaks, the only remedy is to delete the room and create a new one. Key rotation is a post-V1 capability. + +## Risks + +**Adoption requires a behavior change.** Today, reviewers annotate alone. Live rooms require sharing a link and waiting for others to join. If the review culture stays "one person reviews in isolation," rooms won't get used regardless of how well they work. The UX should make starting a room feel as lightweight as copying a share link. + +**Bearer links can leak.** The room URL is the access credential. If it's posted publicly, anyone can join and read the encrypted content. This is the same model as Excalidraw, Google Docs "anyone with the link," and Plannotator's existing share URLs -- but it's worth being explicit about in product copy and documentation. + +**Image stripping may surprise creators.** Plans with image attachments (mockups, diagrams, screenshots) are common. Stripping images when entering a room means reviewers lose visual context. The notification needs to be clear and upfront, and encrypted room assets should be prioritized as a fast follow. + +**Agent participation requires trust.** Giving an agent the room URL gives it full read/write access to the room's encrypted content. This is equivalent to inviting a human participant, but users may not think of it that way. The UX should make this explicit when sharing room URLs with agents. + +**The server sees metadata, not content.** While the room server cannot read plans, annotations, or presence data, it can observe room activity patterns: connection counts, message timing, ciphertext sizes, IP addresses, and whether a room is active, locked, or expired. This is inherent to any server-coordinated system and should be documented plainly rather than hidden behind the "zero-knowledge" label. + +## Success Criteria + +The feature succeeds if: + +- A team of 2-4 reviewers can annotate the same plan simultaneously without import/export cycles +- The creator can approve or deny with consolidated multi-party feedback in a single action +- The room server stores only ciphertext -- a server compromise does not expose plan content +- The feature works without accounts, configuration, or setup beyond sharing a URL +- Existing single-player review, static sharing, and agent annotation workflows are unaffected + +## Reference + +Technical implementation: `specs/v1.md` +Implementation approach: `specs/v1-implementation-approach.md` +Agent bridge and SSE compatibility: `specs/v1-decisionbridge.md` +Local bridge trust boundary: `specs/v1-decisionbridge-local-clarity.md` diff --git a/specs/v1-slice4-plan.md b/specs/v1-slice4-plan.md new file mode 100644 index 00000000..fa91ae2b --- /dev/null +++ b/specs/v1-slice4-plan.md @@ -0,0 +1,403 @@ +# Slice 4: Browser/Direct-Agent Collab Client Runtime + +## Context + +Slices 1-3 built the protocol contract, the room service, and the durable room engine. The `apps/room-service/scripts/smoke.ts` file (390 lines) is the inline reference client — it implements every client behavior using direct WebSocket and crypto calls. Slice 4 refactors those patterns into a reusable `CollabRoomClient` runtime and a React hook, so browsers and direct-agent clients can connect to room.plannotator.ai without duplicating protocol code. + +Slice 4 does NOT wire into `packages/editor/App.tsx`, add share UI, cursor overlays, approve/deny, or the local SSE bridge. Those are Slice 5 and Slice 6. + +## File Structure + +Create a new subdirectory under `packages/shared/collab/`: + +``` +packages/shared/collab/client-runtime/ + types.ts — runtime state + options types + emitter.ts — TypedEventEmitter (tiny local, ~40 lines) + backoff.ts — pure computeBackoffMs() for reconnect + apply-event.ts — pure reducer applying RoomServerEvent to annotations Map + client.ts — CollabRoomClient class (core, ~400 lines) + create-room.ts — createRoom() HTTP helper + join-room.ts — joinRoom() factory + mock-websocket.ts — in-memory WebSocket for unit tests + + emitter.test.ts + backoff.test.ts + apply-event.test.ts + client.test.ts — uses mock-websocket + integration.test.ts — gated by SMOKE_BASE_URL (against wrangler dev) +``` + +Export from the **client barrel only** (`@plannotator/shared/collab/client`). The Worker/DO must never import from client-runtime. + +Extend `packages/shared/collab/url.ts` to parse the `admin=` fragment param and add `buildAdminRoomUrl()`. + +Add `packages/ui/hooks/useCollabRoom.ts` as the React wrapper. + +## Key Design Decisions + +**Class with factory wrappers.** `CollabRoomClient` is a class (long-lived state, many methods). `createRoom()` and `joinRoom()` are factory functions that construct instances. Clients can call `new CollabRoomClient()` directly if needed; typical callers use the factories. + +**Tiny local TypedEventEmitter.** ~40 lines, no dependency. API: `on(name, fn) → unsubscribe`, `off`, `emit`, `removeAll`. Wraps listeners in try/catch so one throwing listener doesn't break others. Avoids `EventTarget` (poor typing, silently swallows errors) and external packages (supply-chain and bundle cost not worth it for 40 lines). + +**Internal state: `Map`.** Fast O(1) updates and lookups. Derive an ordered `RoomAnnotation[]` for consumers via `[...map.values()]`. Map insertion order is stable per spec. + +**Auto-reconnect with exponential backoff + jitter.** `initialDelayMs = 500`, `maxDelayMs = 15_000`, `factor = 2`. Full jitter: `Math.min(max, initial * factor^attempt) * Math.random()`. Reset attempt counter on successful `auth.accepted`. On terminal close (room deleted/expired), transition to `'closed'` and stop. Reconnect sends current `this.seq` as `lastSeq` so the server replays missed events. + +**Server echo is authoritative (no optimistic apply in V1).** On `sendAnnotationAdd(...)`: generate `opId`, encrypt, send over wire. Do NOT apply to local state. The server broadcasts the op back via `room.event` to ALL clients including the sender; `handleRoomEvent` processes it and advances `this.seq`. Valid events are applied through `applyAnnotationEvent`; malformed or undecryptable events are consumed for forward progress but do not mutate state. There is no echo dedup. Rationale: V1 has no opId-correlated ack/reject, so optimistic apply has no safe rollback path if the server rejects the op (e.g., room transitions to locked between local check and DO processing). Mutation methods resolve when the send completes, not when state updates — consumers subscribe to the `state` event for post-echo state. + +**Accepted V1 UX tradeoff: slight latency for accepted-state correctness.** The original Slice 4 direction considered optimistic local apply: the sender would see an annotation immediately, then reconcile with the server echo. V1 intentionally does not do this. A user-created annotation appears only after the room service receives it, assigns a durable `seq`, and echoes it back. On healthy networks this should feel near-instant, but slow or unstable connections may show a brief delay between submit and visible annotation. + +We accept that delay because V1 has no op-specific `ack` / `reject` message and no rollback protocol. If the client applied locally and the server later rejected the op (for example because the room locked between the local status check and DO processing), the sender could see an annotation that no other participant sees. That is more confusing than a small delay. V1 therefore prefers one consistent, server-accepted view over instant local feedback. Product UI should show lightweight pending feedback around annotation submission and surface `room.error` clearly, especially `room_locked`. If later UX requires instant-feeling annotations, add protocol support first: `opId`-correlated `ack` / `reject`, or explicit client-side pending annotations with rollback. + +**Admin command resolution via observed effects.** The server doesn't send `admin.command.ok` — effects are observed via `room.status` broadcast (lock/unlock) or socket close (delete). The client resolves/rejects admin promises based on observable outcomes, not on send: +- `lockRoom()` resolves when the next `room.status: locked` arrives for this client. +- `unlockRoom()` resolves when the next `room.status: active` arrives (from locked). +- `deleteRoom()` resolves only when `room.status: deleted` is broadcast OR the socket closes with the server's successful-delete signature (`WS_CLOSE_ROOM_UNAVAILABLE` + reason `"Room deleted"`). Other close codes/reasons (network drop, `"Room delete failed"`) reject with `AdminInterruptedError` — we must not report success when the delete was interrupted or failed. +- ALL admin promises reject on: `room.error` received while pending (server rejected proof/state/seq), non-deletion socket close while pending, or 5s timeout with no observable effect. + +This prevents the class of bug where a caller thinks a lock succeeded when the server actually rejected it for invalid proof/state/seq. + +**Presence uses `presenceKey`, not `eventKey`.** The smoke.ts reference incorrectly uses `eventKey` for both channels — the DO is zero-knowledge so it doesn't notice. The protocol spec derives a distinct `presenceKey` via HKDF with label `plannotator:v1:presence`. The runtime must use the correct key per channel. Unit test: presence ciphertext encrypted with presenceKey must NOT decrypt with eventKey. + +**`clientId` is regenerated per connection.** Every `connect()` call generates a fresh `clientId` via `generateClientId()` (random per socket). On reconnect, a new `clientId` is minted — reusing one across reconnects would turn server-visible metadata into a longer-lived participant identifier. Stable identity across reconnects lives inside encrypted `PresenceState.user.id`, which the DO never sees. + +**Stale presence cleanup.** Because `clientId` rotates per connection and the server sends no "presence leave" events, stale cursors can accumulate when peers disconnect or reconnect. The runtime tracks `lastSeen: number` alongside each remote presence entry and runs a periodic sweep (every 5s) that removes entries where `Date.now() - lastSeen > PRESENCE_TTL_MS` (default 30s — longer than the typical presence heartbeat interval but short enough to feel fresh). On any `room.presence` message, update `lastSeen` for the sender's clientId. On socket close, clear all remote presence. + +**Connect timeout and auth handshake cleanup.** `connect()` has a default 10-second timeout from `new WebSocket(...)` to `auth.accepted`. If the timeout fires: close the socket with code 1000 (normal), reject the `connect()` promise with `ConnectTimeoutError`, transition status to `disconnected` (not `reconnecting` — timeouts during initial connect don't auto-retry; reconnect applies only to established sessions that drop). If the server closes during the handshake (before `auth.accepted`) with any code, reject `connect()` with `AuthRejectedError` and transition to `disconnected`. Both paths clear any pending timers to avoid leaks. + +## Public API + +### Types (from `client-runtime/types.ts`) + +```ts +export type ConnectionStatus = + | 'disconnected' | 'connecting' | 'authenticating' + | 'authenticated' | 'reconnecting' | 'closed'; + +export interface CollabRoomUser { id: string; name: string; color: string; } + +export interface CollabRoomState { + connectionStatus: ConnectionStatus; + roomStatus: RoomStatus | null; + roomId: string; + clientId: string; // random per connection + seq: number; // last server seq consumed by this client (used as reconnect lastSeq) + planMarkdown: string; + annotations: RoomAnnotation[]; // ordered view of internal Map + remotePresence: Record; // keyed by clientId + hasAdminCapability: boolean; + lastError: { code: string; message: string } | null; +} + +export interface CollabRoomEvents { + status: ConnectionStatus; + 'room-status': RoomStatus; + snapshot: RoomSnapshot; + event: RoomServerEvent; + presence: { clientId: string; presence: PresenceState }; + error: { code: string; message: string }; + state: CollabRoomState; // fires on any state mutation; React hook subscribes here +} +``` + +### `createRoom()` + +```ts +export interface CreateRoomOptions { + baseUrl: string; // e.g. https://room.plannotator.ai or http://localhost:8787 + initialSnapshot: RoomSnapshot; + expiresInDays?: number; + user: CollabRoomUser; + webSocketImpl?: typeof WebSocket; // test injection + fetchImpl?: typeof fetch; // test injection +} + +export interface CreateRoomResult { + roomId: string; + roomSecret: Uint8Array; + adminSecret: Uint8Array; + joinUrl: string; // with #key= + adminUrl: string; // with #key=&admin= + client: CollabRoomClient; // constructed but NOT connected; caller calls client.connect() +} + +export async function createRoom(options: CreateRoomOptions): Promise; +``` + +Flow: generate roomId + secrets → derive keys → compute verifiers → encrypt initial snapshot → POST `/api/rooms` → build URLs → construct client with pre-seeded snapshot state. Does NOT call `connect()` — caller controls connection timing. + +### `joinRoom()` + +```ts +export interface JoinRoomOptions { + url: string; // full room URL including fragment + adminSecret?: Uint8Array | string; // override if not in URL fragment + user: CollabRoomUser; + webSocketImpl?: typeof WebSocket; + reconnect?: { initialDelayMs?: number; maxDelayMs?: number; factor?: number; maxAttempts?: number }; + autoConnect?: boolean; // default false +} + +export async function joinRoom(options: JoinRoomOptions): Promise; +``` + +Flow: parse URL via `parseRoomUrl()` → derive keys → construct client. If `autoConnect: true`, awaits `connect()` before returning. + +### `CollabRoomClient` + +```ts +export class CollabRoomClient { + // Lifecycle + // connect(): resolves on auth.accepted; rejects on timeout (10s default) or server close. + // If called after disconnect() or after reaching a terminal state, connect() first + // clears userDisconnected and lastError and resets reconnectAttempt, then opens a new socket. + connect(): Promise; + disconnect(reason?: string): void; // user-initiated; disables reconnect until next connect() + + // Subscription + on( + name: K, + fn: (p: CollabRoomEvents[K]) => void, + ): () => void; // returns unsubscribe + + // State read + getState(): CollabRoomState; // immutable snapshot + + // Mutations (send-ack only; local state updates after server echo via the `state` event) + sendAnnotationAdd(annotations: RoomAnnotation[]): Promise; + sendAnnotationUpdate(id: string, patch: Partial): Promise; + sendAnnotationRemove(ids: string[]): Promise; + sendAnnotationClear(source?: string): Promise; + sendPresence(presence: PresenceState): Promise; + + // Admin (reject if no admin capability) + // lockRoom: if finalSnapshot provided, client encrypts with eventKey and + // sets finalSnapshotAtSeq to this.seq at command time. Admin proof binds to + // both the ciphertext and atSeq via canonicalJson(command). + lockRoom(options?: { finalSnapshot?: RoomSnapshot }): Promise; + unlockRoom(): Promise; + deleteRoom(): Promise; +} +``` + +### `useCollabRoom` React hook + +```ts +export interface UseCollabRoomOptions { + url: string; + adminSecret?: string; // base64url; hook does NOT persist it + user: CollabRoomUser; + enabled?: boolean; // default true +} + +export interface UseCollabRoomReturn { + connectionStatus: ConnectionStatus; + roomStatus: RoomStatus | null; + planMarkdown: string; + annotations: RoomAnnotation[]; + remotePresence: Record; + hasAdminCapability: boolean; + lastError: { code: string; message: string } | null; + + addAnnotations: (a: RoomAnnotation[]) => Promise; + updateAnnotation: (id: string, patch: Partial) => Promise; + removeAnnotations: (ids: string[]) => Promise; + clearAnnotations: (source?: string) => Promise; + updatePresence: (p: PresenceState) => Promise; + + lock: (opts?: { finalSnapshot?: RoomSnapshot }) => Promise; + unlock: () => Promise; + deleteRoom: () => Promise; + + client: CollabRoomClient | null; // escape hatch +} +``` + +Implementation pattern (matches `useExternalAnnotations`): +- `useEffect(() => { ... }, [url, adminSecret, user.id, enabled])`: parse URL, call `joinRoom()`, ref the client, call `connect()`. On dep change, the effect tears down and re-creates the client. Full dep list ensures stale clients don't linger when admin capability is added/removed or user identity changes. +- Document the stability contract: consumers should memoize `user` (it's used by value; unstable `user` props will thrash reconnects). The hook uses `user.id` as the primary dep key; changes to `user.name`/`user.color` propagate to the next `sendPresence()` call without reconnecting. +- Subscribe to `state` event → update a `useState`. +- Mutation methods are `useCallback`-memoized and delegate to the client. +- On unmount: unsubscribe, `client.disconnect()`. +- If `enabled === false` or the client has not finished setup: skip the connect effect and return the `DISCONNECTED_STATE` snapshot. Mutation and admin methods **throw/reject with a clear unavailable-client error**, they are **not** silent no-ops. Silent no-ops would let a user click "Add annotation" or "Lock room" and see nothing happen, with no indication that the action was lost. Throwing forces the UI (in Slice 5) to either disable the button while the room isn't ready, or surface a user-visible error. The current implementation uses a `requireClient()` helper that throws `"Collab room client is not available (disabled or not yet connected)"` — consumers should rely on this contract. + +## URL Extensions (`packages/shared/collab/url.ts`) + +Extend `ParsedRoomUrl`: +```ts +export interface ParsedRoomUrl { + roomId: string; + roomSecret: Uint8Array; + adminSecret?: Uint8Array; // NEW — from &admin=... in fragment +} +``` + +`parseRoomUrl()` also reads `admin=` param. Validates `adminSecret` decodes to exactly 32 bytes (matches `generateAdminSecret()` output); rejects the whole URL if `admin=` is present but malformed. + +Add `buildAdminRoomUrl(roomId, roomSecret, adminSecret, baseUrl?)` that produces `.../c/#key=&admin=`. Validates `adminSecret.length === 32`. + +Matches spec v1.md:72 (admin recovery URL format). + +**Default sharing stays admin-free.** The copied join URL produced by `createRoom().joinUrl` and `buildRoomJoinUrl()` contains ONLY `#key=`. `buildAdminRoomUrl()` / `adminUrl` are creator-only recovery outputs that must not be the default copy target. The hook and editor must never surface `adminUrl` as the share button's text — admin capability is sensitive and stays with the creator. + +## Files to Modify + +| File | Change | +|------|--------| +| `packages/shared/collab/url.ts` | Extend ParsedRoomUrl with `adminSecret?`; add `buildAdminRoomUrl` | +| `packages/shared/collab/client.ts` | Add client-runtime exports | +| `packages/shared/collab/url.test.ts` | Add tests for admin fragment parse + build | + +## Files to Create + +All in `packages/shared/collab/client-runtime/`: +- `types.ts`, `emitter.ts`, `backoff.ts`, `apply-event.ts` +- `client.ts`, `create-room.ts`, `join-room.ts`, `mock-websocket.ts` +- `emitter.test.ts`, `backoff.test.ts`, `apply-event.test.ts`, `client.test.ts`, `integration.test.ts` + +And: +- `packages/ui/hooks/useCollabRoom.ts` + +## Implementation Order + +1. **Primitives with tests:** `emitter.ts`, `backoff.ts`, `apply-event.ts` (pure, zero internal deps) +2. **`types.ts`** (pure types, no runtime) +3. **`url.ts` extension** + tests (admin fragment parsing, `buildAdminRoomUrl`) +4. **`mock-websocket.ts`** (test harness) +5. **`client.ts`** (the class) + `client.test.ts` (written together, test-driven) +6. **`create-room.ts`** and **`join-room.ts`** (thin wrappers) +7. **`client.ts` barrel re-exports** (client barrel only) +8. **`useCollabRoom.ts`** in `packages/ui/hooks/` +9. **`integration.test.ts`** — gated by `SMOKE_BASE_URL` env var + +## Reconnect Semantics + +- `onclose` → if `userDisconnected`: status `closed`, stop. +- If terminal (room deleted/expired detected via room.status before close, OR close code 4006 with recognizable reason): status `closed`, emit `error`, stop. +- Otherwise: status `reconnecting`, compute backoff delay, setTimeout → internal reconnect attempt (new WebSocket with existing keys). Send current `this.seq` as `lastSeq`. +- On reconnect success: reset `reconnectAttempt = 0`. +- Local `annotations` map preserved across reconnects; server replay reconciles. +- `remotePresence` cleared on close (others will re-broadcast presence post-reconnect). +- **Explicit `connect()` resets lifecycle flags.** A manual `disconnect()` sets `userDisconnected = true`, which prevents auto-reconnect on socket close. A subsequent explicit `connect()` call must clear `userDisconnected`, clear any terminal `lastError`, and reset `reconnectAttempt` to 0 before opening a new socket. Without this, manual disconnect would poison later manual reconnects and silently suppress auto-reconnect behavior. + +## Send and Apply (V1) + +When sending an op: +1. Generate `opId = generateOpId()`. +2. Encrypt the op with `eventKey`. +3. Send the envelope over the WebSocket. +4. Return. No local state mutation — the server echo is authoritative. + +When receiving a `room.event`: +1. Decrypt and shape-validate the op (`isRoomEventClientOp` — event-channel validator; rejects `presence.update`, which must not land in the durable log). +2. Apply via `apply-event.ts` when valid. +3. Advance `this.seq = event.seq`. This happens on the valid path AND the malformed / undecryptable paths — `this.seq` means "last server seq consumed by this client," not "last applied." Forward-progress is required so reconnect does not replay the same bad event forever and block every event behind it. +4. Emit `event` and `state` on the valid path; emit `event_malformed` / `event_decrypt_failed` on the rejection paths (state unchanged). + +`opId` is still generated and sent over the wire for protocol/logging symmetry and to enable future opId-correlated ack/reject, but V1 does not maintain a client-side cache of sent opIds. There is no echo dedup — every valid event (including our own echoes) is processed exactly once through the single server-authoritative path. + +## Admin Flow + +**V1 assumption: single creator-held admin capability.** The normal participant share URL is `#key=...` only. The `#key=...&admin=...` URL is a sensitive creator/recovery URL and is not intentionally shared with participants. Because admin capability is effectively single-holder in V1, it's acceptable for the client to resolve admin commands by observing `room.status` transitions (`locked`, `active`, `deleted`) rather than command-specific acks. This is NOT multi-admin-safe — if two admin-capable clients are connected simultaneously, a `room.status: locked` broadcast would resolve both their pending lock promises even though only one command was actually executed. Multi-admin support would require a protocol change: add `commandId` to `AdminCommandEnvelope` and an `admin.result { commandId, ok, error? }` ack from the room service. Deferred post-V1. + +Per-command, since admin challenges are single-use and don't persist: + +1. Assert `adminKey !== null`. Otherwise reject with `AdminNotAuthorizedError`. +2. Assert `pendingAdmin === null` (one in-flight per client). +3. Send `{ type: 'admin.challenge.request' }`. +4. Return a Promise; store `{ resolve, reject, command, sentAt, timeoutHandle }` in `pendingAdmin`. **Keep the promise open — do NOT resolve on send.** +5. On receiving `admin.challenge`: compute `computeAdminProof()`, send `admin.command` envelope with proof. Promise stays pending. +6. On receiving `room.status: locked` (for lock command) or `room.status: active` (for unlock from locked) → **resolve** the pending promise, clear `pendingAdmin`. +7. On receiving `room.status: deleted` OR the successful-delete socket close (`WS_CLOSE_ROOM_UNAVAILABLE` + reason `"Room deleted"`) for delete command → **resolve** the pending promise. +8. On receiving `room.error` while pending → **reject** with `AdminRejectedError(error.code, error.message)`, clear `pendingAdmin`. +9. On socket close while pending for non-delete commands, failed delete closes, or network drops → reject with `AdminInterruptedError`, clear `pendingAdmin`. +10. On 5s timeout with no observable effect → reject with `AdminTimeoutError`, clear `pendingAdmin`. +11. Do NOT auto-retry admin commands after reconnect. They're user-initiated. + +## Error Types + +```ts +export class ConnectTimeoutError extends Error {} +export class AuthRejectedError extends Error {} +export class RoomUnavailableError extends Error {} // close 4006 +export class NotConnectedError extends Error {} +export class AdminNotAuthorizedError extends Error {} +export class AdminTimeoutError extends Error {} +export class AdminInterruptedError extends Error {} +export class AdminRejectedError extends Error { constructor(public code: string, message: string) { super(message); } } +export class InvalidRoomUrlError extends Error {} +export class CreateRoomError extends Error { status: number; } +``` + +## Testing Strategy + +### Unit tests (mock WebSocket) +- Connect → auth.challenge → auth.response → auth.accepted transitions +- Snapshot decrypt + annotation Map population +- `sendAnnotationAdd` produces correctly-encrypted envelope; local state does not change until the server echo; own echoes and peer events apply through the same server-authoritative path +- Reconnect: simulate close, assert backoff timing with stubbed Math.random and fake timers; assert `lastSeq` in new auth.response +- Admin lock: triggers challenge.request → responds with correctly-bound proof +- Admin lock with `finalSnapshot`: client encrypts with eventKey, sets `finalSnapshotAtSeq = this.seq`, proof binds to both ciphertext and atSeq via canonicalJson(command) +- Admin promise resolution via observed effects: `lockRoom()` pending → server sends `room.status: locked` → promise resolves; server sends `room.error` instead → promise rejects with `AdminRejectedError` +- No admin capability: `lockRoom()` rejects synchronously +- `disconnect()` sets userDisconnected; subsequent close does not reconnect +- Terminal close (4006) → `closed` without reconnect +- `presenceKey` vs `eventKey`: presence ciphertext MUST NOT decrypt with eventKey + +### Pure reducer tests (`apply-event.test.ts`) +- Snapshot replaces annotations Map +- Add/update/remove semantics +- Clear with and without source filter +- Missing-id update is no-op (with log) + +### Integration test (`integration.test.ts`, gated) +- Skipped unless `SMOKE_BASE_URL` is set +- Full round-trip against `wrangler dev`: createRoom → two clients join → event exchange → presence relay → reconnect replay → admin lock/unlock/delete +- Uses `CollabRoomClient` (no inline WebSocket) + +### React hook test — deferred to Slice 5 + +The workspace does not currently depend on `@testing-library/react`, `happy-dom`, or `jsdom`. Running React hook tests requires a DOM test environment (to mount a component that calls the hook) plus a render helper. Adding one of these as a dev dependency is a non-trivial workspace change that belongs with the Slice 5 editor integration work, where hook behavior is exercised live against the real editor UI. + +For Slice 4: +- **Client runtime tests are the primary safety net.** The hook is a thin wrapper that subscribes to the client's `state` event and delegates mutations. All protocol-level correctness lives in the client and is covered by `client.test.ts`. +- **The hook will be code-reviewed against the client API** during Slice 4 implementation to ensure mutations delegate correctly and effect cleanup matches the reconnect/teardown contract. +- **Slice 5 will add a DOM test environment** (likely `happy-dom` since it's lighter than `jsdom` and works with `bun:test`) and full `useCollabRoom.test.ts` coverage as part of editor integration. + +This is explicit scope reduction with a named follow-up, not an implementation "if fiddly" fallback. + +## Verification + +```bash +# Tests +bun test packages/shared/collab/client-runtime/ +bun test packages/shared/collab/ # ensure existing tests still pass +bun test apps/room-service/ # ensure server tests still pass + +# Typechecks +bunx tsc --noEmit -p packages/shared/tsconfig.json +bunx tsc --noEmit -p apps/room-service/tsconfig.json +bunx tsc --noEmit -p packages/ui/tsconfig.collab.json # scoped Slice 4 hook typecheck (useCollabRoom.ts only) +# Or: bun run typecheck (runs all four) +``` + +Slice 4 adds `packages/ui/tsconfig.collab.json` — a **scoped** verification config whose `include` list contains only `hooks/useCollabRoom.ts`. React/DOM libs plus `@types/react` / `@types/react-dom` devDependencies on `packages/ui` make it compile standalone. This is deliberately **not** a full `packages/ui` typecheck: the UI package carries pre-existing type debt (strict-mode violations, missing CSS side-effect decls, `bun:test` lib gaps) unrelated to Slice 4, and pulling all of that into a feature PR would blur the review. The scoped config verifies the new hook and is honest about its scope. + +**Follow-up:** Full `packages/ui` typecheck is intentionally deferred because it surfaces pre-existing UI type debt unrelated to Slice 4. A dedicated cleanup PR should introduce a full `packages/ui/tsconfig.json` once that debt is addressed. + +Integration test (requires `wrangler dev` running): +```bash +cd apps/room-service && bunx wrangler dev +# In another terminal: +SMOKE_BASE_URL=http://localhost:8787 bun test packages/shared/collab/client-runtime/integration.test.ts +``` + +## What This Slice Does NOT Do + +- Wire `useCollabRoom` into `packages/editor/App.tsx` — Slice 5 +- Share UI, cursor overlay, approve/deny integration — Slice 5 +- Image attachment support — `RoomAnnotation.images` stays `never` +- Local `/api/external-annotations` SSE bridge — Slice 6 +- Direct-agent usage guide or SDK docs — Slice 6 +- Replace `apps/room-service/scripts/smoke.ts` — keep as independent reference diff --git a/specs/v1.md b/specs/v1.md new file mode 100644 index 00000000..907ec498 --- /dev/null +++ b/specs/v1.md @@ -0,0 +1,826 @@ +**Plannotator Live Rooms Spec** + +**Goal** +Add live, multi-party annotation rooms for Plannotator using a new `room.plannotator.ai` service. The room service coordinates encrypted collaboration but cannot read plans, annotations, comment bodies, cursor positions, display names, user colors, selected annotation IDs, or presence state. + +Keep existing static sharing unchanged: + +```text +https://share.plannotator.ai/#... +https://share.plannotator.ai/p/#key=... +``` + +Add live rooms separately: + +```text +https://room.plannotator.ai/c/#key= +wss://room.plannotator.ai/ws/ +``` + +`share.plannotator.ai` remains the static async sharing surface. `room.plannotator.ai` becomes the live collaboration surface. + +**Architecture** +Add a new `apps/room-service` Cloudflare Worker with one Durable Object per room. + +The existing CloudFront-hosted static site can stay where it is. `room.plannotator.ai` should be Cloudflare-native for the live collaboration surface: + +```text +GET /c/ -> serve the room SPA shell +GET /assets/* -> serve room bundle/assets +POST /api/rooms -> create room +GET /ws/ -> WebSocket upgrade into the room Durable Object +GET /health -> health check +``` + +The Worker owns `/c/*`, `/api/*`, and `/ws/*` on `room.plannotator.ai`. The room SPA can reuse the existing editor bundle where practical, but the deployment boundary should remain separate from `share.plannotator.ai`. + +Use raw Durable Objects rather than PartyKit for the first implementation. PartyKit is useful for prototyping, but it does not solve the zero-knowledge protocol, key derivation, auth, sequencing, event replay, snapshots, lock/delete semantics, or privacy guarantees. A raw Durable Object keeps the security boundary easier to audit. + +The existing paste service stays as-is. It remains the async snapshot sharing backend. KV remains appropriate for paste links but should not be used for live coordination because it is eventually consistent and has no room coordinator. + +**CORS Policy** +The room service uses explicit CORS configuration, not implicit bypasses. + +`ALLOWED_ORIGINS` lists fixed public origins (e.g., `https://room.plannotator.ai`). `ALLOW_LOCALHOST_ORIGINS` is a separate boolean flag that, when `"true"`, allows any `http(s)://localhost:*` origin. Both are set in `wrangler.toml` and visible in deployment config. + +This is intentional product behavior: the local Plannotator review UI runs on random localhost ports and needs to call `room.plannotator.ai/api/rooms` when the creator starts a live room. The room service still stores only ciphertext and verifiers — room content access depends on the URL fragment secret, not CORS. But the policy should be explicit and auditable, not hidden as an unconditional allowlist bypass in code. + +All reflected `Access-Control-Allow-Origin` responses include `Vary: Origin` for cache correctness. + +For production deployment without local dev access, set `ALLOW_LOCALHOST_ORIGINS` to `"false"` or omit it. + +**Room URLs** +Join URL: + +```text +https://room.plannotator.ai/c/#key= +``` + +WebSocket URL: + +```text +wss://room.plannotator.ai/ws/ +``` + +Room URL parsing is client-side only. `parseRoomUrl()` is for browser and direct-agent clients that receive `https://room.plannotator.ai/c/#key=...`. The Worker and Durable Object never receive the URL fragment and must not parse full room URLs; they receive only `roomId` through `/api/rooms` request bodies or `/ws/` routes, plus verifiers, proofs, ciphertext, and non-secret metadata in request or WebSocket message bodies. + +Admin capability is creator-only. Store it locally by default; do not include it in the normal share URL. + +A future admin recovery link could look like this, but should not be copied by default: + +```text +https://room.plannotator.ai/c/#key=&admin= +``` + +**Security Model** +The room secret lives only in the browser URL fragment: + +```text +#key= +``` + +It is never sent in HTTP requests, WebSocket URLs, logs, or server payloads. + +Generate a separate creator-only admin secret at room creation: + +```text +adminSecret = base64url-256-bit-secret +``` + +Derive separate subkeys with HKDF: + +```text +authKey = HKDF(roomSecret, "plannotator:v1:room-auth") +eventKey = HKDF(roomSecret, "plannotator:v1:event") +presenceKey = HKDF(roomSecret, "plannotator:v1:presence") +adminKey = HKDF(adminSecret, "plannotator:v1:room-admin") +``` + +All application payloads are encrypted, including presence. Presence can leak document focus and behavior, so cursor coordinates, display names, user colors, selected annotation IDs, and active state must not be plaintext. + +Server-visible payloads should look like: + +```ts +type ServerEnvelope = { + clientId: string; + opId: string; + channel: "event" | "presence"; + ciphertext: string; +}; +``` + +`clientId` is an opaque random identifier generated per WebSocket connection, not a name, email, stable account ID, or stable room participant ID. Stable user identity across reconnects belongs inside encrypted `PresenceState.user.id`. `channel` is intentional server-visible metadata so the Durable Object can treat durable events and volatile presence differently. The Durable Object may also see room metadata, timing, connection counts, and ciphertext sizes. It must not see plaintext plans, annotations, comments, display names, cursor positions, or room encryption keys. + +**Room Creation** +Use an HTTP create endpoint. The browser generates the high-entropy `roomId`, the room secrets, and the encrypted initial snapshot. `roomId` should contain at least 128 bits of randomness and should not encode user or document metadata. + +```text +POST https://room.plannotator.ai/api/rooms +``` + +Request: + +```ts +type CreateRoomRequest = { + roomId: string; + roomVerifier: string; + adminVerifier: string; + initialSnapshotCiphertext: string; + expiresInDays?: number; // optional, clamped to the V1 retention policy +}; +``` + +Response: + +```ts +type CreateRoomResponse = { + roomId: string; + status: "active"; + seq: 0; + snapshotSeq: 0; + joinUrl: string; // without fragment + websocketUrl: string; +}; +``` + +The server must reject duplicate `roomId`s with `409 Conflict`. The response must not include `roomSecret` or `adminSecret`. The browser constructs the copied join URL by adding `#key=` locally. + +Creation flow: + +1. Browser creates `roomId`, `roomSecret`, and `adminSecret`. +2. Browser derives `authKey`, `eventKey`, `presenceKey`, and `adminKey`. +3. Browser encrypts the initial room snapshot with `eventKey`. +4. Browser computes `roomVerifier` and `adminVerifier`. +5. Browser sends only `roomId`, verifiers, and `initialSnapshotCiphertext` to `POST /api/rooms`. +6. Durable Object stores the verifiers and encrypted snapshot, initializes `seq = 0`, `snapshotSeq = 0`, and sets status to `active`. +7. Browser stores `adminSecret` locally for creator-only controls and copies only the normal join URL by default. + +`created` is an internal transient state for partially initialized rooms. A normal room should become `active` during `POST /api/rooms` after the encrypted initial snapshot is stored. If creation fails before that point, the room should be discarded or left inaccessible until cleanup. + +**Room Auth** +Use challenge-response, not a stable token in the WebSocket URL. + +Room verifier derivation: + +```text +roomVerifier = HMAC(authKey, "plannotator:v1:room-verifier:" || roomId) +adminVerifier = HMAC(adminKey, "plannotator:v1:admin-verifier:" || roomId) +``` + +All `||` concatenation in HMAC inputs uses null byte (`\0`) separators between components to prevent ambiguity (e.g., so `roomId="ab" + clientId="cd"` is distinct from `roomId="a" + clientId="bcd"`). The HMAC input is the UTF-8 encoding of the components joined by `\0`. + +The client sends `roomVerifier` and `adminVerifier` to the Durable Object when creating the room. The DO stores them as sensitive auth material. They are not decryption keys. + +Connection flow: + +1. Client connects to: + +```text +wss://room.plannotator.ai/ws/ +``` + +2. Server sends: + +```json +{ + "type": "auth.challenge", + "challengeId": "ch_...", + "nonce": "base64url-random-32-bytes", + "expiresAt": 1760000000000 +} +``` + +Auth challenges are single-use and should expire after 30 seconds. + +3. Client replies: + +```ts +type AuthResponse = { + type: "auth.response"; + challengeId: string; + clientId: string; + proof: string; // HMAC(roomVerifier, "plannotator:v1:auth-proof" || roomId || clientId || challengeId || nonce) + lastSeq?: number; +}; +``` + +4. Server verifies the proof, marks the challenge used, then accepts the socket. + +Never put `roomSecret`, `adminSecret`, `authKey`, `adminKey`, `roomVerifier`, `adminVerifier`, or proof values in URL query params. Redact proofs, verifiers, ciphertext, and message bodies from logs. + +A leaked one-time proof should be useless after the challenge expires. A leaked verifier may allow room relay access but still cannot decrypt content because event and presence encryption use separate keys. Treat verifiers as sensitive. + +After authentication, the server sends a welcome message: + +```ts +type AuthAccepted = { + type: "auth.accepted"; + roomStatus: RoomStatus; + seq: number; + snapshotSeq?: number; + snapshotAvailable: boolean; +}; +``` + +Then the server either replays encrypted events after `lastSeq` or sends the latest encrypted snapshot followed by events after `snapshotSeq`. + +**Room Lifecycle** +Use: + +```text +active <-> locked + | | + +----+----+ + | + deleted or expired +``` + +Rooms are created directly in the `active` state. There is no separate `created` lifecycle step. + +Room status: + +```ts +type RoomStatus = "active" | "locked" | "deleted" | "expired"; +``` + +Semantics: + +```text +created: + room exists, verifier/adminVerifier initialized, initial encrypted snapshot may not exist yet + +active: + accept annotation add/update/remove/clear + accept presence + accept snapshot read/write according to protocol + accept admin lock/delete + +locked: + reject annotation add/update/remove/clear + accept snapshot read + accept presence unless disabled by policy + accept admin unlock/delete + preserve a frozen encrypted snapshot for review/export + +deleted: + reject all + close sockets + future joins return 410 Gone + purge stored ciphertext and verifiers, or retain only a short tombstone + +expired: + reject all + close sockets + future joins return 410 Gone + purge stored ciphertext and verifiers, or retain only a short tombstone + distinct from deleted because expiry is automatic retention, not creator action +``` + +`locked` means “review is done.” Existing participants stay connected read-only. New participants may join read-only if they have the room key. The creator can unlock if they locked too early. + +Presence remains enabled in locked rooms by default because it is useful in review discussions. This can later become a room policy: + +```ts +type LockedPresencePolicy = "enabled" | "disabledWhenLocked"; +``` + +Default: + +```text +enabled +``` + +**Admin Commands** +Admin commands require a fresh admin challenge-response per command. Do not reuse the connection auth challenge for later admin actions. + +```ts +type AdminCommand = + | { type: "room.lock"; finalSnapshotCiphertext?: string } + | { type: "room.unlock" } + | { type: "room.delete" }; +``` + +Admin command flow: + +1. Client sends: + +```ts +type AdminChallengeRequest = { + type: "admin.challenge.request"; +}; +``` + +2. Server replies with a fresh single-use challenge: + +```ts +type AdminChallenge = { + type: "admin.challenge"; + challengeId: string; + nonce: string; + expiresAt: number; +}; +``` + +Admin challenges should expire after 30 seconds. + +3. Client sends the command with proof: + +```ts +type AdminCommandEnvelope = { + type: "admin.command"; + challengeId: string; + clientId: string; + command: AdminCommand; + adminProof: string; +}; +``` + +Proof: + +```text +adminProof = HMAC(adminVerifier, "plannotator:v1:admin-proof" || roomId || clientId || challengeId || nonce || canonicalJson(command)) +``` + +`canonicalJson(value)` means deterministic JSON with lexicographically sorted object keys at every nesting level, no whitespace, and UTF-8 bytes as the HMAC input. Arrays preserve order, `undefined` fields are omitted, and values must not contain functions, symbols, `NaN`, or `Infinity`. + +Including `canonicalJson(command)` binds the proof to the exact admin action so a proof for `room.lock` cannot be reused as `room.delete`. The DO verifies the proof, marks the admin challenge used, checks current room status, and then applies or rejects the command. + +`room.lock` freezes durable annotation mutations. The client should upload or confirm the latest encrypted snapshot before lock completes. + +`room.unlock` returns the room to active state. + +`room.delete` deletes server-side room material. It does not revoke data already received by participants. + +Use clear product copy: + +```text +Delete room from Plannotator servers +``` + +Do not imply deletion removes screenshots, copied text, local browser memory, or data already decrypted by participants. + +**Durable State** +The DO owns server-authoritative sequencing for durable room events: + +```ts +type SequencedEnvelope = { + seq: number; + receivedAt: number; + envelope: ServerEnvelope; +}; +``` + +Stored room state: + +```ts +type RoomState = { + roomId: string; + status: RoomStatus; + roomVerifier: string; + adminVerifier: string; + seq: number; + earliestRetainedSeq: number; // oldest event seq still in storage + snapshotCiphertext?: string; + snapshotSeq?: number; + lockedAt?: number; + deletedAt?: number; + expiredAt?: number; + expiresAt: number; +}; +``` + +Store: + +- `status` +- `roomVerifier` +- `adminVerifier` +- current `seq` +- `earliestRetainedSeq` +- encrypted room snapshot +- snapshot sequence +- creation/expiry metadata +- lock/delete/expiry timestamps if applicable + +Events are persisted as individual Durable Object storage rows keyed by `event:` (not a single `eventLog` array). That layout lets `ctx.storage.list({ prefix: 'event:', start: ... })` range-scan efficiently on reconnect replay and stays within per-row size limits. + +Do not persist presence. + +Only `channel: "event"` envelopes consume durable `seq` values and are written to the event log. `channel: "presence"` envelopes are validated, decrypted by peers only, broadcast to connected clients, and then discarded. + +V1 does NOT implement active compaction. The room service retains all durable events for the room's lifetime and `earliestRetainedSeq` stays at 1 throughout V1. A creator-initiated lock with `finalSnapshot` writes a final snapshot; participants can join/reconnect and receive that snapshot plus any replayed events. Compaction (snapshot-after-N-ops or snapshot-after-N-KiB) is deferred; the replay code paths already check `lastSeq >= earliestRetainedSeq` and will fall back to the snapshot path when compaction later advances `earliestRetainedSeq`. + +Reconnect rule: + +```text +if client lastSeq is still in the retained log: + replay events from lastSeq + 1 through current seq +else: + send latest encrypted snapshot + replay retained events after snapshotSeq +``` + +The service should not compact away events newer than `snapshotSeq` until the replacement encrypted snapshot is stored successfully. + +Default retention: + +```text +30 days max retention for live room ciphertext +``` + +V1 should use fixed expiry from room creation. Activity-based TTL extension is a post-V1 policy decision. Explicit creator delete should purge the room earlier. + +**Room Ops and Events** +Separate transport messages from decrypted room payloads. The server sees transport messages and encrypted envelopes. Clients decrypt snapshots and envelopes into the room operation/event model. + +Server-to-client transport: + +```ts +type RoomTransportMessage = + | { type: "room.snapshot"; snapshotSeq: number; snapshotCiphertext: string } + | { type: "room.event"; seq: number; receivedAt: number; envelope: ServerEnvelope } + | { type: "room.presence"; envelope: ServerEnvelope } + | { type: "room.status"; status: RoomStatus }; +``` + +Use one canonical decrypted operation/event model. `opId` lives on `ServerEnvelope`, not inside the decrypted payload. + +Room snapshots must include the plan, not only annotations: + +```ts +type RoomAnnotation = Omit & { + images?: never; +}; + +type RoomSnapshot = { + versionId: "v1"; + planMarkdown: string; + annotations: RoomAnnotation[]; +}; +``` + +`Annotation` is the existing Plannotator UI type from `packages/ui/types.ts`. `RoomAnnotation` intentionally excludes `images` in V1 because the current `ImageAttachment` model stores local paths, not portable encrypted bytes. In V1, `versionId` is always `"v1"` because there is only one plan version per room; `snapshotSeq` is the snapshot checkpoint identity. + +Clients send encrypted operations. The wire envelope's `channel` field +(`"event"` or `"presence"`) identifies the payload kind to the server, so the +encrypted payloads are distinct per channel: + +```ts +// envelope.channel === "event" — encrypted payload is a RoomEventClientOp +type RoomEventClientOp = + | { type: "annotation.add"; annotations: RoomAnnotation[] } + | { type: "annotation.update"; id: string; patch: Partial } + | { type: "annotation.remove"; ids: string[] } + | { type: "annotation.clear"; source?: string }; + +// envelope.channel === "presence" — encrypted payload is a raw PresenceState, +// NOT wrapped in { type: "presence.update", presence: ... }. The envelope's +// channel already identifies the payload as presence; adding an extra +// discriminator would be redundant. +type PresencePayload = PresenceState; + +// Legacy union — kept for RoomServerEvent symmetry and for callers that want +// a single discriminated union. The runtime always treats presence as raw +// PresenceState on the wire; the presence.update variant here is only a +// client-side shape used by applyAnnotationEvent's exhaustiveness path. +type RoomClientOp = + | RoomEventClientOp + | { type: "presence.update"; presence: PresenceState }; +``` + +Clients receive decrypted server events: + +```ts +type RoomServerEvent = + | { type: "snapshot"; payload: RoomSnapshot; snapshotSeq: number } + | { type: "annotation.add"; annotations: RoomAnnotation[] } + | { type: "annotation.update"; id: string; patch: Partial } + | { type: "annotation.remove"; ids: string[] } + | { type: "annotation.clear"; source?: string } + | { type: "presence.update"; clientId: string; presence: PresenceState }; +``` + +Room lifecycle status is server-visible metadata and arrives as a `room.status` transport message, not as encrypted application payload. + +Presence should use document-relative coordinates where possible: + +```ts +type CursorState = { + blockId?: string; + x: number; + y: number; + coordinateSpace: "block" | "document" | "viewport"; +}; +``` + +Prefer `block` or `document`; use `viewport` only as fallback. + +Presence state is encrypted with `presenceKey`: + +```ts +type PresenceState = { + user: { + id: string; + name: string; + color: string; + }; + cursor: CursorState | null; + activeAnnotationId?: string | null; + idle?: boolean; +}; +``` + +`PresenceState.user.name` is populated from the existing Plannotator identity system: the configured display name, which may be set from the git username through the existing Settings shortcut. `PresenceState.user.id` should be a stable identifier derived from the same identity source so participants can be recognized across reconnects. Identity is self-declared and unverified in V1. Signed or verified identity is future work. + +**Client Integration** +Add `packages/shared/collab` for: + +- room message schemas +- key derivation helpers +- encryption/decryption helpers +- room URL helpers for browser and direct-agent clients +- operation ID helpers +- challenge-response helpers +- room client helpers for browser and agent runtimes + +Add `useCollabRoom` under `packages/ui/hooks`. + +Responsibilities: + +- parse `/c/#key=...` +- derive `authKey`, `eventKey`, `presenceKey` +- load local creator admin capability if present +- create/join the WebSocket room +- authenticate with challenge-response +- encrypt/decrypt room envelopes +- maintain remote presence state +- apply sequenced annotation events +- treat server echo as the authoritative annotation apply path +- request snapshot/replay on reconnect +- expose `sendAnnotationAdd` +- expose `sendAnnotationUpdate` +- expose `sendAnnotationRemove` +- expose `sendAnnotationClear` +- expose `sendPresence` +- expose admin `lockRoom`, `unlockRoom`, and `deleteRoom` helpers when admin capability is present + +Wire it into `packages/editor/App.tsx` where annotations currently live. Outside rooms, existing local annotation behavior remains unchanged. Inside a live room, annotation creation/edit/remove/clear should emit encrypted durable ops and update room-backed annotation state only after the room service echoes the accepted event. Remote annotation ops should update `annotations` without regenerating IDs. + +**Accepted V1 tradeoff: server echo over optimistic local apply** + +V1 intentionally does not apply room annotation mutations optimistically. When a user submits an annotation in a live room, the annotation becomes visible after the room service receives the encrypted op, assigns a durable `seq`, and echoes it back to all clients, including the sender. + +This may add a small visible delay on slow or unstable connections. The tradeoff is accepted for V1 because the protocol does not include op-specific `ack` / `reject` messages or rollback semantics. If the client showed an annotation immediately and the server later rejected the op, the sender could see local-only state that collaborators never receive. This is especially likely around room locking, where the room can transition to locked between a client's local status check and Durable Object processing. + +V1 therefore prefers accepted-state correctness over instant local feedback: the UI should render from echoed room state, show lightweight pending feedback while an annotation op is in flight, and surface room errors such as `room_locked` clearly. Future optimistic UX requires a protocol addition first, such as `opId`-correlated `ack` / `reject` or explicit pending annotations with rollback. + +When starting a live room, strip image attachments from annotations and global attachments before building `RoomAnnotation` payloads. Do not block room creation. Show a clear notice that image attachments are not supported in live rooms yet and that encrypted room assets are on the roadmap. + +Render remote cursors as a separate overlay in `Viewer`, not as annotations. + +Add share UI: + +- “Start live room” +- “Copy room link” +- “Lock review” +- “Unlock review” +- “Delete room from Plannotator servers” + +Keep paste links unchanged. + +**Agent Bridge and External Annotations** +The room service is not an agent server. It never talks to Claude Code, OpenCode, Codex, or any local agent loop directly. It coordinates encrypted room state only. + +Agents can participate through two paths. + +Local bridge mode preserves the current Plannotator flow: + +```text +agent/tool + -> localhost:/api/external-annotations + -> local Plannotator SSE store + -> creator/participant browser + -> encrypted room op + -> room.plannotator.ai Durable Object + -> other room clients +``` + +The existing local API remains valid: + +```text +GET /api/plan +GET /api/external-annotations +GET /api/external-annotations/stream +POST /api/external-annotations +PATCH /api/external-annotations?id= +DELETE /api/external-annotations?id= +DELETE /api/external-annotations?source= +``` + +The existing SSE store and `useExternalAnnotations()` hook remain valid for local agent injection. When a browser is joined to a room, locally received external annotations are converted into encrypted room `annotation.add`, `annotation.update`, `annotation.remove`, or `annotation.clear` ops. + +In local bridge mode, the browser performs plaintext-to-encrypted translation: it receives plaintext annotations from localhost SSE, encrypts them with `eventKey`, and sends encrypted room envelopes to `room.plannotator.ai`. This makes the browser the trust boundary, which is consistent with the rest of the zero-knowledge model: clients can read plaintext; the remote room server cannot. + +The browser must prevent duplicates when a local SSE annotation is forwarded into the room and later echoed back. V1 should do this with stable annotation IDs and the single server-authoritative apply path: local bridge input is sent as an encrypted room op, and room-backed annotation state is updated from the echoed event. Do not rely on content-based dedupe. + +If a local SSE annotation includes image attachments, the browser should strip the image fields before forwarding it as a `RoomAnnotation` op. V1 room annotations use `RoomAnnotation`, which excludes `images` because existing image attachments are local paths rather than portable encrypted assets. The annotation text content is still forwarded. + +Direct room client mode treats agents as first-class encrypted clients: + +```text +agent + -> wss://room.plannotator.ai/ws/ + -> challenge-response auth + -> decrypt latest snapshot + -> read plan + annotations + -> send encrypted annotation ops +``` + +A direct agent client receives the full room URL from the user: + +```text +https://room.plannotator.ai/c/#key= +``` + +It derives the same room keys and uses the shared collab client library to parse the room URL, authenticate the socket, decrypt snapshots, subscribe to events, and submit annotation ops. Giving an agent the room URL grants that agent the ability to read the plan and annotations and submit encrypted annotations. This is equivalent to inviting another participant. + +The agent-readable decrypted room state should be exposed by the shared client as: + +```ts +type AgentReadableRoomState = { + roomId: string; + status: RoomStatus; + versionId: "v1"; + planMarkdown: string; + annotations: RoomAnnotation[]; +}; +``` + +The encrypted room snapshot must contain the plan, not only annotations. It should use the `RoomSnapshot` shape above so stable annotation IDs survive reconnects, exports, and direct agent clients. The collab client should expose a clear room state object to browser and agent callers. + +**Creator Agent Decision Bridge** +The creator’s browser is usually the bridge back to the primary local agent because it holds both the encrypted room session and `localhost:` access to the running Plannotator server. + +Creator/admin-only controls: + +- Approve +- Deny / Send Feedback +- Lock review +- Unlock review +- Delete room from Plannotator servers + +Approve flow: + +1. Browser consolidates all room annotations into `annotationsOutput`. +2. Browser POSTs to `localhost:/api/approve`. +3. If approve succeeds, browser locks the room. +4. Room remains readable as a frozen review snapshot. + +Deny flow: + +1. Browser consolidates all room annotations into `annotationsOutput`. +2. Browser POSTs to `localhost:/api/deny`. +3. Room remains active by default for the next revision cycle. + +All participants, not just the creator, may export, copy, or download consolidated feedback from the encrypted room state. Only the creator/admin can submit approve/deny to their local agent bridge or lock/delete the room. + +**What Transfers From SSE** +Transfer from the current external-annotations SSE model: + +- snapshot/add/update/remove/clear event vocabulary +- source-based cleanup semantics +- batch annotation input shape +- `COMMENT` vs `GLOBAL_COMMENT` validation semantics +- local polling fallback for local API environments +- agent-facing instructions style + +Do not transfer: + +- plaintext server-side annotation storage to `room-service` +- SSE as the live room transport +- server-generated annotation IDs for room ops +- content-based dedupe as the primary dedupe strategy + +`room-service` uses WebSockets and encrypted envelopes. Local SSE remains a compatibility bridge. + +**Future Plan Versioning** +V1 should not attempt a full Google Docs-style version/branch model, but it should leave room for it. + +Future room document versions should look like: + +```ts +type RoomDocumentVersion = { + versionId: string; + parentVersionId?: string; + createdByClientId: string; + createdAt: number; + snapshotCiphertext: string; +}; +``` + +Annotations should reference a document version: + +```ts +type AnnotationVersionRef = { + versionId: string; + annotationId: string; +}; +``` + +Initial follow-up behavior: + +- Room starts with version `v1`. +- Annotations attach to `v1`. +- Creator deny sends feedback to the local agent and leaves the room active. +- When the creator imports the agent’s revised plan, the browser publishes `document.version.add` with an encrypted new plan snapshot. +- UI can show versions/tabs. +- Old annotations remain attached to old versions. +- Later workflows can mark annotations as resolved, carried forward, orphaned, or copied to a new version. + +Participant-submitted versions are out of V1. Start with creator/admin-published versions only. + +**Future Encrypted Room Assets** +V1 live rooms should not support image attachments. The current codebase represents image attachments as local paths and names, and renders those paths through the local `/api/image?path=...` endpoint. That model is not portable across room participants and should not be treated as zero-knowledge room data. + +A later room asset protocol should use encrypted blobs: + +```text +assetKey = HKDF(roomSecret, "plannotator:v1:asset") +``` + +Future asset references should look like: + +```ts +type RoomImageRef = { + assetId: string; + name: string; + mimeType: string; + size: number; +}; +``` + +The client encrypts image bytes with `assetKey`, uploads ciphertext blob storage, and sends only encrypted room events that reference `assetId`. On Cloudflare, R2 is the likely blob store; the Durable Object should coordinate room metadata and lifecycle, not store large image bytes directly. + +**Privacy Gaps To Document** +Even with zero-knowledge payloads, the server can still observe: + +- room ID +- IP address, user agent, rough geography +- connection count per room +- timing and approximate message sizes +- active/idle room patterns +- ciphertext size and event counts +- whether a room is active, locked, deleted, or expired + +Main user-facing caveats: + +- Room links are bearer capabilities. Anyone with the full URL fragment can join and decrypt. +- Giving an agent the full room URL gives that agent plaintext access as an end client. +- Live rooms do not support image attachments in V1. Do not transmit local image paths or third-party image URLs as room assets. +- Creator delete removes server-side ciphertext; it does not revoke content already decrypted by participants. +- Revocation requires a new room/key in v1. +- XSS on `room.plannotator.ai` is catastrophic. Use strict CSP, no third-party scripts, no analytics, sanitized markdown, and never log `location.href`. +- Participants can copy plaintext. Zero-knowledge protects against the server, not invited users. +- Server can deny, delay, drop, or replay ciphertext. Use `seq`, `opId`, server-authoritative echoes, reconnect snapshots, and optionally a client-side hash chain later. +- Room verifier leakage can allow relay access but not content decryption. Admin verifier leakage can allow lock/unlock/delete, but still not content decryption. Treat verifiers as sensitive and redact logs. +- Message timing remains a side channel. Throttle and batch presence, but do not claim timing privacy. + +**MVP Scope** + +1. Add `apps/room-service` Worker + Durable Object. +2. Add `packages/shared/collab` for schemas, key derivation, encryption helpers, challenge-response helpers, and room URL helpers for browser and direct-agent clients. +3. Add `/c/` room route on `room.plannotator.ai`. +4. Add `wss://room.plannotator.ai/ws/`. +5. Add WebSocket auth challenge-response. +6. Support creator admin capability. +7. Support encrypted presence. +8. Support encrypted annotation add/update/remove/clear with server-assigned `seq`. +9. Support encrypted snapshot on join/reconnect. +10. Support `active`, `locked`, `deleted`, and `expired` enforcement. +11. Support admin lock/unlock/delete. +12. Support local bridge mode from existing `/api/external-annotations` SSE into encrypted room ops. +13. Provide shared collab client helpers so direct agent clients can be built without duplicating crypto/protocol code. +14. Strip image attachments from annotations when entering a room and notify the user. +15. Add a “Start live room” share mode separate from existing paste links. +16. Leave paste-service and static sharing unchanged. + +**Non-Goals For V1** + +- CRDTs +- multi-author editing of the underlying document +- login-based access control +- room key rotation +- role-based moderation beyond creator/admin lock/delete +- long-term room archival +- image attachments or encrypted room assets +- plaintext server-side search, analytics, or indexing +- participant identity verification beyond possession of room/admin capabilities +- participant-submitted plan versions or branch workflows + +**Positioning** +This borrows the best Excalidraw lesson: link-based collaboration with URL-fragment secrets and encrypted room traffic. Plannotator can be stronger because the domain is simpler: annotations on a fixed document, not mutable whiteboard geometry. Durable Objects provide the missing single-writer room coordinator without adding Firebase, CRDT complexity, or a traditional multi-node WebSocket stack. + +Enterprise story: + +```text +Plannotator room servers coordinate encrypted sessions. They can authenticate room access, order ciphertext events, enforce room lifecycle state, and delete stored ciphertext, but they cannot read plans, annotations, comment bodies, cursor positions, participant names, colors, selected annotation IDs, or presence state. The room URL fragment is the decryption capability and never leaves the browser except when the user shares it. +``` From f68c8cd7c42e915b53cab11bd30e6f835934e514 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Sat, 18 Apr 2026 21:39:10 -0700 Subject: [PATCH 05/41] WIP: pre-consolidation snapshot (anchor for Live Rooms V1 cleanup) Anchor commit for the Live Rooms V1 consolidation pass. Captures the working tree as-of Slice 5 + presence fixes + fake-presence harness tuning. This commit is NOT intended to ship; it exists so downstream consolidation phases have a stable rollback point via `git reset --hard` or the `v0.17.10-preconsolidation` tag. Baseline at snapshot time: - typecheck: green (shared, ai, server, ui.slice5, editor all clean). - tests: 1028 pass, 3 skip, 0 fail (bun run test). - branch: feat/collab at b78019e. Scope by category: - collab hooks: useCollabRoom (modified), useCollabRoomSession, usePresenceThrottle, useRoomAdminActions, useRoomMode, useAnnotationController, useAnnotationHighlightReconciler (all new) - collab components: packages/ui/components/collab/* (RoomPanel replaced by RoomHeaderControls + RoomMenu; AdminControls, ParticipantAvatars, RemoteCursorLayer, StartRoomModal, JoinRoomGate, ImageStripNotice, RoomStatusBadge, RoomAdminErrorToast) - collab utils: adminSecretStorage, presenceColor, roomIdentityConfirmed - room-service shell: entry.tsx, index.html, static/favicon.svg, vite.config.ts, tsconfig.browser.json, plus room-do.ts / handler.ts presence + admin updates - editor shell: AppRoot.tsx, RoomApp.tsx, roomIdentityHandoff.ts, env.d.ts, tsconfig.json, plus App.tsx start-room / admin flow - shared collab: validation.ts, redact-url.ts, strip-images update, client-runtime/client.ts presence + admin updates, types.ts with room.participant.left broadcast - dev tooling: apps/room-service/scripts/fake-presence.ts (fake multi-participant harness with continuous-lerp motion model); scripts/dev-live-room-local.sh - CI: minor workflow tweaks (release.yml, test.yml) - specs: v1.md and slice plans updated; v1-cursor-presence.md added DO NOT squash-merge. The consolidation PR preserves this commit as an anchor; revert destination for all downstream phases. For provenance purposes, this commit was AI assisted. --- .github/workflows/release.yml | 4 +- .github/workflows/test.yml | 7 +- .gitignore | 4 + AGENTS.md | 37 +- apps/hook/public/favicon.svg | 5 + apps/hook/tsconfig.json | 3 +- apps/hook/vite.config.ts | 3 +- apps/pi-extension/server-plan.test.ts | 173 ++++ apps/pi-extension/server/serverAnnotate.ts | 3 +- apps/pi-extension/server/serverPlan.ts | 85 +- apps/pi-extension/server/serverReview.ts | 3 +- apps/pi-extension/vendor.sh | 7 + apps/portal/tsconfig.json | 3 +- apps/portal/vite.config.ts | 3 +- apps/room-service/core/csp.test.ts | 141 +++ apps/room-service/core/handler.ts | 250 +++-- apps/room-service/core/room-do.ts | 21 + apps/room-service/core/types.ts | 2 + apps/room-service/entry.tsx | 36 + apps/room-service/index.html | 24 + apps/room-service/package.json | 18 +- apps/room-service/scripts/fake-presence.ts | 551 +++++++++++ apps/room-service/static/favicon.svg | 5 + apps/room-service/tsconfig.browser.json | 23 + apps/room-service/tsconfig.json | 3 +- apps/room-service/vite.config.ts | 67 ++ apps/room-service/wrangler.toml | 10 + bun.lock | 86 +- package.json | 6 +- packages/editor/App.tsx | 906 +++++++++++++++++- packages/editor/AppRoot.tsx | 142 +++ packages/editor/RoomApp.tsx | 590 ++++++++++++ packages/editor/bunfig.toml | 6 + packages/editor/env.d.ts | 24 + .../hooks/useCheckboxOverrides.test.tsx | 194 ++++ packages/editor/hooks/useCheckboxOverrides.ts | 75 ++ packages/editor/package.json | 9 +- packages/editor/roomIdentityHandoff.test.ts | 124 +++ packages/editor/roomIdentityHandoff.ts | 75 ++ packages/editor/tsconfig.json | 65 ++ packages/server/annotate.ts | 3 +- packages/server/index.ts | 113 ++- packages/server/review.ts | 3 +- .../shared/collab/client-runtime/client.ts | 70 +- .../shared/collab/client-runtime/types.ts | 29 +- packages/shared/collab/index.ts | 2 + packages/shared/collab/redact-url.test.ts | 80 ++ packages/shared/collab/redact-url.ts | 81 ++ packages/shared/collab/strip-images.test.ts | 71 +- packages/shared/collab/strip-images.ts | 40 +- packages/shared/collab/types.ts | 14 +- packages/shared/collab/validation.test.ts | 57 ++ packages/shared/collab/validation.ts | 30 + packages/shared/config.test.ts | 94 ++ packages/shared/config.ts | 11 + packages/shared/package.json | 3 +- packages/ui/bunfig.toml | 2 + packages/ui/components/AnnotationPanel.tsx | 194 +++- packages/ui/components/CommentPopover.tsx | 37 +- packages/ui/components/ExportModal.tsx | 29 + packages/ui/components/PlanHeaderMenu.tsx | 76 ++ packages/ui/components/Settings.tsx | 31 +- packages/ui/components/Viewer.tsx | 267 ++++-- .../ui/components/collab/AdminControls.tsx | 79 ++ .../ui/components/collab/ImageStripNotice.tsx | 43 + .../ui/components/collab/JoinRoomGate.tsx | 122 +++ .../collab/ParticipantAvatars.test.tsx | 60 ++ .../components/collab/ParticipantAvatars.tsx | 80 ++ .../components/collab/RemoteCursorLayer.tsx | 449 +++++++++ .../components/collab/RoomAdminErrorToast.tsx | 65 ++ .../components/collab/RoomHeaderControls.tsx | 105 ++ packages/ui/components/collab/RoomMenu.tsx | 216 +++++ .../collab/RoomStatusBadge.test.tsx | 63 ++ .../ui/components/collab/RoomStatusBadge.tsx | 70 ++ .../ui/components/collab/StartRoomModal.tsx | 162 ++++ .../ui/components/plan-diff/VSCodeIcon.tsx | 5 +- packages/ui/components/types.d.ts | 9 +- packages/ui/config/settings.ts | 47 +- .../useAnnotationController.room.test.tsx | 476 +++++++++ .../ui/hooks/useAnnotationController.test.tsx | 107 +++ packages/ui/hooks/useAnnotationController.ts | 516 ++++++++++ .../useAnnotationHighlightReconciler.test.tsx | 155 +++ .../hooks/useAnnotationHighlightReconciler.ts | 150 +++ ...useAnnotationHighlighter.readOnly.test.tsx | 179 ++++ packages/ui/hooks/useAnnotationHighlighter.ts | 101 +- packages/ui/hooks/useCollabRoom.test.tsx | 56 ++ packages/ui/hooks/useCollabRoom.ts | 55 +- .../ui/hooks/useCollabRoomSession.test.tsx | 59 ++ packages/ui/hooks/useCollabRoomSession.ts | 154 +++ .../ui/hooks/useDismissOnOutsideAndEscape.ts | 5 +- .../hooks/useExternalAnnotationHighlights.ts | 123 +-- .../ui/hooks/usePresenceThrottle.test.tsx | 115 +++ packages/ui/hooks/usePresenceThrottle.ts | 83 ++ packages/ui/hooks/useRoomAdminActions.ts | 57 ++ packages/ui/hooks/useRoomMode.test.tsx | 111 +++ packages/ui/hooks/useRoomMode.ts | 68 ++ packages/ui/hooks/useSharing.ts | 55 +- packages/ui/package.json | 5 + packages/ui/test-setup.test.ts | 26 + packages/ui/test-setup.ts | 25 + packages/ui/tsconfig.collab.json | 37 - packages/ui/tsconfig.slice5.json | 79 ++ packages/ui/types/annotationController.ts | 86 ++ packages/ui/utils/adminSecretStorage.test.ts | 72 ++ packages/ui/utils/adminSecretStorage.ts | 81 ++ packages/ui/utils/identity.ts | 36 +- packages/ui/utils/presenceColor.test.ts | 66 ++ packages/ui/utils/presenceColor.ts | 43 + packages/ui/utils/roomIdentityConfirmed.ts | 52 + packages/ui/utils/sharing.ts | 7 +- scripts/dev-live-room-local.sh | 90 ++ specs/room-browser-smoke.md | 160 ++++ specs/v1-cursor-presence.md | 227 +++++ specs/v1-decisionbridge-local-clarity.md | 12 +- specs/v1-decisionbridge.md | 63 +- specs/v1-implementation-approach.md | 64 +- specs/v1-prd.md | 24 +- specs/v1-slice4-plan.md | 4 +- specs/v1.md | 101 +- tests/parity/vendor-parity.test.ts | 2 + 120 files changed, 10108 insertions(+), 584 deletions(-) create mode 100644 apps/hook/public/favicon.svg create mode 100644 apps/pi-extension/server-plan.test.ts create mode 100644 apps/room-service/core/csp.test.ts create mode 100644 apps/room-service/entry.tsx create mode 100644 apps/room-service/index.html create mode 100644 apps/room-service/scripts/fake-presence.ts create mode 100644 apps/room-service/static/favicon.svg create mode 100644 apps/room-service/tsconfig.browser.json create mode 100644 apps/room-service/vite.config.ts create mode 100644 packages/editor/AppRoot.tsx create mode 100644 packages/editor/RoomApp.tsx create mode 100644 packages/editor/bunfig.toml create mode 100644 packages/editor/env.d.ts create mode 100644 packages/editor/hooks/useCheckboxOverrides.test.tsx create mode 100644 packages/editor/roomIdentityHandoff.test.ts create mode 100644 packages/editor/roomIdentityHandoff.ts create mode 100644 packages/editor/tsconfig.json create mode 100644 packages/shared/collab/redact-url.test.ts create mode 100644 packages/shared/collab/redact-url.ts create mode 100644 packages/shared/collab/validation.test.ts create mode 100644 packages/shared/collab/validation.ts create mode 100644 packages/shared/config.test.ts create mode 100644 packages/ui/bunfig.toml create mode 100644 packages/ui/components/collab/AdminControls.tsx create mode 100644 packages/ui/components/collab/ImageStripNotice.tsx create mode 100644 packages/ui/components/collab/JoinRoomGate.tsx create mode 100644 packages/ui/components/collab/ParticipantAvatars.test.tsx create mode 100644 packages/ui/components/collab/ParticipantAvatars.tsx create mode 100644 packages/ui/components/collab/RemoteCursorLayer.tsx create mode 100644 packages/ui/components/collab/RoomAdminErrorToast.tsx create mode 100644 packages/ui/components/collab/RoomHeaderControls.tsx create mode 100644 packages/ui/components/collab/RoomMenu.tsx create mode 100644 packages/ui/components/collab/RoomStatusBadge.test.tsx create mode 100644 packages/ui/components/collab/RoomStatusBadge.tsx create mode 100644 packages/ui/components/collab/StartRoomModal.tsx create mode 100644 packages/ui/hooks/useAnnotationController.room.test.tsx create mode 100644 packages/ui/hooks/useAnnotationController.test.tsx create mode 100644 packages/ui/hooks/useAnnotationController.ts create mode 100644 packages/ui/hooks/useAnnotationHighlightReconciler.test.tsx create mode 100644 packages/ui/hooks/useAnnotationHighlightReconciler.ts create mode 100644 packages/ui/hooks/useAnnotationHighlighter.readOnly.test.tsx create mode 100644 packages/ui/hooks/useCollabRoom.test.tsx create mode 100644 packages/ui/hooks/useCollabRoomSession.test.tsx create mode 100644 packages/ui/hooks/useCollabRoomSession.ts create mode 100644 packages/ui/hooks/usePresenceThrottle.test.tsx create mode 100644 packages/ui/hooks/usePresenceThrottle.ts create mode 100644 packages/ui/hooks/useRoomAdminActions.ts create mode 100644 packages/ui/hooks/useRoomMode.test.tsx create mode 100644 packages/ui/hooks/useRoomMode.ts create mode 100644 packages/ui/test-setup.test.ts create mode 100644 packages/ui/test-setup.ts delete mode 100644 packages/ui/tsconfig.collab.json create mode 100644 packages/ui/tsconfig.slice5.json create mode 100644 packages/ui/types/annotationController.ts create mode 100644 packages/ui/utils/adminSecretStorage.test.ts create mode 100644 packages/ui/utils/adminSecretStorage.ts create mode 100644 packages/ui/utils/presenceColor.test.ts create mode 100644 packages/ui/utils/presenceColor.ts create mode 100644 packages/ui/utils/roomIdentityConfirmed.ts create mode 100755 scripts/dev-live-room-local.sh create mode 100644 specs/room-browser-smoke.md create mode 100644 specs/v1-cursor-presence.md diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4fdfa468..c9ed0ac4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -41,7 +41,9 @@ jobs: run: bun run typecheck - name: Run tests - run: bun test + # See .github/workflows/test.yml for why this is `bun run test` + # and not raw `bun test`. + run: bun run test build: needs: test diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d319f940..985aa4f1 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -29,7 +29,12 @@ jobs: run: bun run typecheck - name: Run tests - run: bun test + # Use the root `test` script (splits non-UI + UI-cwd) so the + # packages/ui/bunfig.toml happy-dom preload is loaded. Raw + # `bun test` from the repo root doesn't pick up that package- + # scoped preload, so UI hook tests would hit "document is not + # defined". + run: bun run test install-cmd-windows: # End-to-end integration test for scripts/install.cmd on real cmd.exe. diff --git a/.gitignore b/.gitignore index 1e7c7e48..d00c4733 100644 --- a/.gitignore +++ b/.gitignore @@ -50,6 +50,10 @@ plannotator-local # Cloudflare Wrangler local state (Miniflare SQLite, caches) .wrangler/ +# Room-service Vite build output (chunked editor bundle served by +# Cloudflare's [assets] binding; regenerated by `bun run build:shell`). +apps/room-service/public/ + # Claude Code local scratch files (per-session locks, etc.). Intentionally # ignored so they can't be committed accidentally. .claude/scheduled_tasks.lock diff --git a/AGENTS.md b/AGENTS.md index b3c09aac..910afddb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,10 +29,15 @@ plannotator/ │ │ ├── index.tsx │ │ └── vite.config.ts │ ├── room-service/ # Live collaboration rooms (Cloudflare Worker + Durable Object) -│ │ ├── core/ # Handler, DO class, validation, CORS, log, types +│ │ ├── core/ # Handler, DO class, validation, CORS, log, types, csp │ │ ├── targets/cloudflare.ts # Worker entry + DO re-export +│ │ ├── entry.tsx # Browser shell entry — mounts AppRoot for /c/:roomId +│ │ ├── index.html # Vite template; produces hashed chunks under /assets/ +│ │ ├── vite.config.ts # Browser shell build (bun run build:shell) +│ │ ├── tsconfig.browser.json # DOM-lib tsconfig for the shell +│ │ ├── static/ # Root-level static assets copied into public/ by build:shell (favicon.svg) │ │ ├── scripts/smoke.ts # Integration test against wrangler dev -│ │ └── wrangler.toml # SQLite-backed DO binding +│ │ └── wrangler.toml # SQLite-backed DO binding + ASSETS binding for built shell │ └── vscode-extension/ # VS Code extension — opens plans in editor tabs │ ├── bin/ # Router scripts (open-in-vscode, xdg-open) │ ├── src/ # extension.ts, cookie-proxy.ts, ipc-server.ts, panel-manager.ts, editor-annotations.ts, vscode-theme.ts @@ -56,9 +61,10 @@ plannotator/ │ │ ├── components/ # Viewer, Toolbar, Settings, etc. │ │ │ ├── icons/ # Shared SVG icon components (themeIcons, etc.) │ │ │ ├── plan-diff/ # PlanDiffBadge, PlanDiffViewer, clean/raw diff views -│ │ │ └── sidebar/ # SidebarContainer, SidebarTabs, VersionBrowser, ArchiveBrowser -│ │ ├── utils/ # parser.ts, sharing.ts, storage.ts, planSave.ts, agentSwitch.ts, planDiffEngine.ts, planAgentInstructions.ts -│ │ ├── hooks/ # useAnnotationHighlighter.ts, useSharing.ts, usePlanDiff.ts, useSidebar.ts, useLinkedDoc.ts, useAnnotationDraft.ts, useCodeAnnotationDraft.ts, useArchive.ts, useCollabRoom.ts +│ │ │ ├── sidebar/ # SidebarContainer, SidebarTabs, VersionBrowser, ArchiveBrowser +│ │ │ └── collab/ # RoomPanel, RoomStatusBadge, ParticipantAvatars, AdminControls, JoinRoomGate, StartRoomModal, RemoteCursorLayer, ImageStripNotice +│ │ ├── utils/ # parser.ts, sharing.ts, storage.ts, planSave.ts, agentSwitch.ts, planDiffEngine.ts, planAgentInstructions.ts, adminSecretStorage.ts, blockTargeting.ts +│ │ ├── hooks/ # useAnnotationHighlighter.ts, useSharing.ts, usePlanDiff.ts, useSidebar.ts, useLinkedDoc.ts, useAnnotationDraft.ts, useCodeAnnotationDraft.ts, useArchive.ts, useCollabRoom.ts, useCollabRoomSession.ts, useAnnotationController.ts, useRoomMode.ts, usePresenceThrottle.ts │ │ └── types.ts │ ├── ai/ # Provider-agnostic AI backbone (providers, sessions, endpoints) │ ├── shared/ # Shared types, utilities, and cross-runtime logic @@ -73,9 +79,15 @@ plannotator/ │ │ ├── constants.ts # ROOM_SECRET_LENGTH_BYTES, ADMIN_SECRET_LENGTH_BYTES, WS_CLOSE_ROOM_UNAVAILABLE, WS_CLOSE_REASON_ROOM_DELETED, WS_CLOSE_REASON_ROOM_EXPIRED │ │ ├── canonical-json.ts # canonicalJson for admin command proof binding │ │ ├── encoding.ts # base64url helpers +│ │ ├── strip-images.ts # toRoomAnnotation, stripRoomAnnotationImages (image stripping for room snapshots) +│ │ ├── redact-url.ts # redactRoomSecrets (scrub #key=/#admin= from telemetry/logs) +│ │ ├── validation.ts # isBase64Url32ByteString / isValidPermissionMode │ │ ├── client.ts # Client barrel re-exports │ │ └── client-runtime/ # CollabRoomClient class, createRoom, joinRoom, apply-event reducer -│ ├── editor/ # Plan review App.tsx +│ ├── editor/ # Plan review app (App.tsx) + room-mode shell +│ │ ├── App.tsx # Plan review editor (local + room-mode prop) +│ │ ├── AppRoot.tsx # Mode fork (local | room | invalid-room); package default export +│ │ └── RoomApp.tsx # Room-mode shell — identity gate, session, overlays, delete/expired fallbacks │ └── review-editor/ # Code review UI │ ├── App.tsx # Main review app │ ├── components/ # DiffViewer, FileTree, ReviewSidebar @@ -207,6 +219,17 @@ During normal plan review, an Archive sidebar tab provides the same browsing via ### Plan Server (`packages/server/index.ts`) +Live Rooms V1 does NOT support approve/deny from the room origin. +Approvals always happen on the local editor origin (the tab that +started the hook). Room-side annotations flow back to the local +editor via the existing import paths (static share hash, paste short +URL, "Copy consolidated feedback" → paste). + +Local external annotations (`/api/external-annotations` + SSE) remain +local to the localhost editor in the current room integration. +Forwarding those annotations into encrypted room ops is later Slice 6 +work; it is not part of the room-origin approve/deny surface. + | Endpoint | Method | Purpose | | --------------------- | ------ | ------------------------------------------ | | `/api/plan` | GET | Returns `{ plan, origin, previousPlan, versionInfo }` (plan mode) or `{ plan, origin, mode: "archive", archivePlans }` (archive mode) | @@ -297,7 +320,7 @@ Live-collaboration rooms for encrypted multi-user annotation. Zero-knowledge: th | Endpoint | Method | Purpose | | --------------------- | ------ | ------------------------------------------ | | `/health` | GET | Worker liveness probe | -| `/c/:roomId` | GET | Room SPA shell (Slice 5 replaces with the editor bundle) | +| `/c/:roomId` | GET | Room SPA shell — serves the built editor bundle (hashed chunks under `/assets/`). Response carries `ROOM_CSP`, `Cache-Control: no-store` on the HTML, `Referrer-Policy: no-referrer`. `:roomId` is validated against `isRoomId()` before the asset fetch. | | `/api/rooms` | POST | Create room. Body: `{ roomId, roomVerifier, adminVerifier, initialSnapshotCiphertext, expiresInDays? }`. Returns `201` on success; `409` on duplicate `roomId`. Response body is intentionally not consumed by `createRoom()`. | | `/ws/:roomId` | GET | WebSocket upgrade into the room Durable Object. `roomId` is validated via `isRoomId()` before `idFromName()` to prevent arbitrary DO instantiation. | diff --git a/apps/hook/public/favicon.svg b/apps/hook/public/favicon.svg new file mode 100644 index 00000000..070e83e2 --- /dev/null +++ b/apps/hook/public/favicon.svg @@ -0,0 +1,5 @@ + + + + P + diff --git a/apps/hook/tsconfig.json b/apps/hook/tsconfig.json index 93ef3e28..1b3c3a05 100644 --- a/apps/hook/tsconfig.json +++ b/apps/hook/tsconfig.json @@ -21,7 +21,8 @@ "paths": { "@/*": ["./*"], "@plannotator/ui/*": ["../../packages/ui/*"], - "@plannotator/editor": ["../../packages/editor/App.tsx"], + "@plannotator/editor": ["../../packages/editor/AppRoot.tsx"], + "@plannotator/editor/App": ["../../packages/editor/App.tsx"], "@plannotator/editor/*": ["../../packages/editor/*"] }, "allowImportingTsExtensions": true, diff --git a/apps/hook/vite.config.ts b/apps/hook/vite.config.ts index 5577f88e..664db1ea 100644 --- a/apps/hook/vite.config.ts +++ b/apps/hook/vite.config.ts @@ -20,7 +20,8 @@ export default defineConfig({ '@': path.resolve(__dirname, '.'), '@plannotator/ui': path.resolve(__dirname, '../../packages/ui'), '@plannotator/editor/styles': path.resolve(__dirname, '../../packages/editor/index.css'), - '@plannotator/editor': path.resolve(__dirname, '../../packages/editor/App.tsx'), + '@plannotator/editor/App': path.resolve(__dirname, '../../packages/editor/App.tsx'), + '@plannotator/editor': path.resolve(__dirname, '../../packages/editor/AppRoot.tsx'), } }, build: { diff --git a/apps/pi-extension/server-plan.test.ts b/apps/pi-extension/server-plan.test.ts new file mode 100644 index 00000000..21d2f484 --- /dev/null +++ b/apps/pi-extension/server-plan.test.ts @@ -0,0 +1,173 @@ +/** + * Regression tests for the Pi plan server's approve/deny path: + * + * - saveFinalSnapshot / saveAnnotations throwing must NOT strand the + * decision promise (claim-then-publish hardening, H9/R1). + * - body.permissionMode must be validated via isValidPermissionMode(). + * + * Mirrors the fixes in packages/server/index.ts (Bun). The Pi server is + * the easier integration target because it uses node:http and its + * `startPlanReviewServer` exposes a straightforward decision promise. + */ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { createServer as createNetServer } from "node:net"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { startPlanReviewServer } from "./server/serverPlan"; + +const tempDirs: string[] = []; +const originalCwd = process.cwd(); +const originalHome = process.env.HOME; +const originalPort = process.env.PLANNOTATOR_PORT; + +function makeTempDir(prefix: string): string { + const dir = mkdtempSync(join(tmpdir(), prefix)); + tempDirs.push(dir); + return dir; +} + +function reservePort(): Promise { + return new Promise((resolve, reject) => { + const server = createNetServer(); + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (!address || typeof address === "string") { + server.close(); + reject(new Error("Failed to reserve test port")); + return; + } + const { port } = address; + server.close((error) => { + if (error) reject(error); + else resolve(port); + }); + }); + }); +} + +afterEach(() => { + process.chdir(originalCwd); + if (originalHome === undefined) delete process.env.HOME; + else process.env.HOME = originalHome; + if (originalPort === undefined) delete process.env.PLANNOTATOR_PORT; + else process.env.PLANNOTATOR_PORT = originalPort; + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +async function bootPlanServer(options: { permissionMode?: string } = {}) { + const homeDir = makeTempDir("plannotator-pi-plan-home-"); + process.env.HOME = homeDir; + process.chdir(homeDir); // Avoid picking up repo git context + process.env.PLANNOTATOR_PORT = String(await reservePort()); + const server = await startPlanReviewServer({ + plan: "# Plan\n\nBody.", + htmlContent: "plan", + origin: "pi", + permissionMode: options.permissionMode ?? "default", + sharingEnabled: false, + }); + return server; +} + +describe("pi plan server: decision-hang regression", () => { + test("approve with a customPath that forces save to throw still resolves the decision", async () => { + const server = await bootPlanServer(); + try { + // Force saveFinalSnapshot/saveAnnotations to throw by pointing the + // custom plan dir at a regular file — mkdirSync recursive will + // fail with ENOTDIR because an ancestor is a file, not a dir. + const fileAsDir = join(makeTempDir("plannotator-block-"), "not-a-dir"); + writeFileSync(fileAsDir, "blocker", "utf-8"); + + const res = await fetch(`${server.url}/api/approve`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + feedback: "ok", + planSave: { enabled: true, customPath: fileAsDir }, + }), + }); + expect(res.status).toBe(200); + + // Decision promise must resolve even though the save threw. + const decision = await server.waitForDecision(); + expect(decision.approved).toBe(true); + expect(decision.savedPath).toBeUndefined(); + } finally { + server.stop(); + } + }, 10_000); + + test("deny with a customPath that forces save to throw still resolves the decision", async () => { + const server = await bootPlanServer(); + try { + const fileAsDir = join(makeTempDir("plannotator-block-"), "not-a-dir"); + writeFileSync(fileAsDir, "blocker", "utf-8"); + + const res = await fetch(`${server.url}/api/deny`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + feedback: "nope", + planSave: { enabled: true, customPath: fileAsDir }, + }), + }); + expect(res.status).toBe(200); + + const decision = await server.waitForDecision(); + expect(decision.approved).toBe(false); + expect(decision.savedPath).toBeUndefined(); + expect(decision.feedback).toBe("nope"); + } finally { + server.stop(); + } + }, 10_000); +}); + +describe("pi plan server: permissionMode validation", () => { + test("same-origin body.permissionMode is honored only when isValidPermissionMode passes", async () => { + const server = await bootPlanServer({ permissionMode: "default" }); + try { + // Invalid string → silently dropped; fall back to server startup value. + const res = await fetch(`${server.url}/api/approve`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + feedback: "ok", + planSave: { enabled: false }, + permissionMode: "rootKeyPleaseAndThankYou", + }), + }); + expect(res.status).toBe(200); + const decision = await server.waitForDecision(); + expect(decision.permissionMode).toBe("default"); + } finally { + server.stop(); + } + }, 10_000); + + test("same-origin body.permissionMode='bypassPermissions' IS honored (valid value)", async () => { + const server = await bootPlanServer({ permissionMode: "default" }); + try { + const res = await fetch(`${server.url}/api/approve`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + feedback: "ok", + planSave: { enabled: false }, + permissionMode: "bypassPermissions", + }), + }); + expect(res.status).toBe(200); + const decision = await server.waitForDecision(); + expect(decision.permissionMode).toBe("bypassPermissions"); + } finally { + server.stop(); + } + }, 10_000); + +}); diff --git a/apps/pi-extension/server/serverAnnotate.ts b/apps/pi-extension/server/serverAnnotate.ts index 7b1e1177..1933375a 100644 --- a/apps/pi-extension/server/serverAnnotate.ts +++ b/apps/pi-extension/server/serverAnnotate.ts @@ -94,9 +94,10 @@ export async function startAnnotateServer(options: { }); } else if (url.pathname === "/api/config" && req.method === "POST") { try { - const body = (await parseBody(req)) as { displayName?: string; diffOptions?: Record; conventionalComments?: boolean }; + const body = (await parseBody(req)) as { displayName?: string; presenceColor?: string; diffOptions?: Record; conventionalComments?: boolean }; const toSave: Record = {}; if (body.displayName !== undefined) toSave.displayName = body.displayName; + if (body.presenceColor !== undefined) toSave.presenceColor = body.presenceColor; if (body.diffOptions !== undefined) toSave.diffOptions = body.diffOptions; if (body.conventionalComments !== undefined) toSave.conventionalComments = body.conventionalComments; if (Object.keys(toSave).length > 0) saveConfig(toSave as Parameters[0]); diff --git a/apps/pi-extension/server/serverPlan.ts b/apps/pi-extension/server/serverPlan.ts index 78448877..410ba91c 100644 --- a/apps/pi-extension/server/serverPlan.ts +++ b/apps/pi-extension/server/serverPlan.ts @@ -35,6 +35,7 @@ import { saveToOctarine, } from "./integrations.js"; import { listenOnPort } from "./network.js"; +import { isValidPermissionMode } from "../generated/collab/validation.js"; import { saveConfig, detectGitUser, getServerConfig } from "../generated/config.js"; import { detectProjectName, getRepoInfo } from "./project.js"; @@ -128,13 +129,21 @@ export async function startPlanReviewServer(options: { const reviewId = randomUUID(); let resolveDecision!: (result: PlanReviewDecision) => void; const decisionListeners = new Set<(result: PlanReviewDecision) => void | Promise>(); + // Claim-then-publish: claimDecision() sets the flag BEFORE any side + // effects run, so two near-simultaneous POSTs cannot both pass the + // guard and run integrations/saves twice. publishDecision() is + // called after side effects finish; it only resolves the promise + // and notifies listeners. Mirrors packages/server/index.ts. let decisionSettled = false; const decisionPromise = new Promise((r) => { resolveDecision = r; }); - const publishDecision = (result: PlanReviewDecision): boolean => { + const claimDecision = (): boolean => { if (decisionSettled) return false; decisionSettled = true; + return true; + }; + const publishDecision = (result: PlanReviewDecision): boolean => { resolveDecision(result); for (const listener of decisionListeners) { Promise.resolve(listener(result)).catch((error) => { @@ -225,9 +234,10 @@ export async function startPlanReviewServer(options: { } } else if (url.pathname === "/api/config" && req.method === "POST") { try { - const body = (await parseBody(req)) as { displayName?: string; diffOptions?: Record; conventionalComments?: boolean }; + const body = (await parseBody(req)) as { displayName?: string; presenceColor?: string; diffOptions?: Record; conventionalComments?: boolean }; const toSave: Record = {}; if (body.displayName !== undefined) toSave.displayName = body.displayName; + if (body.presenceColor !== undefined) toSave.presenceColor = body.presenceColor; if (body.diffOptions !== undefined) toSave.diffOptions = body.diffOptions; if (body.conventionalComments !== undefined) toSave.conventionalComments = body.conventionalComments; if (Object.keys(toSave).length > 0) saveConfig(toSave as Parameters[0]); @@ -338,7 +348,7 @@ export async function startPlanReviewServer(options: { } json(res, { ok: true, results }); } else if (url.pathname === "/api/approve" && req.method === "POST") { - if (decisionSettled) { + if (!claimDecision()) { json(res, { ok: true, duplicate: true }); return; } @@ -351,14 +361,15 @@ export async function startPlanReviewServer(options: { const body = await parseBody(req); if (body.feedback) feedback = body.feedback as string; if (body.agentSwitch) agentSwitch = body.agentSwitch as string; - if (body.permissionMode) - requestedPermissionMode = body.permissionMode as string; if (body.planSave !== undefined) { const ps = body.planSave as { enabled: boolean; customPath?: string }; planSaveEnabled = ps.enabled; planSaveCustomPath = ps.customPath; } - // Run note integrations in parallel + // Validate body.permissionMode shape so an invalid value + // can't silently fall through to the hook. + if (isValidPermissionMode(body.permissionMode)) + requestedPermissionMode = body.permissionMode; const integrationResults: Record = {}; const integrationPromises: Promise[] = []; const obsConfig = body.obsidian as ObsidianConfig | undefined; @@ -393,20 +404,32 @@ export async function startPlanReviewServer(options: { } catch (err) { console.error(`[Integration] Error:`, err); } - // Save annotations and final snapshot + // Save annotations and final snapshot. The claim is already set + // above, so we MUST reach publishDecision() below — otherwise + // the awaiting hook hangs forever and retries are rejected as + // duplicates. Persistence is best-effort: log and continue. let savedPath: string | undefined; if (planSaveEnabled) { - const annotations = feedback || ""; - if (annotations) saveAnnotations(slug, annotations, planSaveCustomPath); - savedPath = saveFinalSnapshot( - slug, - "approved", - options.plan, - annotations, - planSaveCustomPath, - ); + try { + const annotations = feedback || ""; + if (annotations) saveAnnotations(slug, annotations, planSaveCustomPath); + savedPath = saveFinalSnapshot( + slug, + "approved", + options.plan, + annotations, + planSaveCustomPath, + ); + } catch (err) { + console.error(`[plan-save] approve persistence failed:`, err); + } + } + try { + deleteDraft(draftKey); + } catch (err) { + console.error(`[draft] delete failed:`, err); } - deleteDraft(draftKey); + // Resolution order: client request body > server startup value. const effectivePermissionMode = requestedPermissionMode || options.permissionMode; publishDecision({ approved: true, @@ -417,7 +440,7 @@ export async function startPlanReviewServer(options: { }); json(res, { ok: true, savedPath }); } else if (url.pathname === "/api/deny" && req.method === "POST") { - if (decisionSettled) { + if (!claimDecision()) { json(res, { ok: true, duplicate: true }); return; } @@ -437,16 +460,24 @@ export async function startPlanReviewServer(options: { } let savedPath: string | undefined; if (planSaveEnabled) { - saveAnnotations(slug, feedback, planSaveCustomPath); - savedPath = saveFinalSnapshot( - slug, - "denied", - options.plan, - feedback, - planSaveCustomPath, - ); + try { + saveAnnotations(slug, feedback, planSaveCustomPath); + savedPath = saveFinalSnapshot( + slug, + "denied", + options.plan, + feedback, + planSaveCustomPath, + ); + } catch (err) { + console.error(`[plan-save] deny persistence failed:`, err); + } + } + try { + deleteDraft(draftKey); + } catch (err) { + console.error(`[draft] delete failed:`, err); } - deleteDraft(draftKey); publishDecision({ approved: false, feedback, savedPath }); json(res, { ok: true, savedPath }); } else { diff --git a/apps/pi-extension/server/serverReview.ts b/apps/pi-extension/server/serverReview.ts index c69180aa..9b708e8d 100644 --- a/apps/pi-extension/server/serverReview.ts +++ b/apps/pi-extension/server/serverReview.ts @@ -592,9 +592,10 @@ export async function startReviewServer(options: { json(res, { error: "No file access available" }, 400); } else if (url.pathname === "/api/config" && req.method === "POST") { try { - const body = (await parseBody(req)) as { displayName?: string; diffOptions?: Record; conventionalComments?: boolean }; + const body = (await parseBody(req)) as { displayName?: string; presenceColor?: string; diffOptions?: Record; conventionalComments?: boolean }; const toSave: Record = {}; if (body.displayName !== undefined) toSave.displayName = body.displayName; + if (body.presenceColor !== undefined) toSave.presenceColor = body.presenceColor; if (body.diffOptions !== undefined) toSave.diffOptions = body.diffOptions; if (body.conventionalComments !== undefined) toSave.conventionalComments = body.conventionalComments; if (Object.keys(toSave).length > 0) saveConfig(toSave as Parameters[0]); diff --git a/apps/pi-extension/vendor.sh b/apps/pi-extension/vendor.sh index e7dfce03..7d67051f 100755 --- a/apps/pi-extension/vendor.sh +++ b/apps/pi-extension/vendor.sh @@ -11,6 +11,13 @@ for f in feedback-templates review-core storage draft project pr-provider pr-git printf '// @generated — DO NOT EDIT. Source: packages/shared/%s.ts\n' "$f" | cat - "$src" > "generated/$f.ts" done +# Vendor collab submodule(s) needed by the Pi server. +mkdir -p generated/collab +for f in validation; do + src="../../packages/shared/collab/$f.ts" + printf '// @generated — DO NOT EDIT. Source: packages/shared/collab/%s.ts\n' "$f" | cat - "$src" > "generated/collab/$f.ts" +done + # Vendor review agent modules from packages/server/ — rewrite imports for generated/ layout for f in codex-review claude-review path-utils; do src="../../packages/server/$f.ts" diff --git a/apps/portal/tsconfig.json b/apps/portal/tsconfig.json index 93ef3e28..1b3c3a05 100644 --- a/apps/portal/tsconfig.json +++ b/apps/portal/tsconfig.json @@ -21,7 +21,8 @@ "paths": { "@/*": ["./*"], "@plannotator/ui/*": ["../../packages/ui/*"], - "@plannotator/editor": ["../../packages/editor/App.tsx"], + "@plannotator/editor": ["../../packages/editor/AppRoot.tsx"], + "@plannotator/editor/App": ["../../packages/editor/App.tsx"], "@plannotator/editor/*": ["../../packages/editor/*"] }, "allowImportingTsExtensions": true, diff --git a/apps/portal/vite.config.ts b/apps/portal/vite.config.ts index 822b099c..226d96df 100644 --- a/apps/portal/vite.config.ts +++ b/apps/portal/vite.config.ts @@ -18,7 +18,8 @@ export default defineConfig({ '@': path.resolve(__dirname, '.'), '@plannotator/ui': path.resolve(__dirname, '../../packages/ui'), '@plannotator/editor/styles': path.resolve(__dirname, '../../packages/editor/index.css'), - '@plannotator/editor': path.resolve(__dirname, '../../packages/editor/App.tsx'), + '@plannotator/editor/App': path.resolve(__dirname, '../../packages/editor/App.tsx'), + '@plannotator/editor': path.resolve(__dirname, '../../packages/editor/AppRoot.tsx'), } }, build: { diff --git a/apps/room-service/core/csp.test.ts b/apps/room-service/core/csp.test.ts new file mode 100644 index 00000000..aa30ea29 --- /dev/null +++ b/apps/room-service/core/csp.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, test } from 'bun:test'; +import { ROOM_CSP, handleRequest } from './handler'; + +/** Parse a CSP directive into its individual tokens. */ +function directiveTokens(csp: string, directive: string): string[] { + const d = csp + .split(';') + .map(s => s.trim()) + .find(s => s.startsWith(directive)); + if (!d) return []; + return d.split(/\s+/).slice(1); // drop the directive name itself +} + +describe('ROOM_CSP constant', () => { + test('is a non-empty string', () => { + expect(typeof ROOM_CSP).toBe('string'); + expect(ROOM_CSP.length).toBeGreaterThan(0); + }); + + test("default-src is 'self'", () => { + expect(ROOM_CSP).toContain("default-src 'self'"); + }); + + test("script-src allows 'self' and 'wasm-unsafe-eval' only", () => { + const tokens = directiveTokens(ROOM_CSP, 'script-src'); + expect(tokens).toContain("'self'"); + expect(tokens).toContain("'wasm-unsafe-eval'"); + // Must NOT contain plain 'unsafe-eval' or 'unsafe-inline'. + expect(tokens).not.toContain("'unsafe-eval'"); + expect(tokens).not.toContain("'unsafe-inline'"); + }); + + test('blocks object embeds', () => { + expect(ROOM_CSP).toContain("object-src 'none'"); + }); + + test('blocks base-uri injection', () => { + expect(ROOM_CSP).toContain("base-uri 'none'"); + }); + + test('blocks framing (clickjacking)', () => { + expect(ROOM_CSP).toContain("frame-ancestors 'none'"); + }); + + test('blocks form submissions', () => { + expect(ROOM_CSP).toContain("form-action 'none'"); + }); + + test('does NOT allow localhost HTTP connections', () => { + // The room origin should not have blanket fetch access to any + // local HTTP service; an XSS injection would otherwise exfiltrate + // to loopback listeners. WebSocket entries below are intentionally + // scoped to `ws://` only (HTTP loopback remains closed). + const tokens = directiveTokens(ROOM_CSP, 'connect-src'); + expect(tokens).not.toContain('http://localhost:*'); + expect(tokens).not.toContain('http://127.0.0.1:*'); + expect(tokens).not.toContain('http://[::1]:*'); + }); + + test('allows scoped localhost WebSocket connections (cross-port dev)', () => { + expect(ROOM_CSP).toContain('ws://localhost:*'); + expect(ROOM_CSP).toContain('ws://127.0.0.1:*'); + expect(ROOM_CSP).toContain('ws://[::1]:*'); + }); + + test('does NOT allow blanket https: / ws: / wss: in connect-src', () => { + // `'self'` already covers same-origin wss:/ws: in prod and dev. + // Blanket schemes would allow post-XSS exfiltration to any host on + // that scheme — same reasoning that excludes blanket https:. + const tokens = directiveTokens(ROOM_CSP, 'connect-src'); + expect(tokens).not.toContain('https:'); + expect(tokens).not.toContain('ws:'); + expect(tokens).not.toContain('wss:'); + }); + + test('img-src allows remote markdown images (https:)', () => { + // Remote `![alt](https://...)` in a plan document renders as a + // plain and must not be blocked. Annotation + // attachments remain stripped at room-create time, so this allowance + // only covers document-level markdown images. + const tokens = directiveTokens(ROOM_CSP, 'img-src'); + expect(tokens).toContain("'self'"); + expect(tokens).toContain('https:'); + expect(tokens).toContain('data:'); + expect(tokens).toContain('blob:'); + }); + + test('does NOT include upgrade-insecure-requests', () => { + expect(ROOM_CSP).not.toContain('upgrade-insecure-requests'); + }); + + test('allows Google Fonts', () => { + expect(ROOM_CSP).toContain('https://fonts.googleapis.com'); + expect(ROOM_CSP).toContain('https://fonts.gstatic.com'); + }); +}); + +describe('serveIndexHtml headers (fallback path, no ASSETS)', () => { + // Minimal env with no ASSETS binding — exercises the fallback + // HTML path inside handleRequest, which is the cheapest way to + // assert the headers without needing a Durable Object namespace. + const minimalEnv = { + ROOM: {} as never, // unused by the room-shell path + ALLOWED_ORIGINS: 'https://room.plannotator.ai', + ALLOW_LOCALHOST_ORIGINS: 'true', + BASE_URL: 'https://room.plannotator.ai', + }; + const cors = { + 'Access-Control-Allow-Origin': '*', + }; + + async function getRoom(roomId = 'AAAAAAAAAAAAAAAAAAAAAA'): Promise { + const req = new Request(`https://room.plannotator.ai/c/${roomId}`, { + method: 'GET', + }); + return handleRequest(req, minimalEnv, cors); + } + + test('returns 200 with Content-Security-Policy', async () => { + const res = await getRoom(); + expect(res.status).toBe(200); + const csp = res.headers.get('Content-Security-Policy'); + expect(csp).not.toBeNull(); + expect(csp).toContain("default-src 'self'"); + }); + + test('returns Cache-Control: no-store', async () => { + const res = await getRoom(); + expect(res.headers.get('Cache-Control')).toBe('no-store'); + }); + + test('returns Referrer-Policy: no-referrer', async () => { + const res = await getRoom(); + expect(res.headers.get('Referrer-Policy')).toBe('no-referrer'); + }); + + test('returns text/html content type', async () => { + const res = await getRoom(); + expect(res.headers.get('Content-Type')).toContain('text/html'); + }); +}); diff --git a/apps/room-service/core/handler.ts b/apps/room-service/core/handler.ts index 25dcc71a..86547f15 100644 --- a/apps/room-service/core/handler.ts +++ b/apps/room-service/core/handler.ts @@ -31,65 +31,213 @@ export async function handleRequest( return Response.json({ ok: true }, { headers: cors }); } - // Room SPA shell placeholder (Slice 5 serves the real editor bundle). - // - // Defense-in-depth header: Referrer-Policy: no-referrer. - // Note: browsers already do NOT include the URL fragment (#key=…&admin=…) - // in outbound Referer headers, so the header isn't plugging a fragment - // leak per se. What it does is belt-and-braces: it strips the *path* - // (which contains the room id) from Referer on any outbound navigation - // or subresource fetch the page performs, reducing room-id exposure to - // third parties. The actual credential-leak risk for this page is - // JavaScript telemetry reading `window.location.href` — Slice 5 editor - // code must scrub `#key=` and `#admin=` from any telemetry / - // error-reporting payload. - const roomMatch = pathname.match(ROOM_PATH_RE); - if (roomMatch && method === 'GET') { - const roomId = roomMatch[1]; - // Validate up front so invalid URLs never reach the room shell (or, - // in Slice 5, the editor bundle). Matches the /ws/:roomId validation. - if (!isRoomId(roomId)) { - return new Response('Not Found', { status: 404, headers: cors }); - } - return new Response( - `Plannotator Room

Room: ${escapeHtml(roomId)}

`, - { - status: 200, - headers: { - ...cors, - 'Content-Type': 'text/html; charset=utf-8', - 'Referrer-Policy': 'no-referrer', - }, - }, - ); - } - - // Assets placeholder — intentionally deferred to Slice 5 - if (pathname.startsWith('/assets/') && method === 'GET') { - return Response.json( - { error: 'Static assets not yet available' }, - { status: 404, headers: cors }, - ); - } - // Room creation if (pathname === '/api/rooms' && method === 'POST') { return handleCreateRoom(request, env, cors); } - // WebSocket upgrade + // WebSocket upgrade — matched before asset/SPA routes so a stray ws/* + // under the asset binding can't be mistaken for a file fetch. const wsMatch = pathname.match(WS_PATH_RE); if (wsMatch && method === 'GET') { return handleWebSocket(request, env, wsMatch[1], cors); } - // 404 + // Hashed static assets — produced by `vite build` into ./public/assets/. + // Filenames include a content hash, so we set far-future immutable + // Cache-Control: chunks invalidate by name, never by TTL. Headers from + // the asset response (Content-Type, ETag, Content-Encoding) are + // preserved; we only override CORS + Cache-Control. + // Static root-level assets (favicon.svg). Vite copies these from + // the publicDir into the build output root alongside index.html. + // Served with a 1-day cache — they're not hashed so immutable isn't + // safe, but they change very rarely. + if (pathname === '/favicon.svg' && method === 'GET') { + if (!env.ASSETS) { + return new Response('Not Found', { status: 404, headers: cors }); + } + const assetRes = await env.ASSETS.fetch(request); + // Pass a real miss through as 404, but let 304 Not Modified + // responses flow through — `fetch.ok` treats 304 as "not ok" + // (it's outside 200-299), so returning 404 on 304 would force + // the browser to abandon its cached favicon and re-download + // on every revalidation. + if (!assetRes.ok && assetRes.status !== 304) { + return new Response('Not Found', { status: 404, headers: cors }); + } + const headers = new Headers(assetRes.headers); + for (const [k, v] of Object.entries(cors)) headers.set(k, v); + headers.set('Cache-Control', 'public, max-age=86400'); + return new Response(assetRes.body, { status: assetRes.status, headers }); + } + + if (pathname.startsWith('/assets/') && method === 'GET') { + if (!env.ASSETS) { + return new Response('Not Found', { status: 404, headers: cors }); + } + const assetRes = await env.ASSETS.fetch(request); + if (!assetRes.ok) { + // Surface the real status (404/403/etc.) rather than pretending + // everything is fine. CORS still attached so the browser exposes + // the response to the page's fetch logic. + const headers = new Headers(assetRes.headers); + for (const [k, v] of Object.entries(cors)) headers.set(k, v); + return new Response(assetRes.body, { status: assetRes.status, headers }); + } + const headers = new Headers(assetRes.headers); + for (const [k, v] of Object.entries(cors)) headers.set(k, v); + headers.set('Cache-Control', 'public, max-age=31536000, immutable'); + return new Response(assetRes.body, { status: assetRes.status, headers }); + } + + // Room SPA shell — /c/:roomId rewrites to /index.html so the chunked + // Vite bundle can boot with the original path still visible to the + // client JS (useRoomMode reads window.location.pathname to extract + // roomId, and parseRoomUrl reads the fragment for the room secret). + // + // Cache-Control: no-store — index.html references hashed chunk URLs + // that change on every deploy. Caching it would pin clients to stale + // chunk references and break after the next release. The immutable + // caching for /assets/* is what preserves the warm-visit performance; + // this HTML is tiny. + // + // Referrer-Policy: no-referrer strips the path (which contains the + // roomId) from Referer on any outbound subresource fetch. Fragments + // are never in Referer in any browser, so this is defense-in-depth + // for the path, not the secret itself. + const roomMatch = pathname.match(ROOM_PATH_RE); + if (roomMatch && method === 'GET') { + const roomId = roomMatch[1]; + if (!isRoomId(roomId)) { + return new Response('Not Found', { status: 404, headers: cors }); + } + return serveIndexHtml(request, env, cors); + } + + // No broad SPA fallback. This is a room-only origin — the only valid + // browser route is /c/:roomId (matched above). Serving index.html for + // `/`, `/about`, or other non-room paths would boot the local editor + // via AppRoot's local-mode branch, contradicting the room-only + // boundary. If future routes are added (e.g. /rooms index, admin + // recovery page), add explicit path matches here; don't open a + // catch-all that silently renders local mode. return Response.json( - { error: 'Not found. Valid paths: GET /health, GET /c/:id, POST /api/rooms, GET /ws/:id' }, + { error: 'Not found. Valid paths: GET /health, GET /c/:id, POST /api/rooms, GET /ws/:id, GET /assets/*' }, { status: 404, headers: cors }, ); } +/** + * Content Security Policy for the room HTML shell. + * + * Applied ONLY to the document response (/index.html), not to API or + * asset responses. The browser evaluates CSP from the document. + * + * Rationale for each directive: + * default-src 'self' — lockdown baseline + * script-src 'self' 'wasm-unsafe-eval' + * — Vite chunks + Graphviz WASM + * style-src 'self' 'unsafe-inline' https://fonts.googleapis.com + * — app CSS + Google Fonts + inline styles + * font-src 'self' https://fonts.gstatic.com + * — Google font files + * img-src 'self' https: data: blob: + * — icons, blob previews, and remote + * markdown document images (e.g. + * `![diagram](https://example/a.png)`) + * which Viewer renders as plain . + * connect-src 'self' ws://localhost:* ws://127.0.0.1:* ws://[::1]:* + * — same-origin Worker API/WebSocket + * + cross-port localhost dev WS + * worker-src 'self' blob: — defensive for libs using blob workers + * object-src 'none' — no plugins/objects + * base-uri 'none' — prevent tag injection + * frame-ancestors 'none' — no clickjacking/embedding + * form-action 'none' — no form submissions expected + */ +export const ROOM_CSP = [ + "default-src 'self'", + // 'wasm-unsafe-eval' needed for @viz-js/viz (Graphviz WASM build). + // NOT 'unsafe-eval' — only WebAssembly compilation is allowed. + "script-src 'self' 'wasm-unsafe-eval'", + "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com", + "font-src 'self' https://fonts.gstatic.com", + // Remote markdown document images (e.g. `![diagram](https://example/a.png)`) + // are a supported plan-content feature — Viewer renders them as plain + // ``. Allowing blanket `https:` here is a known + // tradeoff: an injected script could beacon via image URLs. Accepted + // because the product supports remote plan images, and the more + // exfil-capable channels (fetch / WebSocket) stay locked down via + // `connect-src 'self' + scoped localhost`. + // Annotation image attachments remain stripped before sending to the + // room (stripRoomAnnotationImages), so only document-level markdown + // images exercise this allowance. + "img-src 'self' https: data: blob:", + // Production: `'self'` covers the same-origin WebSocket + // (wss://room.plannotator.ai/ws/) per the CSP spec. + // + // Development: wrangler dev serves both the room shell and the + // WebSocket on the same localhost port, so `'self'` covers that + // too. Cross-port local dev (shell on one port, WebSocket on + // another) still needs explicit ws:// localhost entries. + // + // Blanket https: / ws: / wss: are intentionally omitted — + // widening the scheme would give any post-XSS injection an + // unrestricted exfiltration surface. + "connect-src 'self' ws://localhost:* ws://127.0.0.1:* ws://[::1]:*", + "worker-src 'self' blob:", + "object-src 'none'", + "base-uri 'none'", + "frame-ancestors 'none'", + "form-action 'none'", + // upgrade-insecure-requests is intentionally omitted because + // wrangler dev serves the shell + WebSocket over `ws://localhost`, + // and this directive rewrites ws:// → wss:// (which breaks local + // development). Production only makes same-origin wss:// + // connections, so the directive would be a no-op there anyway. +].join('; '); + +/** + * Fetch and serve /index.html from the Wrangler asset binding with the + * headers the room shell needs: CSP, CORS, no-store cache, + * Referrer-Policy, and an HTML content type. Falls back to a minimal + * inline HTML when ASSETS is unbound (local test environments that + * don't run Wrangler). + */ +async function serveIndexHtml( + request: Request, + env: Env, + cors: Record, +): Promise { + if (env.ASSETS) { + const assetUrl = new URL(request.url); + assetUrl.pathname = '/index.html'; + const assetReq = new Request(assetUrl, { method: 'GET', headers: request.headers }); + const assetRes = await env.ASSETS.fetch(assetReq); + const headers = new Headers(assetRes.headers); + for (const [k, v] of Object.entries(cors)) headers.set(k, v); + headers.set('Content-Security-Policy', ROOM_CSP); + headers.set('Referrer-Policy', 'no-referrer'); + headers.set('Content-Type', 'text/html; charset=utf-8'); + headers.set('Cache-Control', 'no-store'); + return new Response(assetRes.body, { status: assetRes.status, headers }); + } + // Fallback for local/test environments without an ASSETS binding. + return new Response( + `Plannotator Room

Room shell (test fallback; ASSETS binding unavailable)

`, + { + status: 200, + headers: { + ...cors, + 'Content-Security-Policy': ROOM_CSP, + 'Content-Type': 'text/html; charset=utf-8', + 'Referrer-Policy': 'no-referrer', + 'Cache-Control': 'no-store', + }, + }, + ); +} + // --------------------------------------------------------------------------- // Room Creation // @@ -162,8 +310,10 @@ async function handleWebSocket( roomId: string, cors: Record, ): Promise { - // Verify WebSocket upgrade header - if (request.headers.get('Upgrade') !== 'websocket') { + // Verify WebSocket upgrade header. RFC 6455 specifies the token + // is case-insensitive; browsers send lowercase but standards- + // conformant non-browser clients may send `WebSocket` or `WEBSOCKET`. + if (request.headers.get('Upgrade')?.toLowerCase() !== 'websocket') { return Response.json( { error: 'Expected WebSocket upgrade' }, { status: 426, headers: cors }, @@ -185,11 +335,3 @@ async function handleWebSocket( const stub = env.ROOM.get(id); return stub.fetch(request); } - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -function escapeHtml(str: string): string { - return str.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); -} diff --git a/apps/room-service/core/room-do.ts b/apps/room-service/core/room-do.ts index d45e41c5..c5ad1a41 100644 --- a/apps/room-service/core/room-do.ts +++ b/apps/room-service/core/room-do.ts @@ -1005,6 +1005,27 @@ export class RoomDurableObject extends DurableObject { const roomId = meta?.roomId ?? 'unknown'; const clientId = meta?.authenticated ? meta.clientId : 'unauthenticated'; safeLog('ws:closed', { roomId, clientId, code }); + + // Tell the remaining peers the closed client has left so they can + // drop that clientId's presence (cursor + avatar) immediately. + // Without this, peers wait out the 30s client-side TTL sweep, + // which made "refresh to test" pile up one ghost cursor per + // refresh until the entries expired. Only broadcast for + // authenticated sockets — unauth'd ones were never in peers' + // presence maps, so nothing needs cleanup. + // + // `exclude: ws` leaves the closing socket out of the fan-out. + // It may already be detached, but the broadcast's send-try/catch + // tolerates that either way. No payload beyond clientId — the + // protocol is zero-knowledge; we only relay opaque encrypted + // presence packets, and the clientId is server-assigned in the + // auth challenge so it's already non-secret. + if (meta?.authenticated) { + this.broadcast( + { type: 'room.participant.left', clientId: meta.clientId }, + ws, + ); + } } async webSocketError(ws: WebSocket, error: unknown): Promise { diff --git a/apps/room-service/core/types.ts b/apps/room-service/core/types.ts index 1ccf198e..8a3cedfd 100644 --- a/apps/room-service/core/types.ts +++ b/apps/room-service/core/types.ts @@ -15,6 +15,8 @@ import type { RoomStatus } from '@plannotator/shared/collab'; /** Cloudflare Worker environment bindings. */ export interface Env { ROOM: DurableObjectNamespace; + /** Wrangler-managed static asset binding. Serves `./public/index.html` (room shell) + hashed `./public/assets/*` chunks. Populated by `bun run build:shell`. */ + ASSETS?: { fetch(request: Request): Promise }; ALLOWED_ORIGINS?: string; ALLOW_LOCALHOST_ORIGINS?: string; BASE_URL?: string; diff --git a/apps/room-service/entry.tsx b/apps/room-service/entry.tsx new file mode 100644 index 00000000..2d954f1d --- /dev/null +++ b/apps/room-service/entry.tsx @@ -0,0 +1,36 @@ +/** + * Browser entry for the live-room Plannotator editor served at + * room.plannotator.ai/c/:roomId. + * + * AppRoot parses the URL via useRoomMode() and picks between + * (local mode — never reached on this origin by design) and + * . The same bundle would + * fall back to local mode on any non-room URL, but Cloudflare's Worker + * only routes /c/:roomId to this HTML, so room mode is the expected + * entry point. + * + * Unlike apps/hook (the local CLI-served binary, built with + * vite-plugin-singlefile so it can be embedded into a one-shot HTTP + * server), this build emits chunked assets. The Worker's `[assets]` + * binding + Cloudflare edge serves them with HTTP/2 multiplexing and + * per-chunk Brotli, so cold-start transfer is smaller than a singlefile + * blob and warm visits rehit cached hashed chunks. + */ + +import React from 'react'; +import { createRoot } from 'react-dom/client'; +import AppRoot from '@plannotator/editor'; +// @ts-expect-error — Vite resolves CSS side-effect imports at build time; +// there is no .d.ts for the index.css file and adding one would not match +// the existing apps/hook pattern. TypeScript doesn't need to analyze it. +import '@plannotator/editor/styles'; + +const root = document.getElementById('root'); +if (!root) { + throw new Error('Plannotator entry: #root element missing from index.html'); +} +createRoot(root).render( + + + , +); diff --git a/apps/room-service/index.html b/apps/room-service/index.html new file mode 100644 index 00000000..759a068d --- /dev/null +++ b/apps/room-service/index.html @@ -0,0 +1,24 @@ + + + + + + Plannotator + + + + + +
+ + + diff --git a/apps/room-service/package.json b/apps/room-service/package.json index 854f254c..e2785e82 100644 --- a/apps/room-service/package.json +++ b/apps/room-service/package.json @@ -3,15 +3,27 @@ "version": "0.1.0", "private": true, "scripts": { - "dev": "wrangler dev", - "deploy": "wrangler deploy", + "build:shell": "rm -rf public && vite build", + "dev": "bun run build:shell && wrangler dev", + "deploy": "bun run build:shell && wrangler deploy", "test": "bun test" }, "dependencies": { - "@plannotator/shared": "workspace:*" + "@plannotator/editor": "workspace:*", + "@plannotator/shared": "workspace:*", + "@plannotator/ui": "workspace:*", + "react": "^19.2.3", + "react-dom": "^19.2.3" }, "devDependencies": { "@cloudflare/workers-types": "^4.20241218.0", + "@tailwindcss/vite": "^4.1.18", + "@types/react": "^19.2.0", + "@types/react-dom": "^19.2.0", + "@vitejs/plugin-react": "^5.0.0", + "tailwindcss": "^4.1.18", + "typescript": "~5.8.2", + "vite": "^6.2.0", "wrangler": "^4.80.0" } } diff --git a/apps/room-service/scripts/fake-presence.ts b/apps/room-service/scripts/fake-presence.ts new file mode 100644 index 00000000..1211326f --- /dev/null +++ b/apps/room-service/scripts/fake-presence.ts @@ -0,0 +1,551 @@ +/** + * Fake-presence visual-test harness. + * + * Joins an existing Live Rooms room as N fake participants, each + * connecting through the real WebSocket + auth-proof path and + * emitting block-anchored encrypted presence at a configurable + * cadence. The room service treats them as ordinary peers; an + * observer's real browser tab sees participant-count bumps, avatar + * bubbles, moving cursor labels, edge indicators when offscreen, + * and clean disappearance on Ctrl-C (via the + * `room.participant.left` broadcast we ship on WebSocket close). + * + * Not a load test. Purpose is visual validation of the real room UI + * with many peers — "does the cluster still look right at 25? 50?" + * + * Usage: + * bun run room:fake-presence -- \ + * --url "http://localhost:8787/c/#key=..." \ + * --users 25 \ + * --blocks-file tmp/block-ids.txt + * + * Optional: + * --hz update rate per user (default 10) + * --blocks a,b,c inline comma-separated block ids + * --duration auto-stop after N seconds (default: run until Ctrl-C) + * + * The most convenient way to get real block IDs: open the room tab, + * run in DevTools console: + * copy([...document.querySelectorAll('[data-block-id]')] + * .map(el => el.dataset.blockId).filter(Boolean).join('\n')) + * then `pbpaste > tmp/block-ids.txt` and pass --blocks-file. + */ + +import { + parseRoomUrl, + deriveRoomKeys, + computeRoomVerifier, + computeAuthProof, + encryptPresence, + generateOpId, +} from '@plannotator/shared/collab/client'; + +import type { + AuthChallenge, + PresenceState, +} from '@plannotator/shared/collab'; + +import { readFileSync } from 'node:fs'; + +// --------------------------------------------------------------------------- +// Arg parsing +// --------------------------------------------------------------------------- + +interface Args { + url: string; + users: number; + hz: number; + blocks: string[]; + durationSec: number | null; +} + +function parseArgs(argv: readonly string[]): Args { + const args: Record = {}; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (!a.startsWith('--')) continue; + const key = a.slice(2); + const next = argv[i + 1]; + if (next === undefined || next.startsWith('--')) { + args[key] = 'true'; + } else { + args[key] = next; + i++; + } + } + + if (!args.url) { + fail('Missing --url. Pass the full room URL including the #key=... fragment.'); + } + + const users = Math.max(1, Math.min(500, Number(args.users ?? '10') | 0)); + const hz = Math.max(1, Math.min(60, Number(args.hz ?? '10') | 0)); + const durationSec = args.duration ? Math.max(1, Number(args.duration) | 0) : null; + + let blocks: string[] = []; + if (args['blocks-file']) { + try { + blocks = readFileSync(args['blocks-file'], 'utf8') + .split(/\r?\n/) + .map(s => s.trim()) + .filter(Boolean); + } catch (e) { + fail(`Failed to read --blocks-file: ${String(e)}`); + } + } else if (args.blocks) { + blocks = args.blocks.split(',').map(s => s.trim()).filter(Boolean); + } + + if (blocks.length === 0) { + console.warn( + '\n⚠ No --blocks / --blocks-file provided. Using placeholder block ids\n' + + ' (block-0 through block-9). If the real plan does not render those\n' + + ' ids, fake cursors will be invisible until you pass real ones.\n' + + ' Extract block ids from the room tab:\n' + + ' copy([...document.querySelectorAll(\'[data-block-id]\')]\n' + + ' .map(el => el.dataset.blockId).filter(Boolean).join(\'\\n\'))\n', + ); + blocks = Array.from({ length: 10 }, (_, i) => `block-${i}`); + } + + return { url: args.url, users, hz, blocks, durationSec }; +} + +function fail(msg: string): never { + console.error(`Error: ${msg}`); + process.exit(1); +} + +// --------------------------------------------------------------------------- +// Visual variety +// --------------------------------------------------------------------------- + +// Match the UI's swatch palette so fakes look like real participants. +// Duplicated here rather than imported from packages/ui to keep the +// script free of DOM-bound transitive deps. +const SWATCHES = [ + '#2563eb', '#f97316', '#10b981', '#ef4444', + '#8b5cf6', '#eab308', '#06b6d4', '#ec4899', +] as const; + +const NAMES = [ + 'Alpha', 'Bravo', 'Charlie', 'Delta', 'Echo', 'Foxtrot', + 'Golf', 'Hotel', 'India', 'Juliet', 'Kilo', 'Lima', + 'Mike', 'November', 'Oscar', 'Papa', 'Quebec', 'Romeo', + 'Sierra', 'Tango', 'Uniform', 'Victor', 'Whiskey', 'Xray', + 'Yankee', 'Zulu', +] as const; + +function pickName(i: number): string { + const base = NAMES[i % NAMES.length]; + const suffix = i < NAMES.length ? '' : ` ${Math.floor(i / NAMES.length) + 1}`; + return `${base}${suffix}`; +} + +function pickColor(i: number): string { + return SWATCHES[i % SWATCHES.length]; +} + +// --------------------------------------------------------------------------- +// Fake user +// --------------------------------------------------------------------------- + +/** + * Movement state per fake user. Models reading behavior: each bot + * cycles between a READING state (mostly still at one point, with + * tiny jitter like a resting hand on the mouse) and a MOVING state + * (a short eased traversal to a new point). Pauses are long relative + * to moves, matching how a person actually reads a document. + * + * Previous iteration used continuous sine waves on every axis, which + * read as a metronome — cursors orbiting the block in predictable + * loops. The state-machine model with discrete pauses + discrete + * moves looks meaningfully more human. + * + * Per-bot `ampX`/`ampY` scale the target-picking range so different + * bots reach different corners of the wander area. Permanent + * `homeX`/`homeY` for each bot — randomized at init across the + * whole wander width — keeps the crowd horizontally spread instead + * of collapsing into a vertical column. + */ +interface UserState { + id: string; + name: string; + color: string; + blockIdx: number; + /** + * Continuous-easing model: the bot's rendered position always + * lerps toward `targetX`/`targetY` at `lerpRate` per tick. Target + * updates periodically (every few seconds for drifts, shorter + * for reaches); because the lerp never pauses, there are no + * segment boundaries with zero velocity — the cursor keeps + * moving even when a new target is chosen mid-approach. + * + * Compared to discrete-segment models, this reads as smoother. + * Segments with cubic ease-in-out have velocity=0 at each end, + * which in a 10Hz stream the receiver can perceive as a micro- + * pulse at every boundary. Continuous lerp has no such boundary. + * + * `homeX`/`homeY` is a permanent per-bot home base, randomized + * at init across the full wander width — keeps the crowd + * horizontally spread instead of collapsing to a vertical + * column. Targets are picked relative to the home base, not to + * the previous target; picking relative to previous random-walks + * slowly and leaves a bot orbiting its start point. + */ + x: number; + y: number; + targetX: number; + targetY: number; + /** ms at which we should pick a new target (may be passed while still approaching current one). */ + nextTargetAt: number; + /** Per-bot lerp rate; drifts use a small rate (slow approach), reaches a larger rate (faster). */ + lerpRate: number; + homeX: number; + homeY: number; + ampX: number; + ampY: number; + /** When set and > now, emit cursor:null to simulate idle/away. */ + idleUntil: number; +} + +// Uniform across the top ~25% of the plan — enough top-skew to +// keep the action near the start of the document, while spreading +// bots evenly across every block in that window instead of piling +// them at blockIdx 0. +function pickStartingBlockIdx(blocksLength: number): number { + const range = Math.max(1, Math.floor(blocksLength * 0.25)); + return Math.floor(Math.random() * range); +} + +// Strong upward bias: soft ceiling at 20% of length. Inside the +// ceiling, steps barely favor forward; past the ceiling, bots lean +// very hard back toward the top so stragglers don't accumulate +// mid-document. +function pickTransitionStep(currentIdx: number, blocksLength: number): number { + const ceiling = Math.max(5, Math.floor(blocksLength * 0.2)); + const forwardBias = currentIdx < ceiling ? 0.4 : 0.05; + const magnitude = Math.random() < 0.2 ? 2 : 1; + const direction = Math.random() < forwardBias ? 1 : -1; + return magnitude * direction; +} + +interface FakeClient { + state: UserState; + ws: WebSocket; + authed: boolean; + clientId: string | null; + sends: number; + errors: number; +} + +// --------------------------------------------------------------------------- +// Movement +// --------------------------------------------------------------------------- + +function nextCursor( + state: UserState, + blocks: string[], + tNowMs: number, +): PresenceState['cursor'] { + // Idle simulation: cursor:null for a few seconds. + if (tNowMs < state.idleUntil) return null; + if (Math.random() < 0.0002) { + state.idleUntil = tNowMs + 3000 + Math.random() * 5000; + return null; + } + + // Time to pick a new target? (May happen mid-approach — that's + // fine, the cursor just re-aims and keeps easing. Continuous + // motion with occasional re-targeting reads smoother than discrete + // segments that zero-velocity at each boundary.) + if (tNowMs >= state.nextTargetAt) { + const doReach = Math.random() < 0.35; + if (doReach) { + // Reach: wider radius, faster approach, sometimes crosses + // into a neighbouring block. + if (Math.random() < 0.3) { + const step = pickTransitionStep(state.blockIdx, blocks.length); + state.blockIdx = (state.blockIdx + step + blocks.length) % blocks.length; + } + state.targetX = state.homeX + (Math.random() - 0.5) * 280 * state.ampX; + state.targetY = state.homeY + (Math.random() - 0.5) * 140 * state.ampY; + state.lerpRate = 0.16 + Math.random() * 0.06; // 0.16–0.22, fast + state.nextTargetAt = tNowMs + 900 + Math.random() * 1100; + } else { + // Drift: medium-radius target, moderate approach. Picked + // relative to the PERMANENT home base so drifts can land + // anywhere across the wander area instead of random-walking + // near the previous position. + state.targetX = state.homeX + (Math.random() - 0.5) * 180 * state.ampX; + state.targetY = state.homeY + (Math.random() - 0.5) * 90 * state.ampY; + state.lerpRate = 0.07 + Math.random() * 0.04; // 0.07–0.11 + state.nextTargetAt = tNowMs + 1500 + Math.random() * 1800; + } + } + + // Continuous ease toward current target. Each tick moves by a + // fraction of the remaining distance — produces a smooth + // exponential-decay approach with no velocity pulse at target + // changes. Receiver's own lerp (α=0.3) composes on top. + state.x += (state.targetX - state.x) * state.lerpRate; + state.y += (state.targetY - state.y) * state.lerpRate; + + return { + coordinateSpace: 'block', + blockId: blocks[state.blockIdx], + x: state.x, + y: state.y, + }; +} + +// --------------------------------------------------------------------------- +// WebSocket connect + auth +// --------------------------------------------------------------------------- + +async function connectAndAuth( + wsBase: string, + roomId: string, + roomVerifier: string, + state: UserState, +): Promise { + return new Promise((resolve, reject) => { + const ws = new WebSocket(`${wsBase}/ws/${roomId}`); + const client: FakeClient = { + state, + ws, + authed: false, + clientId: null, + sends: 0, + errors: 0, + }; + + const timeout = setTimeout(() => { + if (!client.authed) { + try { ws.close(); } catch { /* ignore */ } + reject(new Error(`auth timeout for ${state.name}`)); + } + }, 10_000); + + ws.onmessage = async (event) => { + let msg: { type?: string } & Record; + try { + msg = JSON.parse(String(event.data)); + } catch { + return; + } + + if (!client.authed && msg.type === 'auth.challenge') { + const challenge = msg as unknown as AuthChallenge; + const proof = await computeAuthProof( + roomVerifier, + roomId, + challenge.clientId, + challenge.challengeId, + challenge.nonce, + ); + client.clientId = challenge.clientId; + ws.send(JSON.stringify({ + type: 'auth.response', + challengeId: challenge.challengeId, + clientId: challenge.clientId, + proof, + })); + return; + } + + if (!client.authed && msg.type === 'auth.accepted') { + client.authed = true; + clearTimeout(timeout); + // Discard all subsequent inbound — fakes don't need to + // decrypt peer traffic, just emit their own presence. + ws.onmessage = () => {}; + resolve(client); + return; + } + }; + + ws.onerror = () => { + if (!client.authed) { + clearTimeout(timeout); + reject(new Error(`WebSocket error during auth for ${state.name}`)); + } else { + client.errors++; + } + }; + ws.onclose = () => { + // If we close before authenticating, surface as reject. + if (!client.authed) { + clearTimeout(timeout); + reject(new Error(`socket closed before auth for ${state.name}`)); + } + }; + }); +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +async function main(): Promise { + const args = parseArgs(process.argv.slice(2)); + const parsed = parseRoomUrl(args.url); + if (!parsed) fail('Could not parse --url. Expected .../c/#key='); + + const { roomId, roomSecret } = parsed; + const origin = new URL(args.url).origin; + const wsBase = origin.replace(/^http/, 'ws'); + + const { authKey, presenceKey } = await deriveRoomKeys(roomSecret); + const roomVerifier = await computeRoomVerifier(authKey, roomId); + + console.log( + `\nFake presence:\n` + + ` origin ${origin}\n` + + ` roomId ${roomId}\n` + + ` users ${args.users}\n` + + ` hz ${args.hz}\n` + + ` blocks ${args.blocks.length}\n` + + ` duration ${args.durationSec ? `${args.durationSec}s` : 'until Ctrl-C'}\n`, + ); + + // Connect all fakes. Stagger slightly so the server doesn't get a + // thundering-herd of auth handshakes on the same tick. + const clients: FakeClient[] = []; + const connectPromises: Promise[] = []; + for (let i = 0; i < args.users; i++) { + // Permanent home base randomized across the whole wander area, + // so the crowd is spread horizontally rather than clustering + // in a narrow start band. + const ampX = 0.7 + Math.random() * 0.7; + const ampY = 0.7 + Math.random() * 0.7; + const homeX = 160 + (Math.random() - 0.5) * 280; + const homeY = 35 + (Math.random() - 0.5) * 100; + + // Initial position: near home base with some offset so bots + // don't all start at identical coords. + const startX = homeX + (Math.random() - 0.5) * 100; + const startY = homeY + (Math.random() - 0.5) * 50; + + const state: UserState = { + id: `fake-${i}-${Date.now().toString(36)}`, + name: pickName(i), + color: pickColor(i), + blockIdx: pickStartingBlockIdx(Math.max(1, args.blocks.length)), + x: startX, + y: startY, + targetX: homeX + (Math.random() - 0.5) * 180 * ampX, + targetY: homeY + (Math.random() - 0.5) * 90 * ampY, + // Staggered first target so bots don't all re-target on the + // same tick — without this, the whole crowd would seek a new + // destination in unison every couple of seconds. + nextTargetAt: Date.now() + Math.random() * 3000, + lerpRate: 0.07 + Math.random() * 0.04, // begin in drift mode + homeX, + homeY, + ampX, + ampY, + idleUntil: 0, + }; + await new Promise(r => setTimeout(r, 30)); // 30ms stagger + connectPromises.push( + connectAndAuth(wsBase, roomId, roomVerifier, state) + .then(c => { clients.push(c); return c; }) + .catch(err => { + console.error(` [${state.name}] connect failed: ${err.message}`); + return null as unknown as FakeClient; + }), + ); + } + await Promise.all(connectPromises); + + const connected = clients.length; + if (connected === 0) { + fail('No fake clients authenticated. Check the URL and that the room service is running.'); + } + console.log(` connected ${connected}/${args.users}\n`); + + // Presence send loop. One interval per-user to stagger naturally — + // single global interval would fire all N sends on the same tick, + // wasting throughput and making the wire look synthetic. + const intervalMs = Math.round(1000 / args.hz); + const sendTimers: ReturnType[] = []; + for (const client of clients) { + const jitter = Math.random() * intervalMs; + setTimeout(() => { + const t = setInterval(async () => { + if (!client.authed || client.ws.readyState !== 1 /* OPEN */) return; + try { + const cursor = nextCursor(client.state, args.blocks, Date.now()); + const presence: PresenceState = { + user: { + id: client.state.id, + name: client.state.name, + color: client.state.color, + }, + cursor, + }; + const ciphertext = await encryptPresence(presenceKey, presence); + client.ws.send(JSON.stringify({ + clientId: client.clientId, + opId: generateOpId(), + channel: 'presence', + ciphertext, + })); + client.sends++; + } catch (err) { + client.errors++; + if (client.errors <= 3) { + console.error(` [${client.state.name}] send error: ${String(err)}`); + } + } + }, intervalMs); + sendTimers.push(t); + }, jitter); + } + + // Stats every second. + const startAt = Date.now(); + let lastSends = 0; + const statsTimer = setInterval(() => { + const totalSends = clients.reduce((n, c) => n + c.sends, 0); + const totalErrors = clients.reduce((n, c) => n + c.errors, 0); + const alive = clients.filter(c => c.ws.readyState === 1).length; + const deltaSends = totalSends - lastSends; + lastSends = totalSends; + process.stdout.write( + `\r alive ${alive}/${connected} sends/s ${deltaSends} errors ${totalErrors} `, + ); + }, 1000); + + // Graceful shutdown. + const shutdown = () => { + process.stdout.write('\n\nShutting down fake participants…\n'); + clearInterval(statsTimer); + for (const t of sendTimers) clearInterval(t); + for (const c of clients) { + try { c.ws.close(); } catch { /* ignore */ } + } + // Give the server a beat to fan out the `room.participant.left` + // broadcasts the real observer tab relies on for clean bubble + // removal — without this, we'd exit before sendmsg completes for + // the close frames. + setTimeout(() => process.exit(0), 300); + }; + process.on('SIGINT', shutdown); + process.on('SIGTERM', shutdown); + + if (args.durationSec) { + setTimeout(shutdown, args.durationSec * 1000); + } + + // Keep the event loop alive — setInterval already does this, but + // belt-and-braces for platforms where timers alone don't hold the + // process open. + setInterval(() => {}, 1 << 30); +} + +main().catch(err => { + console.error(`\nFatal: ${String(err)}`); + process.exit(1); +}); diff --git a/apps/room-service/static/favicon.svg b/apps/room-service/static/favicon.svg new file mode 100644 index 00000000..070e83e2 --- /dev/null +++ b/apps/room-service/static/favicon.svg @@ -0,0 +1,5 @@ + + + + P + diff --git a/apps/room-service/tsconfig.browser.json b/apps/room-service/tsconfig.browser.json new file mode 100644 index 00000000..722f5e0f --- /dev/null +++ b/apps/room-service/tsconfig.browser.json @@ -0,0 +1,23 @@ +{ + "//": "Aspirational typecheck for the browser entry. NOT currently a green CI gate — TypeScript follows the AppRoot import into packages/editor + packages/ui, which surface pre-existing implicit-any, missing @types/react, CSS module, and __APP_VERSION__ errors from the broader codebase. Those are shared-package tech-debt items, not room-service issues. Run via: bunx tsc --noEmit -p apps/room-service/tsconfig.browser.json. Entry.tsx itself is clean; the transitive errors are inherited. Once the shared packages carry their own tsconfig with strict: false or the missing declarations are added, this config becomes a usable gate.", + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "allowImportingTsExtensions": true, + "isolatedModules": true, + "paths": { + "@plannotator/ui/*": ["../../packages/ui/*"], + "@plannotator/editor": ["../../packages/editor/AppRoot.tsx"], + "@plannotator/editor/App": ["../../packages/editor/App.tsx"], + "@plannotator/editor/*": ["../../packages/editor/*"], + "@plannotator/editor/styles": ["../../packages/editor/index.css"] + } + }, + "include": ["entry.tsx", "vite.config.ts"] +} diff --git a/apps/room-service/tsconfig.json b/apps/room-service/tsconfig.json index 9ce4372e..28fc62cc 100644 --- a/apps/room-service/tsconfig.json +++ b/apps/room-service/tsconfig.json @@ -10,5 +10,6 @@ "isolatedModules": true, "types": ["@cloudflare/workers-types"] }, - "exclude": ["**/*.test.ts", "scripts/**"] + "//exclude": "entry.tsx and vite.config.ts are browser-only and typechecked separately via tsconfig.browser.json (with DOM + React libs). This Worker config stays Cloudflare-scoped.", + "exclude": ["**/*.test.ts", "scripts/**", "entry.tsx", "vite.config.ts"] } diff --git a/apps/room-service/vite.config.ts b/apps/room-service/vite.config.ts new file mode 100644 index 00000000..4ec81fe3 --- /dev/null +++ b/apps/room-service/vite.config.ts @@ -0,0 +1,67 @@ +/** + * Vite config for the Cloudflare-served live-room editor. + * + * Opposite constraints from apps/hook: + * - apps/hook: single-file HTML, embedded into a Bun binary that + * streams it over a one-shot localhost HTTP server. Uses + * vite-plugin-singlefile, inlineDynamicImports, and + * assetsInlineLimit=∞ to produce a standalone blob. + * - apps/room-service: served by Cloudflare's [assets] binding. + * Emits normal chunked output (hashed assets/*.js, *.css). + * Wrangler + Cloudflare edge do HTTP/2 multiplexing, Brotli, + * per-chunk edge caching, and immutable Cache-Control on hashed + * assets. Single-file would defeat all of that. + * + * Aliases mirror apps/hook: @plannotator/editor → AppRoot.tsx so the + * default import is room-mode-aware; @plannotator/editor/App remains + * available for callers that explicitly want the local shell. + */ + +import path from 'path'; +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import tailwindcss from '@tailwindcss/vite'; +import pkg from '../../package.json'; + +export default defineConfig({ + base: '/', + define: { + __APP_VERSION__: JSON.stringify(pkg.version), + }, + plugins: [react(), tailwindcss()], + resolve: { + alias: { + '@plannotator/ui': path.resolve(__dirname, '../../packages/ui'), + '@plannotator/editor/styles': path.resolve(__dirname, '../../packages/editor/index.css'), + '@plannotator/editor/App': path.resolve(__dirname, '../../packages/editor/App.tsx'), + '@plannotator/editor': path.resolve(__dirname, '../../packages/editor/AppRoot.tsx'), + }, + }, + // Static assets (favicon.svg) are copied verbatim from ./static/ into + // the build output root. Vite's default publicDir is 'public' but our + // outDir is also 'public' — using a separate 'static' avoids the + // "publicDir and outDir overlap" warning. + publicDir: 'static', + build: { + outDir: 'public', + emptyOutDir: true, + target: 'esnext', + // No singlefile, no inlineDynamicImports, no bloated + // assetsInlineLimit — default Vite chunk shape is what Cloudflare + // wants. Hashed filenames in assets/ allow indefinite caching with + // the handler's immutable Cache-Control header. + rollupOptions: { + output: { + // Keep the Vite default naming: [name]-[hash].js under assets/, + // which the handler's /assets/* passthrough serves verbatim. + }, + }, + }, + server: { + // Not used for deploy — just for local `vite` dev if someone wants + // to iterate on the room UI without Wrangler. The Worker still + // serves the compiled output. + port: 3002, + host: '0.0.0.0', + }, +}); diff --git a/apps/room-service/wrangler.toml b/apps/room-service/wrangler.toml index 3f8fbf7b..7ff3a020 100644 --- a/apps/room-service/wrangler.toml +++ b/apps/room-service/wrangler.toml @@ -2,6 +2,16 @@ name = "plannotator-room" main = "targets/cloudflare.ts" compatibility_date = "2024-12-01" +# Chunked Vite build output lives in ./public (index.html + hashed +# assets/*.js, *.css). Produced by `bun run build:shell` from +# apps/room-service/vite.config.ts. The directory is gitignored — +# always generated, never checked in. The Worker's handler rewrites +# /c/:roomId → /index.html and /assets/* → passthrough with long-lived +# immutable Cache-Control on hashed chunks; see core/handler.ts. +[assets] +directory = "./public" +binding = "ASSETS" + [[durable_objects.bindings]] name = "ROOM" class_name = "RoomDurableObject" diff --git a/bun.lock b/bun.lock index 64728ead..d7331682 100644 --- a/bun.lock +++ b/bun.lock @@ -139,10 +139,21 @@ "name": "@plannotator/room-service", "version": "0.1.0", "dependencies": { + "@plannotator/editor": "workspace:*", "@plannotator/shared": "workspace:*", + "@plannotator/ui": "workspace:*", + "react": "^19.2.3", + "react-dom": "^19.2.3", }, "devDependencies": { "@cloudflare/workers-types": "^4.20241218.0", + "@tailwindcss/vite": "^4.1.18", + "@types/react": "^19.2.0", + "@types/react-dom": "^19.2.0", + "@vitejs/plugin-react": "^5.0.0", + "tailwindcss": "^4.1.18", + "typescript": "~5.8.2", + "vite": "^6.2.0", "wrangler": "^4.80.0", }, }, @@ -171,6 +182,12 @@ "react-dom": "^19.2.3", "tailwindcss": "^4.1.18", }, + "devDependencies": { + "@happy-dom/global-registrator": "^20.8.9", + "@testing-library/react": "^16.3.2", + "@types/react": "^19.2.0", + "@types/react-dom": "^19.2.0", + }, }, "packages/review-editor": { "name": "@plannotator/review-editor", @@ -221,6 +238,11 @@ "react-dom": "^19.2.3", "unique-username-generator": "^1.5.1", }, + "devDependencies": { + "@happy-dom/global-registrator": "^20.8.9", + "@testing-library/react": "^16.3.2", + "happy-dom": "^20.8.9", + }, }, }, "packages": { @@ -478,6 +500,8 @@ "@google/genai": ["@google/genai@1.42.0", "", { "dependencies": { "google-auth-library": "^10.3.0", "p-retry": "^4.6.2", "protobufjs": "^7.5.4", "ws": "^8.18.0" }, "peerDependencies": { "@modelcontextprotocol/sdk": "^1.25.2" }, "optionalPeers": ["@modelcontextprotocol/sdk"] }, "sha512-+3nlMTcrQufbQ8IumGkOphxD5Pd5kKyJOzLcnY0/1IuE8upJk5aLmoexZ2BJhBp1zAjRJMEB4a2CJwKI9e2EYw=="], + "@happy-dom/global-registrator": ["@happy-dom/global-registrator@20.8.9", "", { "dependencies": { "@types/node": ">=20.0.0", "happy-dom": "^20.8.9" } }, "sha512-DtZeRRHY9A/bisTJziUBBPrdnPui7+R185G/hzi6/Boymhqh7/wi53AY+IvQHS1+7OPaqfO/1XNpngNwthLz+A=="], + "@hono/node-server": ["@hono/node-server@1.19.11", "", { "peerDependencies": { "hono": "^4" } }, "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g=="], "@iconify/types": ["@iconify/types@2.0.0", "", {}, "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg=="], @@ -920,6 +944,10 @@ "@tailwindcss/vite": ["@tailwindcss/vite@4.1.18", "", { "dependencies": { "@tailwindcss/node": "4.1.18", "@tailwindcss/oxide": "4.1.18", "tailwindcss": "4.1.18" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA=="], + "@testing-library/dom": ["@testing-library/dom@10.4.1", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="], + + "@testing-library/react": ["@testing-library/react@16.3.2", "", { "dependencies": { "@babel/runtime": "^7.12.5" }, "peerDependencies": { "@testing-library/dom": "^10.0.0", "@types/react": "^18.0.0 || ^19.0.0", "@types/react-dom": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g=="], + "@textlint/ast-node-types": ["@textlint/ast-node-types@15.5.2", "", {}, "sha512-fCaOxoup5LIyBEo7R1oYWE7V4bSX0KQeHh66twon9e9usaLE3ijgF8QjYsR6joCssdeCHVd0wHm7ppsEyTr6vg=="], "@textlint/linter-formatter": ["@textlint/linter-formatter@15.5.2", "", { "dependencies": { "@azu/format-text": "^1.0.2", "@azu/style-format": "^1.0.1", "@textlint/module-interop": "15.5.2", "@textlint/resolver": "15.5.2", "@textlint/types": "15.5.2", "chalk": "^4.1.2", "debug": "^4.4.3", "js-yaml": "^4.1.1", "lodash": "^4.17.23", "pluralize": "^2.0.0", "string-width": "^4.2.3", "strip-ansi": "^6.0.1", "table": "^6.9.0", "text-table": "^0.2.0" } }, "sha512-jAw7jWM8+wU9cG6Uu31jGyD1B+PAVePCvnPKC/oov+2iBPKk3ao30zc/Itmi7FvXo4oPaL9PmzPPQhyniPVgVg=="], @@ -936,6 +964,8 @@ "@tootallnate/quickjs-emscripten": ["@tootallnate/quickjs-emscripten@0.23.0", "", {}, "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA=="], + "@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="], + "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], "@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="], @@ -1052,6 +1082,10 @@ "@types/vscode": ["@types/vscode@1.109.0", "", {}, "sha512-0Pf95rnwEIwDbmXGC08r0B4TQhAbsHQ5UyTIgVgoieDe4cOnf92usuR5dEczb6bTKEp7ziZH4TV1TRGPPCExtw=="], + "@types/whatwg-mimetype": ["@types/whatwg-mimetype@3.0.2", "", {}, "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="], + + "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], + "@typespec/ts-http-runtime": ["@typespec/ts-http-runtime@0.3.3", "", { "dependencies": { "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.0", "tslib": "^2.6.2" } }, "sha512-91fp6CAAJSRtH5ja95T1FHSKa8aPW9/Zw6cta81jlZTUw/+Vq8jM/AfF/14h2b71wwR84JUTW/3Y8QPhDAawFA=="], "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="], @@ -1100,7 +1134,7 @@ "ansi-escapes": ["ansi-escapes@7.3.0", "", { "dependencies": { "environment": "^1.0.0" } }, "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg=="], - "ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], + "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], @@ -1402,6 +1436,8 @@ "dockview-react": ["dockview-react@5.2.0", "", { "dependencies": { "dockview": "^5.2.0" } }, "sha512-xJU5EiViiYYoP0ez5KxN8I+5CWSiPC27KVgVJBpRYRYJN6wKjMUpUqqSHwTlN1PGw5OzCu7UGQlUl1RQew74ag=="], + "dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="], + "dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="], "domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="], @@ -1436,7 +1472,7 @@ "enhanced-resolve": ["enhanced-resolve@5.18.4", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q=="], - "entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], + "entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], "environment": ["environment@1.1.0", "", {}, "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q=="], @@ -1596,6 +1632,8 @@ "hachure-fill": ["hachure-fill@0.5.2", "", {}, "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg=="], + "happy-dom": ["happy-dom@20.8.9", "", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.18.3" } }, "sha512-Tz23LR9T9jOGVZm2x1EPdXqwA37G/owYMxRwU0E4miurAtFsPMQ1d2Jc2okUaSjZqAFz2oEn3FLXC5a0a+siyA=="], + "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], @@ -1806,6 +1844,8 @@ "lru_map": ["lru_map@0.4.1", "", {}, "sha512-I+lBvqMMFfqaV8CJCISjI3wbjmwVu/VyOoU7+qtu9d7ioW5klMgsTTiUOUp+DJvfTTzKXoPbyC6YfgkNcyPSOg=="], + "lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="], + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], "magicast": ["magicast@0.5.2", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "source-map-js": "^1.2.1" } }, "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ=="], @@ -2100,6 +2140,8 @@ "prebuild-install": ["prebuild-install@7.1.3", "", { "dependencies": { "detect-libc": "^2.0.0", "expand-template": "^2.0.3", "github-from-package": "0.0.0", "minimist": "^1.2.3", "mkdirp-classic": "^0.5.3", "napi-build-utils": "^2.0.0", "node-abi": "^3.3.0", "pump": "^3.0.0", "rc": "^1.2.7", "simple-get": "^4.0.0", "tar-fs": "^2.0.0", "tunnel-agent": "^0.6.0" }, "bin": { "prebuild-install": "bin.js" } }, "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug=="], + "pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="], + "printable-characters": ["printable-characters@1.0.42", "", {}, "sha512-dKp+C4iXWK4vVYZmYSd0KBH5F/h1HoZRsbJ82AVKRO3PEo8L4lBS/vLwhVtpwwuYcoIsVY+1JYKR268yn480uQ=="], "prismjs": ["prismjs@1.30.0", "", {}, "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw=="], @@ -2140,6 +2182,8 @@ "react-dom": ["react-dom@19.2.3", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.3" } }, "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg=="], + "react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="], + "react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="], "read": ["read@1.0.7", "", { "dependencies": { "mute-stream": "~0.0.4" } }, "sha512-rSOKNYUmaxy0om1BNjMN4ezNT6VKK+2xF4GBhc81mkH7L60i6dp8qPYrkndNLT3QPphoII3maL9PVC9XmhHwVQ=="], @@ -2496,7 +2540,7 @@ "whatwg-encoding": ["whatwg-encoding@3.1.1", "", { "dependencies": { "iconv-lite": "0.6.3" } }, "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ=="], - "whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="], + "whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="], "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], @@ -2514,7 +2558,7 @@ "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], - "ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="], + "ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="], "wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="], @@ -2594,8 +2638,6 @@ "@cspotcode/source-map-support/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="], - "@google/genai/ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="], - "@mariozechner/pi-ai/@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.73.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-URURVzhxXGJDGUGFunIOtBlSl7KWvZiAAKY/ttTkZAkXT9bTPqdk2eK0b8qqSxXpikh3QKPnPYpiyX98zf5ebw=="], "@mariozechner/pi-ai/undici": ["undici@7.22.0", "", {}, "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg=="], @@ -2638,6 +2680,8 @@ "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "@testing-library/dom/aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="], + "@textlint/linter-formatter/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "@textlint/linter-formatter/pluralize": ["pluralize@2.0.0", "", {}, "sha512-TqNZzQCD4S42De9IfnnBvILN7HAW7riLqsCyp8lgjXeysyPlX5HhqKAcJHHHb9XskE4/a+7VGC9zzx8Ls0jOAw=="], @@ -2674,6 +2718,8 @@ "cheerio/undici": ["undici@7.22.0", "", {}, "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg=="], + "cheerio/whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="], + "chevrotain/lodash-es": ["lodash-es@4.17.21", "", {}, "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw=="], "cli-highlight/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], @@ -2698,6 +2744,8 @@ "d3-sankey/d3-shape": ["d3-shape@1.3.7", "", { "dependencies": { "d3-path": "1" } }, "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw=="], + "dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], + "encoding-sniffer/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], "escodegen/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], @@ -2716,14 +2764,14 @@ "hast-util-raw/parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], - "htmlparser2/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], - "katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="], "magicast/@babel/parser": ["@babel/parser@7.29.0", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww=="], "magicast/@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + "markdown-it/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], + "mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], "mermaid/dompurify": ["dompurify@3.3.1", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q=="], @@ -2734,6 +2782,8 @@ "miniflare/acorn": ["acorn@8.14.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA=="], + "miniflare/ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="], + "miniflare/zod": ["zod@3.22.3", "", {}, "sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug=="], "node-fetch/data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="], @@ -2750,6 +2800,8 @@ "parse5-parser-stream/parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], + "pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], + "protobufjs/@types/node": ["@types/node@22.19.3", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA=="], "proxy-agent/lru-cache": ["lru-cache@7.18.3", "", {}, "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA=="], @@ -2772,7 +2824,7 @@ "string-width-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], - "strip-ansi-cjs/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], "svgo/commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="], @@ -2840,8 +2892,6 @@ "@textlint/linter-formatter/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], - "@textlint/linter-formatter/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - "@types/sax/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], "@vscode/vsce/glob/minimatch": ["minimatch@10.2.2", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw=="], @@ -2910,8 +2960,6 @@ "cliui/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], - "cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - "csso/css-tree/mdn-data": ["mdn-data@2.0.28", "", {}, "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g=="], "cytoscape-fcose/cose-base/layout-base": ["layout-base@2.0.1", "", {}, "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg=="], @@ -2938,12 +2986,8 @@ "rimraf/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="], - "string-width-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - "table/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], - "table/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - "vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], "vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], @@ -3078,8 +3122,6 @@ "wrap-ansi-cjs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], - "wrap-ansi-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - "yargs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], "yargs/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], @@ -3144,6 +3186,8 @@ "@plannotator/room-service/wrangler/miniflare/undici": ["undici@7.24.4", "", {}, "sha512-BM/JzwwaRXxrLdElV2Uo6cTLEjhSb3WXboncJamZ15NgUURmvlXvxa6xkwIOILIjPNo9i8ku136ZvWV0Uly8+w=="], + "@plannotator/room-service/wrangler/miniflare/ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="], + "@plannotator/room-service/wrangler/miniflare/youch": ["youch@4.1.0-beta.10", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@poppinss/dumper": "^0.6.4", "@speed-highlight/core": "^1.2.7", "cookie": "^1.0.2", "youch-core": "^0.3.3" } }, "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ=="], "@plannotator/room-service/wrangler/workerd/@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260405.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-EbmdBcmeIGogKG4V1odSWQe7z4rHssUD4iaXv0cXA22/MFrzH3iQT0R+FJFyhucGtih/9B9E+6j0QbSQD8xT3w=="], @@ -3158,14 +3202,10 @@ "@vscode/vsce/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], - "ansi-align/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - "rimraf/glob/jackspeak/@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="], "rimraf/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], - "yargs/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - "rimraf/glob/jackspeak/@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], "rimraf/glob/jackspeak/@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="], diff --git a/package.json b/package.json index bb8350bd..afc39e86 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,8 @@ "dev:marketing": "bun run --cwd apps/marketing dev", "dev:review": "bun run --cwd apps/review dev", "dev:room": "bun run --cwd apps/room-service dev", + "dev:live-room": "scripts/dev-live-room-local.sh", + "room:fake-presence": "bun run apps/room-service/scripts/fake-presence.ts", "build:hook": "bun run --cwd apps/hook build", "build:portal": "bun run --cwd apps/portal build", "build:marketing": "bun run --cwd apps/marketing build", @@ -30,8 +32,8 @@ "dev:vscode": "bun run --cwd apps/vscode-extension watch", "build:vscode": "bun run --cwd apps/vscode-extension build", "package:vscode": "bun run --cwd apps/vscode-extension package", - "test": "bun test", - "typecheck": "bunx tsc --noEmit -p packages/shared/tsconfig.json && bunx tsc --noEmit -p packages/ai/tsconfig.json && bunx tsc --noEmit -p packages/server/tsconfig.json && bunx tsc --noEmit -p packages/ui/tsconfig.collab.json" + "test": "bun test --path-ignore-patterns 'packages/ui/**' --path-ignore-patterns 'packages/editor/**' && bun test --cwd packages/ui && bun test --cwd packages/editor", + "typecheck": "bunx tsc --noEmit -p packages/shared/tsconfig.json && bunx tsc --noEmit -p packages/ai/tsconfig.json && bunx tsc --noEmit -p packages/server/tsconfig.json && bunx tsc --noEmit -p packages/ui/tsconfig.slice5.json && bunx tsc --noEmit -p packages/editor/tsconfig.json" }, "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.2.92", diff --git a/packages/editor/App.tsx b/packages/editor/App.tsx index 5425d145..0168d23e 100644 --- a/packages/editor/App.tsx +++ b/packages/editor/App.tsx @@ -19,6 +19,12 @@ import { getCallbackConfig, CallbackAction, executeCallback, type ToastPayload } import { useAgents } from '@plannotator/ui/hooks/useAgents'; import { useActiveSection } from '@plannotator/ui/hooks/useActiveSection'; import { storage } from '@plannotator/ui/utils/storage'; +import { + getIdentity, + setCustomIdentity, + getPresenceColor, + setPresenceColor, +} from '@plannotator/ui/utils/identity'; import { configStore } from '@plannotator/ui/config'; import { CompletionOverlay } from '@plannotator/ui/components/CompletionOverlay'; import { UpdateBanner } from '@plannotator/ui/components/UpdateBanner'; @@ -55,6 +61,11 @@ import { useArchive } from '@plannotator/ui/hooks/useArchive'; import { useEditorAnnotations } from '@plannotator/ui/hooks/useEditorAnnotations'; import { useExternalAnnotations } from '@plannotator/ui/hooks/useExternalAnnotations'; import { useExternalAnnotationHighlights } from '@plannotator/ui/hooks/useExternalAnnotationHighlights'; +import { useAnnotationHighlightReconciler } from '@plannotator/ui/hooks/useAnnotationHighlightReconciler'; +import { useRoomAdminActions } from '@plannotator/ui/hooks/useRoomAdminActions'; +import { RoomHeaderControls } from '@plannotator/ui/components/collab/RoomHeaderControls'; +import { RoomAdminErrorToast } from '@plannotator/ui/components/collab/RoomAdminErrorToast'; +import { ImageStripNotice } from '@plannotator/ui/components/collab/ImageStripNotice'; import { buildPlanAgentInstructions } from '@plannotator/ui/utils/planAgentInstructions'; import { hasNewSettings, markNewSettingsSeen } from '@plannotator/ui/utils/newSettingsHint'; import { useFileBrowser } from '@plannotator/ui/hooks/useFileBrowser'; @@ -67,6 +78,9 @@ import { PlanDiffViewer } from '@plannotator/ui/components/plan-diff/PlanDiffVie import type { PlanDiffMode } from '@plannotator/ui/components/plan-diff/PlanDiffModeSwitcher'; import { DEMO_PLAN_CONTENT } from './demoPlan'; import { useCheckboxOverrides } from './hooks/useCheckboxOverrides'; +import { useAnnotationController } from '@plannotator/ui/hooks/useAnnotationController'; +import { StartRoomModal, type StartRoomSubmit } from '@plannotator/ui/components/collab/StartRoomModal'; +import { stripRoomAnnotationImages } from '@plannotator/shared/collab'; type NoteAutoSaveResults = { obsidian?: boolean; @@ -74,9 +88,113 @@ type NoteAutoSaveResults = { octarine?: boolean; }; -const App: React.FC = () => { - const [markdown, setMarkdown] = useState(DEMO_PLAN_CONTENT); - const [annotations, setAnnotations] = useState([]); +export interface AppProps { + /** + * When provided, the editor runs in room mode: annotation mutations + * route through the room client and the `/api/plan` fetch is + * skipped (the plan arrives from the encrypted room snapshot + * instead). Approve and Deny are local-only — they are never + * offered in room mode — so passing this prop does not change the + * approve/deny flow. + * + * AppRoot is the only caller that should pass this; plain + * consumers mounting `` get local mode unchanged. + */ + roomSession?: import('@plannotator/ui/hooks/useCollabRoomSession').UseCollabRoomSessionReturn; +} + +/** + * Resolve the room-service base URL for `createRoom()`. Precedence: + * + * 1. `window.__ROOM_BASE_URL` — runtime escape hatch. Set via + * DevTools console for ad-hoc redirection without restarting + * the dev server. + * 2. `import.meta.env.VITE_ROOM_BASE_URL` — build/dev-time env + * var, the standard Vite pattern. `scripts/dev-live-room-local.sh` + * sets this so the editor at :3000 targets the local wrangler + * dev at :8787 instead of production. + * 3. `https://room.plannotator.ai` — production default; what + * every shipped build should resolve to when neither override + * is present. + * + * Declared as a module-level helper rather than inlined in + * `handleConfirmStartRoom` so local E2E testing doesn't require + * hand-setting a window global before every click. + */ +function getRoomBaseUrl(): string { + if (typeof window !== 'undefined') { + const explicit = (window as { __ROOM_BASE_URL?: string }).__ROOM_BASE_URL; + if (explicit) return explicit; + } + const viteBase = import.meta.env?.VITE_ROOM_BASE_URL; + if (viteBase) return viteBase; + return 'https://room.plannotator.ai'; +} + +const App: React.FC = ({ roomSession }) => { + const roomModeActive = !!roomSession?.room; + const [markdown, setMarkdown] = useState( + roomModeActive ? '' : DEMO_PLAN_CONTENT, + ); + + // Room admin actions: lock / unlock / delete. Pending + error state + // live here (not in `RoomApp`) because the controls render in the + // editor header alongside everything else App owns. The error slot + // is surfaced via a toast at the bottom of the layout. + const roomAdmin = useRoomAdminActions(roomSession?.room); + + // Stripped-image handoff count from the creator-origin fragment + // (`&stripped=N`). AppRoot reads and strips the fragment param on + // mount, stashing the number on `window.__PLANNOTATOR_STRIPPED_IMAGES__`. + // The initializer is PURE; the consume-once effect below clears the + // global so a later App remount doesn't re-show the notice. Pattern + // matches the previous RoomApp implementation — see its commit + // history for the StrictMode double-run trap this avoids. + const [strippedImagesCount, setStrippedImagesCount] = useState(() => { + if (!roomModeActive || typeof window === 'undefined') return 0; + const w = window as { __PLANNOTATOR_STRIPPED_IMAGES__?: number }; + return w.__PLANNOTATOR_STRIPPED_IMAGES__ ?? 0; + }); + useEffect(() => { + if (typeof window === 'undefined') return; + const w = window as { __PLANNOTATOR_STRIPPED_IMAGES__?: number }; + if (w.__PLANNOTATOR_STRIPPED_IMAGES__ !== undefined) { + delete w.__PLANNOTATOR_STRIPPED_IMAGES__; + } + }, []); + // Annotation state lives behind a uniform controller. In local mode it + // wraps useState; in room mode it delegates to useCollabRoom, with + // `pending` tracking sends awaiting echo and `failed` holding ids whose + // last send rejected. `setAnnotations` is retained as a name pointing at + // `controller.setAll` — undefined in room mode, so the few downstream + // consumers that ATOMIC-replace (useSharing import, draft restore, linked + // doc switch) degrade to no-ops in room mode. That is the Slice 5 + // contract: those flows are not supported inside a live room. + const annotationController = useAnnotationController({ + initial: [], + room: roomSession?.room, + }); + const { annotations } = annotationController; + const setAnnotations: React.Dispatch> = + annotationController.setAll ?? + ((_: React.SetStateAction) => { + // In room mode the controller has no replace-all primitive. Silently + // drop — every caller that relies on atomic replace is already + // guarded upstream (share import is disabled in room mode; draft + // restore triggers only in local mode; linked doc switch exits room + // mode first). + }); + + // Sync the plan markdown from the encrypted room snapshot. `room.planMarkdown` + // is empty string until the snapshot decrypts; we only overwrite local + // markdown when we have non-empty room plan content, so a late snapshot + // doesn't clobber the initial empty string render noisily. + useEffect(() => { + if (!roomSession?.room) return; + const plan = roomSession.room.planMarkdown; + if (plan && plan !== markdown) setMarkdown(plan); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [roomSession?.room?.planMarkdown]); const [selectedAnnotationId, setSelectedAnnotationId] = useState(null); const [blocks, setBlocks] = useState([]); const [frontmatter, setFrontmatter] = useState(null); @@ -128,6 +246,15 @@ const App: React.FC = () => { return () => ro.disconnect(); }, []); const [isApiMode, setIsApiMode] = useState(false); + // Approve / Deny are a LOCAL (same-origin) capability. In room mode + // the editor is served from room.plannotator.ai, which has no + // path to the blocked agent hook — so the buttons aren't offered + // there, even for admins. Decisions are made from the creator's + // localhost tab; room-side feedback flows back via the existing + // import paths (share hash / paste short URL / "Copy consolidated + // feedback" → paste). If a future change wants a room-origin + // approve path, it has to change THIS line. + const approveDenyAvailable = isApiMode; const [origin, setOrigin] = useState(null); const [gitUser, setGitUser] = useState(); const [isWSL, setIsWSL] = useState(false); @@ -141,6 +268,8 @@ const App: React.FC = () => { const [isSubmitting, setIsSubmitting] = useState(false); const [isExiting, setIsExiting] = useState(false); const [submitted, setSubmitted] = useState<'approved' | 'denied' | 'exited' | null>(null); + /** Visible error message for failed approve/deny. Cleared on next attempt. */ + const [submitError, setSubmitError] = useState(null); const [pendingPasteImage, setPendingPasteImage] = useState<{ file: File; blobUrl: string; initialName: string } | null>(null); const [showPermissionModeSetup, setShowPermissionModeSetup] = useState(false); const [permissionMode, setPermissionMode] = useState('bypassPermissions'); @@ -162,6 +291,21 @@ const App: React.FC = () => { const [versionInfo, setVersionInfo] = useState(null); const viewerRef = useRef(null); + // Monotonic generation driven by Viewer's highlight-surface lifecycle + // (init + `clearAllHighlights`). Reconcilers that track which annotation + // IDs are already materialized as DOM marks watch this value so they + // repaint from scratch when the underlying surface is reset out from + // under them (e.g. share-import wiping the DOM via clearAllHighlights). + // + // The counter is parent-owned on purpose: if the Viewer remounts (key + // change, conditional render) and emitted its own first-mount number + // again, React's setState bailout would silently drop the update and + // the reconciler's applied map would stay stale. The Viewer just emits + // "I reset" and we bump here. + const [highlightSurfaceGeneration, setHighlightSurfaceGeneration] = useState(0); + const bumpHighlightSurfaceGeneration = useCallback(() => { + setHighlightSurfaceGeneration(g => g + 1); + }, []); // containerRef + scrollViewport both point at the OverlayScrollbars // viewport element (the node that actually scrolls), not the
// host. Consumers: useActiveSection (IntersectionObserver root) and @@ -172,6 +316,29 @@ const App: React.FC = () => { onViewportReady: handleViewportReady, } = useOverlayViewport(); + // Expose the scroll viewport to sibling components outside App's + // React tree (LocalPresenceEmitter, RemoteCursorLayer — both live + // in RoomApp as React-siblings of App, so they can't consume + // `ScrollViewportContext`). They use content coordinates for + // remote cursor presence: "pointer at pixel (x, y) inside the + // scrolling content" is the one coordinate space that stays + // consistent across participants regardless of scroll / window + // size / zoom. Tagging the element with a data attribute lets + // those components `document.querySelector` for it — ugly + // coupling, but the alternative (hoisting presence components + // into App, or threading the ref back up through `renderEditor`) + // would be a bigger refactor for a localized win. Exactly one + // element carries the attribute (one App instance per page). + useEffect(() => { + if (!scrollViewport) return; + scrollViewport.dataset.planScrollViewport = ''; + return () => { + // Clean up on unmount so a later App mount (e.g. HMR) doesn't + // have two elements briefly claiming the role. + delete scrollViewport.dataset.planScrollViewport; + }; + }, [scrollViewport]); + usePrintMode(); // Resizable panels @@ -361,7 +528,7 @@ const App: React.FC = () => { // Flash highlight for annotated files in the sidebar const [highlightedFiles, setHighlightedFiles] = useState | undefined>(); - const flashTimerRef = React.useRef>(); + const flashTimerRef = React.useRef | undefined>(undefined); const handleFlashAnnotatedFiles = React.useCallback(() => { const filePaths = new Set(allAnnotationCounts.keys()); if (filePaths.size === 0) return; @@ -395,18 +562,27 @@ const App: React.FC = () => { // Drive DOM highlights for SSE-delivered external annotations. Disabled // while a linked doc overlay is open (Viewer DOM is hidden) and while the // plan diff view is active (diff view has its own annotation surface). - const { reset: resetExternalHighlights } = useExternalAnnotationHighlights({ + useExternalAnnotationHighlights({ viewerRef, externalAnnotations, enabled: isApiMode && !linkedDocHook.isActive && !isPlanDiffActive, planKey: markdown, + surfaceGeneration: highlightSurfaceGeneration, }); // Merge local + SSE annotations, deduping draft-restored externals against // live SSE versions. Prefer the SSE version when both exist (same source, // type, and originalText). This avoids the timing issues of an effect-based // cleanup — draft-restored externals persist until SSE actually re-delivers them. + // + // Room-mode exclusion: when a live room is active, external annotations are + // NOT merged. An SSE-sourced annotation visible only to the creator would + // diverge from other participants' views and could be accidentally + // consolidated into approve/deny payloads. Later Slice 6 work can + // forward the SSE stream to encrypted room ops so external annotations + // become shared. const allAnnotations = useMemo(() => { + if (roomSession?.room) return annotations; if (externalAnnotations.length === 0) return annotations; const local = annotations.filter(a => { @@ -419,7 +595,7 @@ const App: React.FC = () => { }); return [...local, ...externalAnnotations]; - }, [annotations, externalAnnotations]); + }, [annotations, externalAnnotations, roomSession?.room]); // Plan diff state — memoize filtered annotation lists to avoid new references per render const diffAnnotations = useMemo(() => allAnnotations.filter(a => !!a.diffContext), [allAnnotations]); @@ -453,7 +629,11 @@ const App: React.FC = () => { setIsLoading(false); }, shareBaseUrl, - pasteApiUrl + pasteApiUrl, + // Room mode disables static sharing entirely. The URL fragment + // (#key=) is a room credential, not a deflated share + // payload, and must not be interpreted as a static share. + roomModeActive, ); // Auto-save annotation drafts @@ -466,6 +646,12 @@ const App: React.FC = () => { }); const handleRestoreDraft = React.useCallback(() => { + // Draft restore is a replace-all flow; in room mode the room-backed + // controller has no replace-all primitive (annotations are + // server-authoritative). We intentionally skip restore in room mode — + // the user's local draft is still on disk; they can apply it after + // leaving the room. + if (roomModeActive) return; const { annotations: restored, globalAttachments: restoredGlobal } = restoreDraft(); if (restored.length > 0) { setAnnotations(restored); @@ -475,7 +661,7 @@ const App: React.FC = () => { viewerRef.current?.applySharedAnnotations(restored.filter(a => !a.diffContext)); }, 100); } - }, [restoreDraft]); + }, [restoreDraft, roomModeActive]); // Fetch available agents for OpenCode (for validation on approve) const { agents: availableAgents, validateAgent, getAgentWarning } = useAgents(origin); @@ -485,17 +671,33 @@ const App: React.FC = () => { if (pendingSharedAnnotations && pendingSharedAnnotations.length > 0) { // Small delay to ensure DOM is rendered const timer = setTimeout(() => { - // Clear existing highlights first (important when loading new share URL) + // Clear existing highlights first (important when loading new share URL). + // Viewer fires `onHighlightSurfaceReset`, which bumps the parent-owned + // generation and cascades into both reconcilers (external SSE + room) + // so their applied maps invalidate and repaint on the next tick. viewerRef.current?.clearAllHighlights(); viewerRef.current?.applySharedAnnotations(pendingSharedAnnotations.filter(a => !a.diffContext)); clearPendingSharedAnnotations(); - // `clearAllHighlights` wiped live external SSE highlights too; - // tell the external-highlight bookkeeper to re-apply them. - resetExternalHighlights(); }, 100); return () => clearTimeout(timer); } - }, [pendingSharedAnnotations, clearPendingSharedAnnotations, resetExternalHighlights]); + }, [pendingSharedAnnotations, clearPendingSharedAnnotations]); + + // Room-mode annotation → DOM mark reconciliation. Delegates to the + // shared `useAnnotationHighlightReconciler` (also used by external + // SSE). Room fingerprint includes comment `text` because peers can + // edit an annotation's comment without changing `originalText` — + // that case must trigger a remove+reapply so the mark's visible + // content matches the canonical annotation. + useAnnotationHighlightReconciler({ + viewerRef, + annotations, + enabled: roomModeActive, + planKey: markdown, + surfaceGeneration: highlightSurfaceGeneration, + eligibleFilter: a => !a.diffContext && !!a.originalText, + fingerprint: roomAnnFingerprint, + }); const handleTaterModeChange = (enabled: boolean) => { setTaterMode(enabled); @@ -520,6 +722,7 @@ const App: React.FC = () => { useEffect(() => { if (isLoadingShared) return; // Wait for share check to complete if (isSharedSession) return; // Already loaded from share + if (roomModeActive) return; // Room mode loads plan from the encrypted snapshot fetch('/api/plan') .then(res => { @@ -700,6 +903,11 @@ const App: React.FC = () => { // Global paste listener for image attachments useEffect(() => { const handlePaste = (e: ClipboardEvent) => { + // Room mode: images are not supported. Block the paste flow so + // the user doesn't get an upload modal that silently fails (the + // room Worker has no /api/upload endpoint). + if (roomModeActive) return; + const items = e.clipboardData?.items; if (!items) return; @@ -720,7 +928,7 @@ const App: React.FC = () => { document.addEventListener('paste', handlePaste); return () => document.removeEventListener('paste', handlePaste); - }, [globalAttachments]); + }, [globalAttachments, roomModeActive]); // Handle paste annotator accept — name comes from ImageAnnotator const handlePasteAnnotatorAccept = async (blob: Blob, hasDrawings: boolean, name: string) => { @@ -756,6 +964,7 @@ const App: React.FC = () => { // API mode handlers const handleApprove = async () => { setIsSubmitting(true); + setSubmitError(null); try { const obsidianSettings = getObsidianSettings(); const bearSettings = getBearSettings(); @@ -823,22 +1032,29 @@ const App: React.FC = () => { body.feedback = annotationsOutput; } - await fetch('/api/approve', { + const approveRes = await fetch('/api/approve', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); + if (!approveRes.ok) { + throw new Error(`Approve failed: ${approveRes.status}`); + } + setSubmitted('approved'); - } catch { + setSubmitError(null); + } catch (err) { setIsSubmitting(false); + setSubmitError(err instanceof Error ? err.message : 'Approve failed'); } }; const handleDeny = async () => { setIsSubmitting(true); + setSubmitError(null); try { const planSaveSettings = getPlanSaveSettings(); - await fetch('/api/deny', { + const denyRes = await fetch('/api/deny', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -849,12 +1065,270 @@ const App: React.FC = () => { }, }) }); + if (!denyRes.ok) { + throw new Error(`Deny failed: ${denyRes.status}`); + } setSubmitted('denied'); - } catch { + setSubmitError(null); + } catch (err) { setIsSubmitting(false); + setSubmitError(err instanceof Error ? err.message : 'Deny failed'); } }; + // Start-live-room modal state. The modal is the sole entry to the creator + // flow — replaces the earlier inline hardcoded path (name/color/expiry + // defaults). Abort during in-flight creation runs through an + // AbortController passed to createRoom(). + const [showStartRoomModal, setShowStartRoomModal] = useState(false); + const [startRoomInFlight, setStartRoomInFlight] = useState(false); + const [startRoomError, setStartRoomError] = useState(''); + const startRoomAbortRef = useRef(null); + + /** + * Count of images that will NOT travel to the live room. + * Includes both per-annotation images AND global attachments — both + * are local-only in Slice 5 (rooms carry no image payloads), so the + * notice must inform the user about either source. + */ + // Single source of truth for "how many local items won't travel to + // the room" — matches the value used at actual room-create time + // (stripRoomAnnotationImages inside handleConfirmStartRoom) so the + // modal notice and the URL `&stripped=N` handoff can never drift. + // stripRoomAnnotationImages is synchronous and O(annotations + + // globals); running it per render on a typical small annotation list + // is cheap. + const imageAnnotationsToStrip = useMemo(() => { + // Dynamic import in the sync path would defeat the purpose; we use + // a static import above and call the helper directly. + const { strippedCount } = stripRoomAnnotationImages(annotations, globalAttachments); + return strippedCount; + }, [annotations, globalAttachments]); + + /** + * Stable `pendingIds` derivation for AnnotationPanel. The controller + * exposes pending as `Map` but the panel only needs + * "is this id pending?" — we project to a Set with the controller's + * pending Map as the memo dependency so a new Set is built ONLY when + * pending actually changes. + */ + const pendingAnnotationIds = useMemo | undefined>( + () => (roomModeActive ? new Set(annotationController.pending.keys()) : undefined), + [roomModeActive, annotationController.pending], + ); + + // Live Rooms is a local-creator-only flow: it needs a running + // Plannotator hook (isApiMode) to host the creator's original tab so + // the blocked agent hook has an approve/deny surface after the room + // opens in a new tab. Hosted portals (share.plannotator.ai, the + // marketing demo) have no such host, and the room-service CORS policy + // intentionally doesn't whitelist them — offering the button there + // would surface an "unreachable room service" error on click. Gate + // the menu + export-modal affordances on this single flag so both + // surfaces stay consistent. + const canStartLiveRoom = isApiMode && !roomModeActive; + + const handleStartLiveRoom = React.useCallback(() => { + if (!canStartLiveRoom) return; // belt-and-braces with the prop-level gate below + setStartRoomError(''); + setShowStartRoomModal(true); + }, [canStartLiveRoom]); + + // Note: room admin actions (lock / unlock / delete) intentionally + // only live in the RoomPanel in Slice 5. The header menu used to + // duplicate them via a `runHeaderRoomAdmin` wrapper, but that path + // swallowed errors without a visible surface — RoomPanel owns the + // `adminActionError` UI and the pending-state chrome, so routing + // admin clicks through a single focal point gives the user a + // consistent recovery path. We keep `isRoomAdmin` / `roomIsLocked` + // props threaded into `PlanHeaderMenu` so other header-level + // conditionals can still read capability state without offering + // click targets. + + const handleCancelStartRoom = React.useCallback(() => { + // Abort the in-flight createRoom if any; modal closes either way. + startRoomAbortRef.current?.abort(); + startRoomAbortRef.current = null; + setShowStartRoomModal(false); + setStartRoomInFlight(false); + }, []); + + const handleConfirmStartRoom = React.useCallback(async (submit: StartRoomSubmit) => { + setStartRoomInFlight(true); + setStartRoomError(''); + + const ctrl = new AbortController(); + startRoomAbortRef.current = ctrl; + + // Persist any edits the user made in the modal. Identity is a + // Plannotator-wide preference — what they pick here also becomes + // the default for the next room and feeds Settings. Writes are + // no-ops when the submitted values already match ConfigStore. + if (submit.displayName && submit.displayName !== getIdentity()) { + setCustomIdentity(submit.displayName); + } + if (submit.color && submit.color !== getPresenceColor()) { + setPresenceColor(submit.color); + } + + // Pre-open a placeholder tab SYNCHRONOUSLY — inside the user- + // activation window from the click that landed us here. Browsers + // only honor user activation for synchronous work (or a very short + // task chain); a bare window.open after the awaits below would + // typically be blocked. We sever `.opener` now while the new + // window is still a same-origin about:blank so the subsequent + // cross-origin `location.replace` doesn't inherit the opener + // reference. Do NOT pass `noopener`/`noreferrer` in the features + // string — those make window.open return null EVEN ON SUCCESS, + // which would make every opened tab look blocked. + const newWindow: Window | null = + typeof window !== 'undefined' ? window.open('', '_blank') : null; + if (newWindow) { + newWindow.opener = null; + } + + const abortPlaceholder = () => { + if (newWindow) { + try { newWindow.close(); } catch { /* already closed */ } + } + }; + + try { + const { createRoom } = await import('@plannotator/shared/collab/client'); + const { bytesToBase64url } = await import('@plannotator/shared/collab'); + const { storeAdminSecret } = await import('@plannotator/ui/utils/adminSecretStorage'); + + // `stripRoomAnnotationImages` returns a generic `Omit[]`. + // RoomAnnotation is defined as Annotation-without-images in the + // protocol, so the shape is compatible; we narrow explicitly instead + // of `as never` so future protocol drift surfaces as a type error. + // Pass globalAttachments so the helper's strippedCount matches the + // memoized imageAnnotationsToStrip used for the modal notice and + // `&stripped=N` URL handoff (single source of truth). `clean` is + // still annotation-shaped — globals are dropped entirely from + // room snapshots. + const { clean } = stripRoomAnnotationImages(annotations, globalAttachments); + const roomAnnotations: import('@plannotator/shared/collab').RoomAnnotation[] = + clean as unknown as import('@plannotator/shared/collab').RoomAnnotation[]; + + const baseUrl = getRoomBaseUrl(); + + const result = await createRoom({ + baseUrl, + expiresInDays: submit.expiresInDays, + signal: ctrl.signal, + initialSnapshot: { + versionId: 'v1', // RoomSnapshot contract pins versionId to 'v1' in V1 + planMarkdown: markdown, + annotations: roomAnnotations, + }, + user: { + id: submit.displayName, + name: submit.displayName, + color: submit.color, + }, + }); + + if (ctrl.signal.aborted) { + // User hit Cancel while the create call was in flight; the request + // still landed and a room was created on the server, but we must + // not navigate. Close the pre-opened placeholder so it doesn't + // linger as an empty tab the user can't explain. + abortPlaceholder(); + return; + } + + // sessionStorage is per-origin — the value we set here lives only in + // the creator's local editor origin and is NOT visible on + // room.plannotator.ai. We still write it so same-origin test/dev + // scenarios (everything on localhost) keep working; cross-origin + // cases rely on the admin fragment in the URL. + storeAdminSecret(result.roomId, bytesToBase64url(result.adminSecret)); + + // No sessionStorage for stripped-images count — sessionStorage + // is per-origin and the navigation crosses from localhost to + // room.plannotator.ai. Instead, append &stripped=N to the + // fragment. AppRoot on the destination origin reads and strips + // it on mount. + + // Auto-copy the PARTICIPANT URL (safe default share target). + try { + await navigator.clipboard.writeText(result.joinUrl); + } catch { /* ignore */ } + + // Creator's destination URL: adminUrl (which already carries + // `#key=&admin=` in its fragment) plus + // an optional `&stripped=N` and an identity handoff. The admin + // fragment stays in the URL because useCollabRoom parses it on + // every connect; stripping it would force a separate admin- + // secret-override injection path. + // + // Identity handoff (name + color) bridges the cross-origin gap: + // localhost ConfigStore cookies are not visible on + // room.plannotator.ai, so the creator's confirmed identity + // rides along in the URL fragment and is consumed + stripped + // by `AppRoot` on arrival. `&admin=` stays (it's the session + // credential); `&name=&color=` get stripped after AppRoot + // writes them into the room-origin ConfigStore. + const appendFragmentParam = (url: string, param: string): string => + `${url}${url.includes('#') ? '&' : '#'}${param}`; + let creatorUrl = result.adminUrl; + if (imageAnnotationsToStrip > 0) { + creatorUrl = appendFragmentParam( + creatorUrl, + `stripped=${imageAnnotationsToStrip}`, + ); + } + if (submit.displayName) { + creatorUrl = appendFragmentParam( + creatorUrl, + `name=${encodeURIComponent(submit.displayName)}`, + ); + } + if (submit.color) { + creatorUrl = appendFragmentParam( + creatorUrl, + `color=${encodeURIComponent(submit.color)}`, + ); + } + + // Navigate the pre-opened placeholder tab to the room URL. The + // creator's current tab stays on localhost so the blocked hook + // has an approval surface. `location.replace` (not `=`) so the + // about:blank intermediate doesn't sit in the new tab's back + // history. If the browser blocked the synchronous pre-open + // above, surface the URL as a copy-able fallback in the modal + // rather than silently reassigning the current tab (which would + // strand the local hook). + if (newWindow) { + // Success: new tab takes over the room session. Close the + // modal so the localhost tab returns to the editor — prior + // behavior relied on `window.location.assign` navigating the + // current tab away, which implicitly dismissed the modal. + newWindow.location.replace(creatorUrl); + setStartRoomInFlight(false); + setShowStartRoomModal(false); + } else { + // Popup blocked: KEEP the modal open so the user can copy + // the surfaced URL and open the room themselves. + setStartRoomError( + `Your browser blocked opening the room in a new tab. ` + + `Copy this URL and open it yourself: ${creatorUrl}`, + ); + setStartRoomInFlight(false); + } + } catch (err) { + abortPlaceholder(); + if (ctrl.signal.aborted) return; // user cancelled; no error + const { redactRoomSecrets } = await import('@plannotator/shared/collab'); + const msg = err instanceof Error ? err.message : String(err); + setStartRoomError(redactRoomSecrets(msg) || 'Failed to start live room'); + setStartRoomInFlight(false); + } finally { + if (startRoomAbortRef.current === ctrl) startRoomAbortRef.current = null; + } + }, [annotations, markdown, imageAnnotationsToStrip, globalAttachments]); + // Annotate mode handler — sends feedback via /api/feedback const handleAnnotateFeedback = async () => { setIsSubmitting(true); @@ -949,8 +1423,16 @@ const App: React.FC = () => { origin, getAgentWarning, ]); + // Room-mode lock: when the admin has locked the room, mutation + // affordances must disable at the source instead of letting the user + // submit and get a rejected op. Short-circuits add/remove/edit to + // no-ops; Viewer/AnnotationPanel also receive `roomIsLocked` to hide + // selection toolbars and per-row edit/delete controls. + const roomIsLocked = !!annotationController.isLocked; + const handleAddAnnotation = (ann: Annotation) => { - setAnnotations(prev => [...prev, ann]); + if (roomIsLocked) return; + annotationController.add(ann); setSelectedAnnotationId(ann.id); setIsPanelOpen(true); }; @@ -961,19 +1443,88 @@ const App: React.FC = () => { if (id && window.innerWidth < 768) setIsPanelOpen(true); }, []); - // Core annotation removal — highlight cleanup + state filter + selection clear + // Core annotation removal — highlight cleanup + controller filter + selection clear. + // + // Local mode: remove the highlight immediately (optimistic). The user + // sees instant feedback and there's no server to reject. + // + // Room mode: do NOT remove the highlight before the server echo. The + // room annotation reconciliation effect (above) removes marks when + // they disappear from canonical state. Removing early would desync + // the DOM if the server rejects (e.g. locked room race): the + // annotation would stay canonical but lose its visible mark. const removeAnnotation = (id: string) => { - viewerRef.current?.removeHighlight(id); - setAnnotations(prev => prev.filter(a => a.id !== id)); + if (roomIsLocked) return; + if (!roomModeActive) { + viewerRef.current?.removeHighlight(id); + } + annotationController.remove(id); if (selectedAnnotationId === id) setSelectedAnnotationId(null); }; + // Room-mode only. Block IDs with an unresolved checkbox op — in + // flight (`pending`) or waiting on user Retry/Discard (`failed`). + // Two consumers inside `useCheckboxOverrides`: + // + // 1. Busy gate: rapid same-block toggles stack a second op on + // top of the first; the room controller's one-op-per-id + // pending map can't reconcile that, and the first op may + // still echo as a confirmed annotation for state the user + // thought they undid. The hook's `toggle` short-circuits on + // membership. + // + // 2. Revert gate: when the user deletes a checkbox annotation + // from the panel in room mode we defer the visual revert + // until the remove actually echoes. The reconciliation + // effect inside the hook keeps the override alive while the + // block is in this set, so a failed remove doesn't strand a + // visually-reverted checkbox with an un-removed canonical + // annotation. + // + // Pending adds surface through `pendingAdditions` (not yet in + // canonical `annotations`). Pending updates/removes and failed + // entries of any kind surface through `pending` / `failed` — their + // target annotation usually still lives in canonical, so we resolve + // blockId there. ID convention is `ann-checkbox--` — + // same as `useCheckboxOverrides`. + const pendingCheckboxBlockIds = useMemo | undefined>(() => { + if (!roomModeActive) return undefined; + const blockIds = new Set(); + const addByIdLookup = (id: string) => { + if (!id.startsWith('ann-checkbox-')) return; + const optimistic = annotationController.pendingAdditions.get(id); + if (optimistic) { + blockIds.add(optimistic.blockId); + return; + } + const canonical = annotations.find(a => a.id === id); + if (canonical) blockIds.add(canonical.blockId); + }; + for (const id of annotationController.pending.keys()) addByIdLookup(id); + for (const id of annotationController.failed.keys()) addByIdLookup(id); + // Cover optimistic adds that haven't made it into `pending` yet + // (defensive — the controller enqueues them in lockstep, but + // iteration is cheap and keeps the set consistent with its + // documented meaning). + for (const [id, ann] of annotationController.pendingAdditions) { + if (id.startsWith('ann-checkbox-')) blockIds.add(ann.blockId); + } + return blockIds; + }, [ + roomModeActive, + annotationController.pending, + annotationController.failed, + annotationController.pendingAdditions, + annotations, + ]); + // Interactive checkbox toggling with annotation tracking const checkbox = useCheckboxOverrides({ blocks, annotations, addAnnotation: handleAddAnnotation, removeAnnotation, + pendingBlockIds: pendingCheckboxBlockIds, }); const handleDeleteAnnotation = (id: string) => { @@ -986,8 +1537,21 @@ const App: React.FC = () => { if (selectedAnnotationId === id) setSelectedAnnotationId(null); return; } - // If this is a checkbox annotation, revert the visual override - if (id.startsWith('ann-checkbox-')) { + // If this is a checkbox annotation, clear the visual override. + // + // Local mode: synchronous — there's no server to reject the remove, + // so revert optimistically in lockstep with the annotation removal. + // + // Room mode: DO NOT revert here. The override must stay until the + // canonical checkbox annotation actually disappears from the room + // state (echoed remove). Otherwise a remove that later fails + // (disconnect, lock race, server rejection) leaves the annotation + // canonical but the checkbox visually reverted — inconsistent. + // `useCheckboxOverrides` runs a reconciliation effect that clears + // overrides once the backing annotation is gone from BOTH canonical + // and pending/failed state, which is exactly when it's safe to + // revert in room mode. + if (id.startsWith('ann-checkbox-') && !roomModeActive) { if (ann) { checkbox.revertOverride(ann.blockId); } @@ -996,20 +1560,33 @@ const App: React.FC = () => { }; const handleEditAnnotation = (id: string, updates: Partial) => { + if (roomIsLocked) return; const ann = allAnnotations.find(a => a.id === id); if (ann?.source && externalAnnotations.some(e => e.id === id)) { updateExternalAnnotation(id, updates); return; } - setAnnotations(prev => prev.map(a => - a.id === id ? { ...a, ...updates } : a - )); + annotationController.update(id, updates); }; const handleIdentityChange = (oldIdentity: string, newIdentity: string) => { - setAnnotations(prev => prev.map(ann => - ann.author === oldIdentity ? { ...ann, author: newIdentity } : ann - )); + // In room mode the joined identity — which stamps new annotations, + // labels remote cursors, and keyed presence — is owned by `RoomApp` + // and was confirmed at the join gate. Rewriting old annotations + // from here while that live identity stays on the prior name + // produces a "split" participant: old rows now say "Alice" but + // future rows / the cursor flag still say "Bob." Skip rewrites + // inside a room. Updating the Settings display name still takes + // effect locally and on subsequent rooms; a deliberate live + // rename feature (update presence + rename server-side) would be + // a RoomApp-owned feature, not a half-rewrite from here. + if (roomModeActive) return; + // Identity-rename is a bulk update across all matching annotations. + for (const ann of annotations) { + if (ann.author === oldIdentity) { + annotationController.update(ann.id, { author: newIdentity }); + } + } }; const handleAddGlobalAttachment = (image: ImageAttachment) => { @@ -1031,7 +1608,12 @@ const App: React.FC = () => { const hasDocAnnotations = Array.from(docAnnotations.values()).some( (d) => d.annotations.length > 0 || d.globalAttachments.length > 0 ); - const hasPlanAnnotations = allAnnotations.length > 0 || globalAttachments.length > 0; + // Room mode: global attachments are local-only (Slice 5 rooms carry + // no image payloads), so they must NOT be included in consolidated + // feedback — approving with local-only image refs would ship paths + // collaborators never saw. Out-of-room keeps existing behavior. + const effectiveGlobalAttachments = roomModeActive ? [] : globalAttachments; + const hasPlanAnnotations = allAnnotations.length > 0 || effectiveGlobalAttachments.length > 0; const hasEditorAnnotations = editorAnnotations.length > 0; if (!hasPlanAnnotations && !hasDocAnnotations && !hasEditorAnnotations) { @@ -1039,7 +1621,7 @@ const App: React.FC = () => { } let output = hasPlanAnnotations - ? exportAnnotations(blocks, allAnnotations, globalAttachments, annotateSource === 'message' ? 'Message Feedback' : annotateSource === 'folder' ? 'Folder Feedback' : annotateSource === 'file' ? 'File Feedback' : 'Plan Feedback', annotateSource ?? 'plan') + ? exportAnnotations(blocks, allAnnotations, effectiveGlobalAttachments, annotateSource === 'message' ? 'Message Feedback' : annotateSource === 'folder' ? 'Folder Feedback' : annotateSource === 'file' ? 'File Feedback' : 'Plan Feedback', annotateSource ?? 'plan') : ''; if (hasDocAnnotations) { @@ -1051,7 +1633,7 @@ const App: React.FC = () => { } return output; - }, [blocks, allAnnotations, globalAttachments, linkedDocHook.getDocAnnotations, editorAnnotations]); + }, [blocks, allAnnotations, globalAttachments, roomModeActive, linkedDocHook.getDocAnnotations, editorAnnotations]); // Bot callback config — read once from URL search params (?cb=&ct=) const callbackConfig = React.useMemo(() => getCallbackConfig(), []); @@ -1167,6 +1749,57 @@ const App: React.FC = () => { setTimeout(() => setNoteSaveToast(null), 3000); }; + // Room-mode copy handlers. Share a single clipboard + toast helper so + // the three call sites can't drift (success-vs-error copy, timeout, + // toast slot reuse). The `noteSaveToast` slot is reused for both + // flows — small grief that two kinds of message share one slot, but + // avoids a second toast system for a handful of rare clicks. + const copyToClipboardWithToast = React.useCallback( + async (text: string, successMessage: string, errorMessage: string) => { + try { + await navigator.clipboard.writeText(text); + setNoteSaveToast({ type: 'success', message: successMessage }); + } catch { + setNoteSaveToast({ type: 'error', message: errorMessage }); + } + setTimeout(() => setNoteSaveToast(null), 2500); + }, + [], + ); + const handleCopyParticipantUrl = React.useCallback(async () => { + const url = roomSession?.joinUrl; + if (!url) return; + await copyToClipboardWithToast( + url, + 'Participant link copied', + 'Failed to copy link', + ); + }, [roomSession?.joinUrl, copyToClipboardWithToast]); + const handleCopyAdminUrl = React.useCallback(async () => { + const url = roomSession?.adminUrl; + if (!url) return; + await copyToClipboardWithToast( + url, + 'Admin link copied', + 'Failed to copy admin link', + ); + }, [roomSession?.adminUrl, copyToClipboardWithToast]); + const handleCopyConsolidatedFeedback = React.useCallback(async () => { + // Exclude diff-context annotations from exported feedback — same + // filter the prior RoomPanel path used. Global attachments are + // empty inside a room (images are stripped at create time). + const text = exportAnnotations( + blocks, + allAnnotations.filter(a => !a.diffContext), + [], + ); + await copyToClipboardWithToast( + text, + 'Feedback copied', + 'Failed to copy feedback', + ); + }, [blocks, allAnnotations, copyToClipboardWithToast]); + // Cmd/Ctrl+S keyboard shortcut — save to default notes app useEffect(() => { const handleSaveShortcut = (e: KeyboardEvent) => { @@ -1299,7 +1932,13 @@ const App: React.FC = () => { )} - {isApiMode && (!linkedDocHook.isActive || annotateMode) && !archive.archiveMode && ( + {submitError && ( +
+ {submitError} +
+ )} + + {approveDenyAvailable && (!linkedDocHook.isActive || annotateMode) && !archive.archiveMode && ( <> {annotateMode ? ( // Annotate mode: Close always visible, Send Annotations when annotations exist @@ -1374,6 +2013,32 @@ const App: React.FC = () => { )} + {/* + Room mode cluster: conditional status pill (only when + state is non-default — locked / reconnecting / offline), + peer avatar bubbles, and a Room actions dropdown. Sits + between the approve/deny area (hidden in room mode) and + the annotations-panel toggle. Replaces the previous + floating `RoomPanel` aside — one visually-grouped + header cluster instead of a separate fixed card. + */} + {roomModeActive && roomSession?.room && ( + void roomAdmin.run('lock')} + onUnlock={() => void roomAdmin.run('unlock')} + onDelete={() => void roomAdmin.run('delete')} + /> + )} + {/* Annotations panel toggle — top-level header button */} + + + )} + {isPending && !failure && ( +
+ Sending… +
+ )} + + ); + })} {editorAnnotations && editorAnnotations.length > 0 && ( <> {sortedAnnotations.length > 0 && ( @@ -230,10 +351,19 @@ function formatTimestamp(ts: number): string { const AnnotationCard: React.FC<{ annotation: Annotation; isSelected: boolean; + /** + * "Is this annotation authored by the current user?" — passed in + * from AnnotationPanel so the helper can bake in the room-mode + * override (the joined display name instead of the cookie Tater). + * Taking it as a prop rather than closing over the parent's local + * helper keeps AnnotationCard module-scoped. + */ + isMe: (author: string | undefined) => boolean; onSelect: () => void; - onDelete: () => void; + /** Undefined = hide delete button (e.g. locked room / read-only). */ + onDelete?: () => void; onEdit?: (updates: Partial) => void; -}> = ({ annotation, isSelected, onSelect, onDelete, onEdit }) => { +}> = ({ annotation, isSelected, isMe, onSelect, onDelete, onEdit }) => { const [isEditing, setIsEditing] = useState(false); const [editText, setEditText] = useState(annotation.text || ''); const textareaRef = useRef(null); @@ -252,6 +382,16 @@ const AnnotationCard: React.FC<{ } }, [annotation.text, isEditing]); + // Cancel in-progress edits when the card becomes read-only (e.g. room + // locks while the user has a textarea open). Without this, the + // textarea would persist visually; Save would silently no-op because + // onEdit is now undefined, confusing the user. + useEffect(() => { + if (!onEdit && isEditing) { + setIsEditing(false); + } + }, [onEdit, isEditing]); + const handleStartEdit = (e: React.MouseEvent) => { e.stopPropagation(); setEditText(annotation.text || ''); @@ -339,11 +479,11 @@ const AnnotationCard: React.FC<{ > {/* Author */} {annotation.author && ( -
+
- {annotation.author}{isCurrentUser(annotation.author) && ' (me)'} + {annotation.author}{isMe(annotation.author) && ' (me)'}
)} @@ -379,15 +519,17 @@ const AnnotationCard: React.FC<{ )} - + {onDelete && ( + + )}
diff --git a/packages/ui/components/CommentPopover.tsx b/packages/ui/components/CommentPopover.tsx index f9a3259a..2d84ad23 100644 --- a/packages/ui/components/CommentPopover.tsx +++ b/packages/ui/components/CommentPopover.tsx @@ -18,6 +18,14 @@ interface CommentPopoverProps { onSubmit: (text: string, images?: ImageAttachment[]) => void; /** Called when popover is closed/cancelled */ onClose: () => void; + /** + * Default true. Set false in room mode: Live Rooms V1 strips image + * attachments at room-create time and doesn't carry them over the + * wire for new annotations either, so the attachments UI would + * either silently drop images or fail validation at send. Hiding + * the affordance is the honest surface. + */ + attachmentsEnabled?: boolean; } const MAX_POPOVER_WIDTH = 384; @@ -45,6 +53,7 @@ export const CommentPopover: React.FC = ({ initialText = '', onSubmit, onClose, + attachmentsEnabled = true, }) => { const [mode, setMode] = useState<'popover' | 'dialog'>('popover'); const [text, setText] = useState(initialText); @@ -196,12 +205,14 @@ export const CommentPopover: React.FC = ({ {/* Footer */}
- setImages((prev) => [...prev, img])} - onRemove={(path) => setImages((prev) => prev.filter((i) => i.path !== path))} - variant="inline" - /> + {attachmentsEnabled && ( + setImages((prev) => [...prev, img])} + onRemove={(path) => setImages((prev) => prev.filter((i) => i.path !== path))} + variant="inline" + /> + )}
{submitHint} @@ -291,12 +302,14 @@ export const CommentPopover: React.FC = ({ {/* Footer */}
- setImages((prev) => [...prev, img])} - onRemove={(path) => setImages((prev) => prev.filter((i) => i.path !== path))} - variant="inline" - /> + {attachmentsEnabled && ( + setImages((prev) => [...prev, img])} + onRemove={(path) => setImages((prev) => prev.filter((i) => i.path !== path))} + variant="inline" + /> + )}
{submitHint} diff --git a/packages/ui/components/ExportModal.tsx b/packages/ui/components/ExportModal.tsx index 99ca856b..9086688a 100644 --- a/packages/ui/components/ExportModal.tsx +++ b/packages/ui/components/ExportModal.tsx @@ -33,6 +33,13 @@ interface ExportModalProps { markdown?: string; isApiMode?: boolean; initialTab?: Tab; + /** + * Optional. When present, the Share tab surfaces a primary + * "Start live room" CTA above the existing static-share links. Absent + * → the existing UI is unchanged (local mode with no room backend + * configured). + */ + onStartLiveRoom?: () => void; } type Tab = 'share' | 'annotations' | 'notes'; @@ -56,6 +63,7 @@ export const ExportModal: React.FC = ({ markdown, isApiMode = false, initialTab, + onStartLiveRoom, }) => { const defaultTab = initialTab || (sharingEnabled ? 'share' : 'annotations'); const [activeTab, setActiveTab] = useState(defaultTab); @@ -252,6 +260,27 @@ export const ExportModal: React.FC = ({ {/* Tab content */} {activeTab === 'share' && sharingEnabled ? (
+ {onStartLiveRoom && ( +
+
Start a live room
+

+ Real-time collaborative review. The link you share is participant-only; + admin controls stay with you. +

+ +
+ )} + {onStartLiveRoom && ( +
+ Static share +
+ )} {/* Short URL — primary copy target when available */} {shortShareUrl ? (
diff --git a/packages/ui/components/PlanHeaderMenu.tsx b/packages/ui/components/PlanHeaderMenu.tsx index 4c5e13b8..7712bf40 100644 --- a/packages/ui/components/PlanHeaderMenu.tsx +++ b/packages/ui/components/PlanHeaderMenu.tsx @@ -18,6 +18,22 @@ interface PlanHeaderMenuProps { onPrint: () => void; onCopyShareLink: () => void; onOpenImport: () => void; + /** + * Opens the Start-live-room modal. Optional so existing consumers + * (build-time and tests) don't need to thread the prop; when omitted + * the menu item is hidden. + */ + onStartLiveRoom?: () => void; + /** + * When true (i.e. the editor is currently joined to a live room), + * the menu surfaces Lock / Unlock / Delete admin actions. All three + * props are optional and tested for presence before rendering. + */ + onRoomLock?: () => void; + onRoomUnlock?: () => void; + onRoomDelete?: () => void; + roomIsLocked?: boolean; + isRoomAdmin?: boolean; onSaveToObsidian: () => void; onSaveToBear: () => void; onSaveToOctarine: () => void; @@ -39,6 +55,12 @@ export const PlanHeaderMenu: React.FC = ({ onPrint, onCopyShareLink, onOpenImport, + onStartLiveRoom, + onRoomLock, + onRoomUnlock, + onRoomDelete, + roomIsLocked, + isRoomAdmin, onSaveToObsidian, onSaveToBear, onSaveToOctarine, @@ -163,6 +185,42 @@ export const PlanHeaderMenu: React.FC = ({ label="Copy Share Link" /> )} + {onStartLiveRoom && ( + { + closeMenu(); + onStartLiveRoom(); + }} + // 👥 (people) distinguishes "Start live room" from the + // visually-similar "Copy Share Link" entry above, which + // keeps its chain-link icon for static sharing. + icon={👥} + label="Start live room…" + /> + )} + {isRoomAdmin && onRoomLock && onRoomUnlock && onRoomDelete && ( + <> + {!roomIsLocked && ( + { closeMenu(); onRoomLock(); }} + icon={} + label="Lock room" + /> + )} + {roomIsLocked && ( + { closeMenu(); onRoomUnlock(); }} + icon={} + label="Unlock room" + /> + )} + { closeMenu(); onRoomDelete(); }} + icon={} + label="Delete room" + /> + + )} {sharingEnabled && ( { @@ -301,3 +359,21 @@ const NoteIcon = () => ( ); +const LockIcon = () => ( + + + +); + +const UnlockIcon = () => ( + + + +); + +const DeleteIcon = () => ( + + + +); + diff --git a/packages/ui/components/Settings.tsx b/packages/ui/components/Settings.tsx index fbb62092..c77dc5c9 100644 --- a/packages/ui/components/Settings.tsx +++ b/packages/ui/components/Settings.tsx @@ -4,7 +4,8 @@ import type { Origin } from '@plannotator/shared/agents'; import { configStore, useConfigValue } from '../config'; import { loadDiffFont } from '../utils/diffFonts'; import { TaterSpritePullup } from './TaterSpritePullup'; -import { getIdentity, regenerateIdentity, setCustomIdentity } from '../utils/identity'; +import { getIdentity, regenerateIdentity, setCustomIdentity, getPresenceColor, setPresenceColor } from '../utils/identity'; +import { PRESENCE_SWATCHES } from '../utils/presenceColor'; import { GitUser } from '../icons/GitUser'; import { getObsidianSettings, @@ -546,6 +547,7 @@ export const Settings: React.FC = ({ taterMode, onTaterModeChange const [showDialog, setShowDialog] = useState(false); const [activeTab, setActiveTab] = useState('general'); const [identity, setIdentity] = useState(''); + const [presenceColor, setPresenceColorState] = useState(PRESENCE_SWATCHES[0]); const [obsidian, setObsidian] = useState({ enabled: false, vaultPath: '', @@ -621,6 +623,7 @@ export const Settings: React.FC = ({ taterMode, onTaterModeChange if (showDialog) { if (showNewHints) markNewSettingsSeen(); setIdentity(getIdentity()) + setPresenceColorState(getPresenceColor()); setObsidian(getObsidianSettings()); setBear(getBearSettings()); setOctarine(getOctarineSettings()); @@ -768,6 +771,12 @@ export const Settings: React.FC = ({ taterMode, onTaterModeChange handleIdentitySave(gitUser); }; + const handlePresenceColorChange = (color: string) => { + if (color === presenceColor) return; + const saved = setPresenceColor(color); + setPresenceColorState(saved); + }; + return ( <>
+ {/* + Presence color lives next to the name as part + of identity. The Live Rooms create/join gates + read the same preference, so editing here + updates what peers see on the next room join. + */} +
+ {PRESENCE_SWATCHES.map(s => ( +
{/* Permission Mode (Claude Code only) */} diff --git a/packages/ui/components/Viewer.tsx b/packages/ui/components/Viewer.tsx index 9e8e7e68..3b09cfc5 100644 --- a/packages/ui/components/Viewer.tsx +++ b/packages/ui/components/Viewer.tsx @@ -82,6 +82,55 @@ interface ViewerProps { // Checkbox toggle props onToggleCheckbox?: (blockId: string, checked: boolean) => void; checkboxOverrides?: Map; + /** + * When true, annotation creation affordances (selection toolbar, + * comment popover) and attachment controls are hidden. Existing + * annotation highlights remain visible for review. Used by the + * locked-room flow to make the editor read-only at the UI level + * instead of letting the user submit writes that the server or + * controller would reject. + */ + readOnly?: boolean; + /** + * When set, newly-created annotations stamp `author` with this + * value instead of the cookie-backed `getIdentity()`. Threaded + * from App in room mode so annotations carry the display name + * the participant typed into the JoinRoomGate — matches the + * name peers see on remote cursors/avatars. + */ + authorOverride?: string; + /** + * Default true. Passed to CommentPopover to hide the attachments + * UI when false. Used by App in room mode: Live Rooms V1 strips + * image attachments at room-create time and doesn't carry new + * attachments over the wire, so offering the affordance would + * silently drop the user's image. + */ + attachmentsEnabled?: boolean; + /** + * When false, links to local documents (wikilinks like `[[foo]]` + * and markdown links to `*.md`/`*.mdx`/`*.html`) render as plain + * text instead of clickable anchors. Used by room mode because + * `room.plannotator.ai` has no `/api/doc` or Obsidian endpoint — + * clicking such a link would either trigger a broken fetch or + * navigate the room tab to a non-existent room-origin path. + * Non-local links (http/https) are unaffected. + */ + localDocLinksEnabled?: boolean; + /** + * Notifies the parent that the internal highlight surface has been + * (re)initialized or cleared. Fires once on initial highlighter + * construction and on each `clearAllHighlights()` call. + * + * The callback is a bare event — it carries no number. The parent + * owns the monotonic generation counter so a Viewer remount (which + * resets any Viewer-local state) still produces a fresh value that + * `setState` won't dedupe as a no-op. Child-owned numbering would + * emit `1` on first mount, then `1` again after a remount, and React + * would bail out of the state update, leaving downstream reconcilers + * stuck with a stale applied map. + */ + onHighlightSurfaceReset?: () => void; } export interface ViewerHandle { @@ -154,7 +203,21 @@ export const Viewer = forwardRef(({ sourceInfo, onToggleCheckbox, checkboxOverrides, + readOnly = false, + authorOverride, + attachmentsEnabled = true, + localDocLinksEnabled = true, + onHighlightSurfaceReset, }, ref) => { + // Forward each surface-reset event to the parent, which owns the + // monotonic generation counter that reconcilers depend on. Held in + // a ref so the highlighter hook doesn't re-init just because the + // parent passed a new callback identity. + const onHighlightSurfaceResetRef = useRef(onHighlightSurfaceReset); + useEffect(() => { onHighlightSurfaceResetRef.current = onHighlightSurfaceReset; }, [onHighlightSurfaceReset]); + const handleSurfaceReset = useCallback(() => { + onHighlightSurfaceResetRef.current?.(); + }, []); const [copied, setCopied] = useState(false); const [lightbox, setLightbox] = useState<{ src: string; alt: string } | null>(null); const globalCommentButtonRef = useRef(null); @@ -188,7 +251,38 @@ export const Viewer = forwardRef(({ const stickySentinelRef = useRef(null); const [isStuck, setIsStuck] = useState(false); - // Shared annotation infrastructure via hook + // Read-only transition cleanup (Viewer-owned write state). + // + // `useAnnotationHighlighter` already clears its own pending + // selection/toolbar/popover/picker state when `readOnly` flips true. + // Viewer owns SEPARATE state for the global-comment popover, the + // code-block comment popover (shared `viewerCommentPopover`), the + // code-block quick-label picker, and the code-block hover toolbar. + // Without this effect those would be hidden-but-alive while locked + // and pop back on unlock — contradicting the hook's "cancel in + // progress on lock" contract. Clearing all of them (plus any in- + // flight hover timeout / exit animation) makes Viewer's lock + // response match the hook's. + useEffect(() => { + if (!readOnly) return; + setViewerCommentPopover(null); + setCodeBlockQuickLabelPicker(null); + setHoveredCodeBlock(null); + setIsCodeBlockToolbarExiting(false); + if (hoverTimeoutRef.current) { + clearTimeout(hoverTimeoutRef.current); + hoverTimeoutRef.current = null; + } + }, [readOnly]); + + // Shared annotation infrastructure via hook. + // In read-only mode we neutralize the add callback so a selection + // doesn't produce an annotation. Existing highlights still render + // (annotations is unchanged) and onSelectAnnotation still works for + // navigation; only the CREATE path is suppressed. + const effectiveOnAddAnnotation = readOnly + ? (_: Annotation) => { /* no-op; room is read-only */ } + : onAddAnnotation; const { highlighterRef, toolbarState, @@ -208,15 +302,25 @@ export const Viewer = forwardRef(({ } = useAnnotationHighlighter({ containerRef, annotations, - onAddAnnotation, + onAddAnnotation: effectiveOnAddAnnotation, onSelectAnnotation, selectedAnnotationId, mode, + // Read-only gate inside the hook: existing annotations still + // render via applyAnnotations, but selection-triggered marks, + // toolbar/popover/quick-label states, and the mobile selection + // bridge are all suppressed. Keeps the locked-room editor + // readable + copyable without exposing write affordances. + readOnly, + authorOverride, + onSurfaceReset: handleSurfaceReset, }); - // Refs for code block annotation path - const onAddAnnotationRef = useRef(onAddAnnotation); - useEffect(() => { onAddAnnotationRef.current = onAddAnnotation; }, [onAddAnnotation]); + // Refs for code block annotation path. When readOnly, use the same + // no-op wrapper as the selection path so submissions from the code + // block toolbar don't mutate state or create annotations. + const onAddAnnotationRef = useRef(effectiveOnAddAnnotation); + useEffect(() => { onAddAnnotationRef.current = effectiveOnAddAnnotation; }, [effectiveOnAddAnnotation]); const modeRef = useRef(mode); useEffect(() => { modeRef.current = mode; }, [mode]); @@ -247,7 +351,11 @@ export const Viewer = forwardRef(({ containerRef, highlighterRef, inputMethod, - enabled: !toolbarState && !hookCommentPopover && !viewerCommentPopover && !hookQuickLabelPicker && !codeBlockQuickLabelPicker && !(isPlanDiffActive ?? false), + // Pinpoint is a mutation path (clicking a block creates an + // annotation via handlePinpointCodeBlockClick). Disable it in + // read-only mode so locked rooms don't expose a click target that + // would no-op or make a local-only mark. + enabled: !readOnly && !toolbarState && !hookCommentPopover && !viewerCommentPopover && !hookQuickLabelPicker && !codeBlockQuickLabelPicker && !(isPlanDiffActive ?? false), onCodeBlockClick: handlePinpointCodeBlockClick, }); @@ -339,10 +447,18 @@ export const Viewer = forwardRef(({ codeEl: Element, type: AnnotationType, text?: string, + /* readOnly gate inside the body below — cheaper than threading a + guard through every caller. */ images?: ImageAttachment[], isQuickLabel?: boolean, quickLabelTip?: string, ) => { + // Locked room / read-only: return before any DOM mutation. Without + // this early exit the code block would get a temporary local + // that isn't backed by a canonical annotation, misleading the user + // into thinking their annotation exists. + if (readOnly) return; + const id = `codeblock-${Date.now()}`; const codeText = codeEl.textContent || ''; @@ -363,7 +479,7 @@ export const Viewer = forwardRef(({ text, originalText: codeText, createdA: Date.now(), - author: getIdentity(), + author: authorOverride ?? getIdentity(), images, ...(isQuickLabel ? { isQuickLabel: true } : {}), ...(quickLabelTip ? { quickLabelTip } : {}), @@ -424,7 +540,7 @@ export const Viewer = forwardRef(({ text: text.trim(), originalText: '', createdA: Date.now(), - author: getIdentity(), + author: authorOverride ?? getIdentity(), images, }; onAddAnnotation(newAnnotation); @@ -474,8 +590,8 @@ export const Viewer = forwardRef(({ {/* Header buttons - top right */}
- {/* Attachments button */} - {onAddGlobalAttachment && onRemoveGlobalAttachment && ( + {/* Attachments button — hidden in read-only (e.g. locked room) */} + {!readOnly && onAddGlobalAttachment && onRemoveGlobalAttachment && ( (({ )} {/* CommentGlobal comment button */} - + {!readOnly && ( + + )} {/* Copy plan/file button */}
); @@ -1120,7 +1272,8 @@ const BlockRenderer: React.FC<{ key={i} className="px-3 py-2 text-left font-semibold text-foreground/90 bg-muted/30" > - + ))} @@ -1130,7 +1283,8 @@ const BlockRenderer: React.FC<{ {row.map((cell, cellIdx) => ( - + ))} @@ -1150,7 +1304,8 @@ const BlockRenderer: React.FC<{ className="mb-4 leading-relaxed text-foreground/90 text-[15px]" data-block-id={block.id} > - +

); } diff --git a/packages/ui/components/collab/AdminControls.tsx b/packages/ui/components/collab/AdminControls.tsx new file mode 100644 index 00000000..0c97738c --- /dev/null +++ b/packages/ui/components/collab/AdminControls.tsx @@ -0,0 +1,79 @@ +import React from 'react'; +import type { RoomStatus } from '@plannotator/shared/collab'; + +/** + * Pure admin controls button strip. Renders Lock / Unlock / Delete as + * appropriate for the current room status; emits callbacks for the parent + * (RoomApp) to wire to `room.lock` / `room.unlock` / `room.deleteRoom`. + * + * Visibility: caller renders this component ONLY when + * `room.hasAdminCapability` is true — we don't re-check here to keep the + * component purely presentational. + * + * Buttons disable while an admin command is in-flight (`pendingAction`). + * The parent owns the pending state because `room.lock()` resolves when + * `room.status: locked` is observed, not when the promise resolves. + */ + +export type AdminAction = 'lock' | 'unlock' | 'delete'; + +export interface AdminControlsProps { + roomStatus: RoomStatus | null; + pendingAction?: AdminAction; + onLock(): void; + onUnlock(): void; + onDelete(): void; + className?: string; +} + +export function AdminControls({ + roomStatus, + pendingAction, + onLock, + onUnlock, + onDelete, + className = '', +}: AdminControlsProps): React.ReactElement { + const isLocked = roomStatus === 'locked'; + const isTerminal = roomStatus === 'deleted' || roomStatus === 'expired'; + const anyInFlight = pendingAction !== undefined; + + return ( +
+ {!isLocked && ( + + )} + {isLocked && ( + + )} + +
+ ); +} diff --git a/packages/ui/components/collab/ImageStripNotice.tsx b/packages/ui/components/collab/ImageStripNotice.tsx new file mode 100644 index 00000000..e234a5e8 --- /dev/null +++ b/packages/ui/components/collab/ImageStripNotice.tsx @@ -0,0 +1,43 @@ +import React from 'react'; + +/** + * Dismissible banner shown after room creation when one or more local + * annotations carried images that were stripped for the shared snapshot. + * Text annotations are preserved; only the image attachments are dropped. + * + * Presentational only — parent controls mount/unmount and dismissal. + */ + +export interface ImageStripNoticeProps { + strippedCount: number; + onDismiss(): void; + className?: string; +} + +export function ImageStripNotice({ + strippedCount, + onDismiss, + className = '', +}: ImageStripNoticeProps): React.ReactElement | null { + if (strippedCount <= 0) return null; + return ( +
+
+ Images stripped.{' '} + {strippedCount} item{strippedCount === 1 ? '' : 's'} with image attachments {strippedCount === 1 ? 'was' : 'were'} removed before sharing — text comments are preserved. Your local copies are unchanged. +
+ +
+ ); +} diff --git a/packages/ui/components/collab/JoinRoomGate.tsx b/packages/ui/components/collab/JoinRoomGate.tsx new file mode 100644 index 00000000..307935bd --- /dev/null +++ b/packages/ui/components/collab/JoinRoomGate.tsx @@ -0,0 +1,122 @@ +import React, { useState } from 'react'; +import type { ConnectionStatus } from '@plannotator/shared/collab/client'; +import { PRESENCE_SWATCHES } from '@plannotator/ui/utils/presenceColor'; + +/** + * Pre-connect identity gate for room participants. Parent mounts this + * BEFORE connecting — captures a display name + color, then calls + * `onJoin` with the settled identity. While connecting, this same + * component also surfaces status messages (connecting / authenticating) + * so the user has constant feedback. + * + * Both `initialDisplayName` and `initialColor` should come from the + * user's Plannotator preferences (`getIdentity()` / `getPresenceColor()`). + * Parent persists edits back via the corresponding setters after the + * user submits; the gate itself is pure presentation. + * + * Fatal failure states (malformed URL / access denied / room deleted) + * are rendered by the parent as a full-screen replacement — this gate + * handles only the happy-path and the in-flight connection states. + */ + +export interface JoinRoomSubmit { + displayName: string; + color: string; +} + +export interface JoinRoomGateProps { + initialDisplayName?: string; + initialColor?: string; + connectionStatus: ConnectionStatus; + onJoin(submit: JoinRoomSubmit): void; +} + +function statusMessage(s: ConnectionStatus): string | null { + switch (s) { + case 'connecting': return 'Connecting to room…'; + case 'authenticating': return 'Verifying access…'; + case 'reconnecting': return 'Reconnecting…'; + default: return null; + } +} + +export function JoinRoomGate({ + initialDisplayName = '', + initialColor = PRESENCE_SWATCHES[0], + connectionStatus, + onJoin, +}: JoinRoomGateProps): React.ReactElement { + const [displayName, setDisplayName] = useState(initialDisplayName); + const [color, setColor] = useState(initialColor); + const [submitted, setSubmitted] = useState(false); + + const showStatus = submitted && statusMessage(connectionStatus); + + function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + const trimmed = displayName.trim(); + if (!trimmed) return; + setSubmitted(true); + onJoin({ displayName: trimmed, color }); + } + + return ( +
+
+

Join live review

+ +
+ + setDisplayName(e.target.value)} + disabled={submitted} + className="w-full px-2 py-1 border rounded text-sm" + placeholder="Your name" + autoFocus + /> +
+ +
+ +
+ {PRESENCE_SWATCHES.map(s => ( +
+
+ + {showStatus && ( +
+ {showStatus} +
+ )} + +
+ +
+
+
+ ); +} diff --git a/packages/ui/components/collab/ParticipantAvatars.test.tsx b/packages/ui/components/collab/ParticipantAvatars.test.tsx new file mode 100644 index 00000000..88621de3 --- /dev/null +++ b/packages/ui/components/collab/ParticipantAvatars.test.tsx @@ -0,0 +1,60 @@ +import { describe, expect, test } from 'bun:test'; +import { render } from '@testing-library/react'; +import { ParticipantAvatars } from './ParticipantAvatars'; +import type { PresenceState } from '@plannotator/shared/collab'; + +function peer(name: string, color = '#abc'): PresenceState { + return { + user: { id: name.toLowerCase(), name, color }, + cursor: null, + }; +} + +describe('ParticipantAvatars', () => { + test('returns null with no peers', () => { + const { container } = render(); + expect(container.querySelector('[data-testid="participant-avatars"]')).toBeNull(); + }); + + test('renders one avatar per peer with correct initial', () => { + const { container } = render( + , + ); + const avatars = container.querySelectorAll('[data-participant-id]'); + expect(avatars.length).toBe(2); + const initials = Array.from(avatars).map(a => a.textContent); + expect(initials).toEqual(['A', 'B']); // sorted by name + }); + + test('collapses extras above maxVisible into "+N"', () => { + const presence: Record = {}; + for (let i = 0; i < 6; i++) { + presence[`c${i}`] = peer(String.fromCharCode(65 + i)); + } + const { container } = render( + , + ); + expect(container.querySelectorAll('[data-participant-id]').length).toBe(3); + const overflow = container.querySelector('[data-testid="participant-overflow"]'); + expect(overflow?.textContent).toBe('+3'); + }); + + test('overflow title lists names not shown', () => { + const presence = { + c1: peer('Alice'), c2: peer('Bob'), c3: peer('Charlie'), c4: peer('Dana'), c5: peer('Eve'), + }; + const { container } = render( + , + ); + const overflow = container.querySelector('[data-testid="participant-overflow"]'); + expect(overflow?.getAttribute('title')).toBe('Charlie, Dana, Eve'); + }); + + test('falls back to "?" initial when name is blank', () => { + const { container } = render( + , + ); + const avatars = container.querySelectorAll('[data-participant-id]'); + expect(avatars[0].textContent).toBe('G'); // falls through to "Guest" → "G" + }); +}); diff --git a/packages/ui/components/collab/ParticipantAvatars.tsx b/packages/ui/components/collab/ParticipantAvatars.tsx new file mode 100644 index 00000000..607ae0b2 --- /dev/null +++ b/packages/ui/components/collab/ParticipantAvatars.tsx @@ -0,0 +1,80 @@ +import React, { useMemo } from 'react'; +import type { PresenceState } from '@plannotator/shared/collab'; + +/** + * Pure avatar stack for room participants. Reads from `remotePresence` + * (keyed by clientId) and renders one colored initial per peer. Does NOT + * include the local user — callers render their own user elsewhere. + * + * Overflow: show at most `maxVisible` avatars; the rest are summarized + * as "+N" with a tooltip listing the extra names. + */ + +export interface ParticipantAvatarsProps { + remotePresence: Record; + maxVisible?: number; + className?: string; +} + +interface Participant { + clientId: string; + name: string; + color: string; + initial: string; +} + +function deriveParticipants( + remotePresence: Record, +): Participant[] { + const out: Participant[] = []; + for (const [clientId, p] of Object.entries(remotePresence)) { + const name = (p.user?.name ?? '').trim() || 'Guest'; + const color = p.user?.color ?? '#888'; + const initial = name.charAt(0).toUpperCase() || '?'; + out.push({ clientId, name, color, initial }); + } + // Stable sort by name so order doesn't thrash when presence maps rehydrate. + out.sort((a, b) => a.name.localeCompare(b.name)); + return out; +} + +export function ParticipantAvatars({ + remotePresence, + maxVisible = 4, + className = '', +}: ParticipantAvatarsProps): React.ReactElement | null { + const participants = useMemo(() => deriveParticipants(remotePresence), [remotePresence]); + if (participants.length === 0) return null; + + const visible = participants.slice(0, maxVisible); + const overflow = participants.slice(maxVisible); + const overflowTitle = overflow.map(p => p.name).join(', '); + + return ( +
+ {visible.map(p => ( + + {p.initial} + + ))} + {overflow.length > 0 && ( + + +{overflow.length} + + )} +
+ ); +} diff --git a/packages/ui/components/collab/RemoteCursorLayer.tsx b/packages/ui/components/collab/RemoteCursorLayer.tsx new file mode 100644 index 00000000..f88ccd43 --- /dev/null +++ b/packages/ui/components/collab/RemoteCursorLayer.tsx @@ -0,0 +1,449 @@ +import React, { useEffect, useMemo, useRef } from 'react'; +import type { PresenceState, CursorState } from '@plannotator/shared/collab'; + +/** + * Absolute-positioned overlay that renders remote cursor flags. Parent + * mounts this as a sibling of the Viewer inside the scroll viewport so + * cursors scroll with content without any extra math. + * + * Rendering model: + * - One `
` per remote client; mount/unmount is React-driven so + * adds/removes during a session update cleanly. + * - Position is NOT React state. A single `requestAnimationFrame` + * loop reads the latest target from `remotePresence`/`containerRect` + * refs, lerps each cursor's current position toward its target, and + * mutates `transform` on the DOM node directly. This matches the + * industry pattern used by Figma-style / Liveblocks-style cursor + * systems — avoids React reconciliation on every frame (~60Hz * N + * cursors would otherwise churn the scheduler for nothing) and + * leans on the GPU compositor for `translate3d`. + * + * Smoothing: + * - Latest-wins target per clientId. On each frame: lerp toward target + * with a fixed alpha (~0.3 feels responsive without overshoot). + * - Snap (bypass lerp) when: + * 1. First frame for a clientId — avoid sliding from (0,0). + * 2. Cursor reappears after going null/idle — treat like first. + * 3. Single-frame distance > SNAP_THRESHOLD — usually a + * coordinate-space flip (block ↔ viewport) or scroll jump, + * where animating the "swoosh" would look worse than snapping. + * + * Offscreen indicators: + * - When a cursor's resolved position falls outside the overlay + * container rect, the same element is repurposed as a small edge + * label (`↑ Alice` / `↓ Alice`) pinned to the nearest edge and + * clamped horizontally. Tells the reader "they're somewhere else + * in the doc" instead of letting the cursor vanish. + * + * Coordinate model (matches the protocol's `CursorState`): + * - `coordinateSpace: 'document'` — (x, y) in scroll-document coords. + * Render at (x - scrollX, y - scrollY) within the viewport. + * - `coordinateSpace: 'viewport'` — (x, y) in viewport coords. + * Rendered as-is minus the container offset. + * - `coordinateSpace: 'block'` — relative to the block's bounding + * rect, identified by `blockId`. Resolved via `[data-block-id=…]`. + * + * Local cursor is NOT rendered — that cursor is the browser's own caret. + */ + +export interface RemoteCursorLayerProps { + remotePresence: Record; + /** + * Bounding rect of the overlay container in viewport coords. Used to + * translate viewport-space cursor coords into overlay-local coords + * and to decide whether a cursor is onscreen vs. pinned to an edge. + */ + containerRect: DOMRect | null; + /** ParentNode to search within for block elements. Defaults to document. */ + root?: ParentNode; + className?: string; +} + +interface CursorRenderState { + displayX: number; + displayY: number; + /** True once we've ever painted this cursor; toggled off on idle. */ + everRendered: boolean; +} + +function findBlockRect(blockId: string, root: ParentNode): DOMRect | null { + // `blockId` arrives as decrypted remote presence. The bundled UI only + // emits real `data-block-id` values, but anything holding the room URL + + // key (direct WebSocket client, modified console, agent) can send an + // arbitrary string. A newline or other CSS-invalid character makes the + // selector throw `SyntaxError` during render, taking the whole cursor + // layer down for every participant. Escape safely and swallow any + // residual selector failures so bad remote input just drops the cursor. + try { + const escaped = + typeof CSS !== 'undefined' && typeof CSS.escape === 'function' + ? CSS.escape(blockId) + : blockId.replace(/["\\]/g, '\\$&'); + const el = root.querySelector(`[data-block-id="${escaped}"]`) as HTMLElement | null; + return el ? el.getBoundingClientRect() : null; + } catch { + return null; + } +} + +/** + * Find the plan's scroll viewport element. App tags it with + * `data-plan-scroll-viewport` when the OverlayScrollbars instance + * settles. `document` cursors are resolved against this element's + * rect + scroll offset — that's the coordinate space LocalPresenceEmitter + * now writes to, so every participant resolves the same content point + * regardless of their own scroll position. + * + * Fall-through to `null` is safe — the caller skips rendering when + * `resolveCursor` returns null, so the cursor waits for the scroll + * area to mount instead of rendering at a garbage position. + */ +function findScrollViewport(): HTMLElement | null { + return typeof document !== 'undefined' + ? document.querySelector('[data-plan-scroll-viewport]') + : null; +} + +function resolveCursor( + cursor: CursorState, + root: ParentNode, +): { viewportX: number; viewportY: number } | null { + switch (cursor.coordinateSpace) { + case 'viewport': + return { viewportX: cursor.x, viewportY: cursor.y }; + case 'document': { + // Content-space: cursor.(x, y) is relative to the scroll + // container's inner content origin. Map to this viewer's + // viewport by re-applying their scroll container rect and + // current scroll position. + const vp = findScrollViewport(); + if (!vp) return null; + const rect = vp.getBoundingClientRect(); + return { + viewportX: rect.left + cursor.x - vp.scrollLeft, + viewportY: rect.top + cursor.y - vp.scrollTop, + }; + } + case 'block': { + // Kept for any direct-agent or future client that still emits + // block coords; our bundled UI no longer emits this case. + if (!cursor.blockId) return null; + const blockRect = findBlockRect(cursor.blockId, root); + if (!blockRect) return null; + return { + viewportX: blockRect.left + cursor.x, + viewportY: blockRect.top + cursor.y, + }; + } + default: + return null; + } +} + +// Line-height fallback for cursor caret — we don't know the remote +// user's line-height at the cursor, and resolving per-block metrics on +// every update would be expensive. 18px covers standard body copy. +const CURSOR_HEIGHT_PX = 18; + +// Smoothing tuning. See component docstring. +const LERP_ALPHA = 0.3; +const SNAP_THRESHOLD_PX = 600; + +/** + * Inset applied when clamping an offscreen cursor to the container + * edge. Keeps the pinned glyph fully visible instead of clipping half + * of it against the edge. + */ +const EDGE_INSET_PX = 8; + +export function RemoteCursorLayer({ + remotePresence, + containerRect, + root = typeof document !== 'undefined' ? document : undefined as unknown as ParentNode, + className = '', +}: RemoteCursorLayerProps): React.ReactElement | null { + // Refs the rAF loop reads. React updates these on every prop change; + // the loop picks up the latest values on its next frame without + // depending on React render cycles for motion. + const presenceRef = useRef(remotePresence); + presenceRef.current = remotePresence; + const containerRectRef = useRef(containerRect); + containerRectRef.current = containerRect; + const rootRef = useRef(root); + rootRef.current = root; + + const renderStatesRef = useRef>(new Map()); + const nodeRefsRef = useRef>(new Map()); + + // Gate the animation loop on actually having remote cursors to draw. + // Solo rooms (the common case) would otherwise run a 60Hz no-op loop + // for every session. Effect restarts only when this boolean flips + // empty↔non-empty, so continuous cursor updates during a busy session + // don't retear the loop down. + const hasRemoteCursors = Object.keys(remotePresence).length > 0; + + useEffect(() => { + if (!hasRemoteCursors) return; + let rafId = 0; + + const tick = () => { + const presence = presenceRef.current; + const rect = containerRectRef.current; + const rootEl = rootRef.current ?? (typeof document !== 'undefined' ? document : null); + if (!rootEl) { + rafId = requestAnimationFrame(tick); + return; + } + + const states = renderStatesRef.current; + const nodes = nodeRefsRef.current; + + // Drop render state for cursors no longer in presence. The node + // itself is unmounted by React on the next render — we just + // release our tracking so a rejoin starts fresh (snap). + for (const id of Array.from(states.keys())) { + if (!(id in presence)) { + states.delete(id); + } + } + + for (const [clientId, p] of Object.entries(presence)) { + const node = nodes.get(clientId); + if (!node) continue; // React hasn't committed the element yet. + + const resolved = p.cursor ? resolveCursor(p.cursor, rootEl) : null; + if (!resolved) { + // Null / unresolvable cursor — mark idle and hide. Next + // non-null packet snaps back in from the new position instead + // of sliding from wherever the ghost was left. + node.style.display = 'none'; + const prev = states.get(clientId); + if (prev) prev.everRendered = false; + continue; + } + + // Target in overlay-local space. + const targetX = resolved.viewportX - (rect?.left ?? 0); + const targetY = resolved.viewportY - (rect?.top ?? 0); + + let state = states.get(clientId); + if (!state || !state.everRendered) { + // First paint for this clientId (or just came back from + // idle): snap so we don't see a slide from (0,0) or the + // previous stale position. + state = { displayX: targetX, displayY: targetY, everRendered: true }; + states.set(clientId, state); + } else { + const dx = targetX - state.displayX; + const dy = targetY - state.displayY; + if (Math.hypot(dx, dy) > SNAP_THRESHOLD_PX) { + // Huge single-frame jump — usually a block↔viewport + // coordinate flip or a scroll that moved the block rect + // hundreds of pixels. Animating it looks like a + // full-screen swoosh; snap instead. + state.displayX = targetX; + state.displayY = targetY; + } else { + state.displayX += dx * LERP_ALPHA; + state.displayY += dy * LERP_ALPHA; + } + } + + // Onscreen check against the overlay container bounds. The + // container IS the editor viewport in our current layout, so + // "outside container" == "outside visible editor" == pin to + // the nearest edge. + const containerWidth = rect?.width ?? (typeof window !== 'undefined' ? window.innerWidth : 0); + const containerHeight = rect?.height ?? (typeof window !== 'undefined' ? window.innerHeight : 0); + const above = state.displayY < 0; + const below = state.displayY > containerHeight; + const leftOf = state.displayX < 0; + const rightOf = state.displayX > containerWidth; + const offscreen = above || below || leftOf || rightOf; + + let renderX = state.displayX; + let renderY = state.displayY; + let edgeDirection: 'none' | 'above' | 'below' | 'left' | 'right' = 'none'; + if (offscreen) { + // Clamp to the nearest edge with a small inset so the glyph + // stays fully visible. Direction picks vertical over + // horizontal because most scrolling is vertical; a corner- + // case cursor gets the vertical indicator with horizontal + // clamping applied for position. + renderX = Math.max(EDGE_INSET_PX, Math.min(containerWidth - EDGE_INSET_PX, state.displayX)); + renderY = Math.max(EDGE_INSET_PX, Math.min(containerHeight - EDGE_INSET_PX, state.displayY)); + edgeDirection = above ? 'above' : below ? 'below' : leftOf ? 'left' : 'right'; + } + + node.style.display = ''; + node.style.transform = `translate3d(${renderX}px, ${renderY}px, 0)`; + + // Toggle the visual via dataset — CSS (below) swaps caret vs. + // edge indicator based on `data-edge-direction`. + if (node.dataset.edgeDirection !== edgeDirection) { + node.dataset.edgeDirection = edgeDirection; + } + } + + rafId = requestAnimationFrame(tick); + }; + + rafId = requestAnimationFrame(tick); + return () => cancelAnimationFrame(rafId); + }, [hasRemoteCursors]); + + // React owns the set of mounted cursor nodes (keyed by clientId). + // The rAF loop positions them. Recomputed each render — trivial cost + // and keeps the list stable in key order so React doesn't reorder + // DOM nodes when presence iteration order shifts. + const clientIds = useMemo( + () => Object.keys(remotePresence).sort(), + // `remotePresence` is a new object each state emit, but its key + // set changes rarely; depending on the whole object is fine given + // the sort is O(n log n) on tiny n. + [remotePresence], + ); + + if (clientIds.length === 0) return null; + + return ( +
+ {/* + Self-contained style block. Keeps the swap between onscreen + caret and offscreen edge-pin pure CSS, driven by the + `data-edge-direction` attribute the rAF loop mutates on each + cursor node. + .remote-cursor-offscreen is default-hidden via CSS here + (NOT via inline `style={{ display: 'flex' }}` on the element) + because inline styles beat stylesheet rules — with an inline + default of flex, the `data-edge-direction="none"` rule that + tries to hide the pill would lose and both variants would + paint on every cursor. + */} + + {clientIds.map(clientId => { + const p = remotePresence[clientId]; + const name = p?.user?.name ?? 'Guest'; + const color = p?.user?.color ?? '#888'; + return ( + + ); + })} +
+ ); +} + +/** + * Single cursor glyph. Position is NEVER set here — the parent's rAF + * loop mutates `transform` and `data-edge-direction` on the node + * directly via the shared ref map. This component only owns the + * static-per-client bits: color, name, and the SVG/label markup. + * + * Contains both the normal caret+label and the offscreen edge-pin + * variants in the DOM; CSS selectors on the parent's `data-edge-*` + * dataset decide which is visible. Keeps motion allocation-free + * since the DOM structure never changes during animation. + */ +function RemoteCursor({ + clientId, + name, + color, + nodeRefsRef, +}: { + clientId: string; + name: string; + color: string; + nodeRefsRef: React.RefObject>; +}): React.ReactElement { + // Callback ref keyed by clientId. Each mount/unmount registers or + // releases in the shared ref map that the rAF loop reads. + const setRef = (el: HTMLDivElement | null) => { + const map = nodeRefsRef.current; + if (!map) return; + if (el) map.set(clientId, el); + else map.delete(clientId); + }; + + return ( +