diff --git a/docs/collaboration.md b/docs/collaboration.md index 6fdea724..5a1bed93 100644 --- a/docs/collaboration.md +++ b/docs/collaboration.md @@ -143,15 +143,18 @@ Only the following public cursor payload is propagated under the awareness } ``` -`userId` must be nonempty, descriptive, and not numeric-only. `displayName` must -be nonempty. `cursorColor` must be a six-digit hexadecimal color. Do not put -access tokens, email addresses, roles, tenant IDs, document permissions, or any -other secret in awareness: awareness state is ephemeral, broadcast to peers, -and intentionally not used as an authorization source. +`userId` must be nonempty, descriptive, not numeric-only, and no longer than 80 +Unicode code points. `displayName` must be nonempty and is published and rendered +as at most 80 Unicode code points. `cursorColor` must be a six-digit hexadecimal +color. Do not put access tokens, email addresses, roles, tenant IDs, document +permissions, or any other secret in awareness: awareness state is ephemeral, +broadcast to peers, and intentionally not used as an authorization source. Remote names are inserted with `textContent`, length-bounded, and never treated -as markup. Invalid remote colors fall back to a safe color. Cursor labels choose -black or white text from relative luminance for readable contrast. +as markup. Invalid remote colors fall back to a safe color. Remote collaborator +counts ignore blank, numeric-only, and over-80-code-point public identifiers. +Cursor labels choose black or white text from relative luminance for readable +contrast. ## Accessibility diff --git a/src/collaboration/awareness.test.ts b/src/collaboration/awareness.test.ts index 4d41fbca..bbaa6e98 100644 --- a/src/collaboration/awareness.test.ts +++ b/src/collaboration/awareness.test.ts @@ -218,6 +218,14 @@ describe('collaboration awareness presentation', () => { expect(collaborationConnectionLabel(status)).toBe(label); }); + it('rejects a runtime connection status outside the public states', () => { + expect(() => collaborationConnectionLabel('failed' as never)).toThrowError( + new RangeError( + 'Collaboration connection status must be connecting, connected, disconnected, or offline.', + ), + ); + }); + it('renders a text-only high-contrast cursor for valid remote data', () => { const cursor = renderCollaborationCursor({ name: 'Remote Alice', diff --git a/src/collaboration/awareness.ts b/src/collaboration/awareness.ts index 0444244d..fa345a3c 100644 --- a/src/collaboration/awareness.ts +++ b/src/collaboration/awareness.ts @@ -23,14 +23,103 @@ const CURSOR_COLOR_PATTERN = /^#[0-9a-fA-F]{6}$/; const NUMERIC_IDENTIFIER_PATTERN = /^\d+$/; const FALLBACK_CURSOR_COLOR = '#475569'; const MAX_CURSOR_LABEL_LENGTH = 80; +const MAX_PUBLIC_IDENTIFIER_LENGTH = 80; +const MAX_REMOTE_FIELD_SOURCE_LENGTH = 1_024; +const MAX_LOCAL_FIELD_SOURCE_LENGTH = 1_024; +type CollaborationUserField = 'userId' | 'displayName' | 'cursorColor'; + +/** Reject malformed or oversized local identity fields before normalization. */ +function assertCollaborationUserStringField( + field: CollaborationUserField, + value: unknown, +): asserts value is string { + if (typeof value !== 'string') { + throw new Error(`collaboration ${field} must be a string`); + } + if (value.length > MAX_LOCAL_FIELD_SOURCE_LENGTH) { + throw new Error( + `collaboration ${field} must be at most ${MAX_LOCAL_FIELD_SOURCE_LENGTH} UTF-16 code units before normalization`, + ); + } +} + +/** Read one host-owned local identity field without leaking getter failures. */ +function readCollaborationUserStringField( + user: CollaborationUser, + field: CollaborationUserField, +): string { + let value: unknown; + try { + value = user[field]; + } catch { + throw new Error(`collaboration ${field} must be a string`); + } + assertCollaborationUserStringField(field, value); + return value; +} + +/** Trim and bound a public cursor label without splitting Unicode code points. */ +function truncateCursorLabel(value: string): string { + const trimmed = value.trim(); + let bounded = ''; + let count = 0; + for (const codePoint of trimmed) { + if (count >= MAX_CURSOR_LABEL_LENGTH) break; + bounded += codePoint; + count += 1; + } + return bounded; +} + +/** Return whether public awareness metadata exceeds its Unicode code-point bound. */ +function exceedsPublicIdentifierLength(value: string): boolean { + let count = 0; + for (const _codePoint of value) { + count += 1; + if (count > MAX_PUBLIC_IDENTIFIER_LENGTH) return true; + } + return false; +} + +/** Read and validate the host-owned awareness capability without leaking failures. */ +function readCompatibleCollaborationAwareness( + provider: CollaborationProviderLike, +): CollaborationAwareness { + try { + const awareness = provider.awareness as + | Partial + | undefined; + if ( + awareness !== undefined && + typeof awareness.clientID === 'number' && + awareness.states instanceof Map && + typeof awareness.getLocalState === 'function' && + typeof awareness.getStates === 'function' && + typeof awareness.setLocalStateField === 'function' && + typeof awareness.on === 'function' && + typeof awareness.off === 'function' + ) { + return awareness as CollaborationAwareness; + } + } catch { + // Normalize host capability access failures at the public Inkspan boundary. + } + + throw new Error( + 'collaboration provider must expose a compatible Yjs awareness instance', + ); +} /** Validate and serialize the only public fields permitted in awareness. */ export function serializeCollaborationUser( user: CollaborationUser, ): CollaborationCursorUser { - const id = user.userId.trim(); - const name = user.displayName.trim(); - const color = user.cursorColor.trim(); + const sourceId = readCollaborationUserStringField(user, 'userId'); + const sourceName = readCollaborationUserStringField(user, 'displayName'); + const sourceColor = readCollaborationUserStringField(user, 'cursorColor'); + const id = sourceId.trim(); + const name = truncateCursorLabel(sourceName); + const color = sourceColor.trim(); if (id === '') { throw new Error('collaboration userId must not be empty'); @@ -38,6 +127,11 @@ export function serializeCollaborationUser( if (NUMERIC_IDENTIFIER_PATTERN.test(id)) { throw new Error('collaboration userId must be descriptive and nonnumeric'); } + if (exceedsPublicIdentifierLength(id)) { + throw new Error( + 'collaboration userId must be at most 80 Unicode code points', + ); + } if (name === '') { throw new Error('collaboration displayName must not be empty'); } @@ -60,23 +154,7 @@ export function assertCollaborationConfiguration( } if (!provider) return; - const awareness = provider.awareness as - | Partial - | undefined; - if ( - !awareness || - typeof awareness.clientID !== 'number' || - !(awareness.states instanceof Map) || - typeof awareness.getLocalState !== 'function' || - typeof awareness.getStates !== 'function' || - typeof awareness.setLocalStateField !== 'function' || - typeof awareness.on !== 'function' || - typeof awareness.off !== 'function' - ) { - throw new Error( - 'collaboration provider must expose a compatible Yjs awareness instance', - ); - } + readCompatibleCollaborationAwareness(provider); } /** @@ -86,7 +164,7 @@ export function assertCollaborationConfiguration( export function createScopedCollaborationProvider( provider: CollaborationProviderLike, ): ScopedCollaborationProvider { - const source = provider.awareness; + const source = readCompatibleCollaborationAwareness(provider); const listenerWrappers: Record< CollaborationAwarenessEvent, Map<(...args: unknown[]) => void, (...args: unknown[]) => void> @@ -109,13 +187,21 @@ export function createScopedCollaborationProvider( on: (event, listener) => { if (listenerWrappers[event].has(listener)) return; const wrapper = (...args: unknown[]) => listener(...args); + try { + source.on(event, wrapper); + } catch { + throw new Error('collaboration awareness listener registration failed'); + } listenerWrappers[event].set(listener, wrapper); - source.on(event, wrapper); }, off: (event, listener) => { const wrapper = listenerWrappers[event].get(listener); if (!wrapper) return; - source.off(event, wrapper); + try { + source.off(event, wrapper); + } catch { + throw new Error('collaboration awareness listener removal failed'); + } listenerWrappers[event].delete(listener); }, }; @@ -128,7 +214,12 @@ export function createScopedCollaborationProvider( disposed = true; for (const event of ['change', 'update'] as const) { for (const wrapper of listenerWrappers[event].values()) { - source.off(event, wrapper); + try { + source.off(event, wrapper); + } catch { + // Host-owned listener teardown must not abort remaining cleanup or + // leak a private provider failure through React effect disposal. + } } listenerWrappers[event].clear(); } @@ -136,25 +227,52 @@ export function createScopedCollaborationProvider( }; } -/** Count remote awareness clients carrying a valid public user identifier. */ +/** Read one own enumerable data field without invoking caller-defined accessors. */ +function ownEnumerableDataValue( + value: unknown, + property: string, +): unknown { + if (typeof value !== 'object' || value === null) return undefined; + try { + const descriptor = Object.getOwnPropertyDescriptor(value, property); + if (!descriptor) return undefined; + if (!descriptor.enumerable) return undefined; + if (!('value' in descriptor)) return undefined; + return descriptor.value; + } catch { + return undefined; + } +} + +/** Count valid remote collaborators without leaking host awareness failures. */ export function countRemoteCollaborators( awareness: CollaborationAwareness | undefined, ): number { if (!awareness) return 0; - let count = 0; - for (const [clientId, state] of awareness.getStates()) { - if (clientId === awareness.clientID) continue; - const user = state.user; - if ( - typeof user === 'object' && - user !== null && - typeof (user as Record).id === 'string' && - (user as Record).id !== '' - ) { + try { + const localClientId = awareness.clientID; + let count = 0; + for (const [clientId, state] of awareness.getStates()) { + if (clientId === localClientId) continue; + const user = ownEnumerableDataValue(state, 'user'); + if (typeof user !== 'object' || user === null) continue; + const id = ownEnumerableDataValue(user, 'id'); + if (typeof id !== 'string') continue; + if (id.length > MAX_REMOTE_FIELD_SOURCE_LENGTH) continue; + const normalizedId = id.trim(); + if ( + normalizedId === '' || + NUMERIC_IDENTIFIER_PATTERN.test(normalizedId) || + exceedsPublicIdentifierLength(normalizedId) + ) { + continue; + } count += 1; } + return count; + } catch { + return 0; } - return count; } /** Convert a host connection state into concise status-region text. */ @@ -162,6 +280,8 @@ export function collaborationConnectionLabel( status: CollaborationConnectionStatus | undefined, ): string { switch (status) { + case undefined: + return 'Collaboration ready'; case 'connecting': return 'Connecting'; case 'connected': @@ -171,7 +291,9 @@ export function collaborationConnectionLabel( case 'offline': return 'Offline'; default: - return 'Collaboration ready'; + throw new RangeError( + 'Collaboration connection status must be connecting, connected, disconnected, or offline.', + ); } } @@ -183,9 +305,12 @@ export function renderCollaborationCursor( user: Record, ): HTMLElement { const color = collaborationCursorColor(user); + const remoteName = ownEnumerableDataValue(user, 'name'); const name = - typeof user.name === 'string' && user.name.trim() !== '' - ? user.name.trim().slice(0, MAX_CURSOR_LABEL_LENGTH) + typeof remoteName === 'string' && + remoteName.length <= MAX_REMOTE_FIELD_SOURCE_LENGTH && + remoteName.trim() !== '' + ? truncateCursorLabel(remoteName) : 'Collaborator'; const caret = document.createElement('span'); @@ -214,6 +339,15 @@ export function renderCollaborationSelection( /** Select black or white text using the WCAG relative-luminance threshold. */ export function contrastingTextColor(hexColor: string): '#000000' | '#ffffff' { + if ( + typeof hexColor !== 'string' || + !CURSOR_COLOR_PATTERN.test(hexColor) + ) { + throw new RangeError( + 'collaboration contrast color must be a six-digit hexadecimal color', + ); + } + const red = Number.parseInt(hexColor.slice(1, 3), 16) / 255; const green = Number.parseInt(hexColor.slice(3, 5), 16) / 255; const blue = Number.parseInt(hexColor.slice(5, 7), 16) / 255; @@ -228,8 +362,9 @@ export function contrastingTextColor(hexColor: string): '#000000' | '#ffffff' { /** Normalize untrusted remote awareness colors to a strict CSS-safe token. */ function collaborationCursorColor(user: Record): string { - return typeof user.color === 'string' && - CURSOR_COLOR_PATTERN.test(user.color) - ? user.color.toLowerCase() + const remoteColor = ownEnumerableDataValue(user, 'color'); + return typeof remoteColor === 'string' && + CURSOR_COLOR_PATTERN.test(remoteColor) + ? remoteColor.toLowerCase() : FALLBACK_CURSOR_COLOR; } diff --git a/src/collaboration/awarenessContrastColor.test.ts b/src/collaboration/awarenessContrastColor.test.ts new file mode 100644 index 00000000..57327df9 --- /dev/null +++ b/src/collaboration/awarenessContrastColor.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest'; +import { contrastingTextColor } from './awareness.js'; + +const INVALID_CONTRAST_COLOR_ERROR = new RangeError( + 'collaboration contrast color must be a six-digit hexadecimal color', +); + +describe('public collaboration contrast-color contract', () => { + it.each(['#fff', '#zzzzzz', 'red', '']) ( + 'rejects malformed color token %j instead of returning a plausible contrast', + (color) => { + expect(() => contrastingTextColor(color)).toThrowError( + INVALID_CONTRAST_COLOR_ERROR, + ); + }, + ); + + it('rejects non-string runtime input without coercion', () => { + expect(() => contrastingTextColor(7 as never)).toThrowError( + INVALID_CONTRAST_COLOR_ERROR, + ); + }); + + it('preserves valid uppercase and lowercase six-digit colors', () => { + expect(contrastingTextColor('#FFFFFF')).toBe('#000000'); + expect(contrastingTextColor('#000000')).toBe('#ffffff'); + expect(contrastingTextColor('#777777')).toBe('#000000'); + }); +}); diff --git a/src/collaboration/awarenessCountFailureContainment.test.ts b/src/collaboration/awarenessCountFailureContainment.test.ts new file mode 100644 index 00000000..b42272b1 --- /dev/null +++ b/src/collaboration/awarenessCountFailureContainment.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest'; +import { countRemoteCollaborators } from './awareness.js'; +import type { CollaborationAwareness } from './types.js'; + +function baseAwareness(): CollaborationAwareness { + const states = new Map>(); + return { + clientID: 11, + states, + getLocalState: () => null, + getStates: () => states, + setLocalStateField: () => undefined, + on: () => undefined, + off: () => undefined, + }; +} + +describe('remote collaborator count failure containment', () => { + it('fails closed without leaking getStates failures', () => { + const privateFailure = { secret: 'provider-get-states-private' }; + const awareness = { + ...baseAwareness(), + getStates: () => { + throw privateFailure; + }, + }; + + let observed: unknown; + let count: number | undefined; + try { + count = countRemoteCollaborators(awareness); + } catch (error) { + observed = error; + } + + expect(observed).toBeUndefined(); + expect(count).toBe(0); + }); + + it('fails closed without leaking clientID access failures', () => { + const privateFailure = { secret: 'provider-client-id-private' }; + const states = new Map>([ + [12, { user: { id: 'remote-one' } }], + ]); + const awareness = { + ...baseAwareness(), + states, + getStates: () => states, + } as CollaborationAwareness; + Object.defineProperty(awareness, 'clientID', { + enumerable: true, + get() { + throw privateFailure; + }, + }); + + let observed: unknown; + let count: number | undefined; + try { + count = countRemoteCollaborators(awareness); + } catch (error) { + observed = error; + } + + expect(observed).toBeUndefined(); + expect(count).toBe(0); + }); +}); diff --git a/src/collaboration/awarenessDisposeFailureContainment.test.ts b/src/collaboration/awarenessDisposeFailureContainment.test.ts new file mode 100644 index 00000000..03320487 --- /dev/null +++ b/src/collaboration/awarenessDisposeFailureContainment.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it, vi } from 'vitest'; +import { createScopedCollaborationProvider } from './awareness.js'; +import type { + CollaborationAwareness, + CollaborationAwarenessEvent, +} from './types.js'; + +describe('scoped collaboration provider cleanup containment', () => { + it('attempts every listener detachment without leaking host cleanup failures', () => { + const privateFailure = new Error('sensitive-provider-cleanup-internal'); + const sourceListeners: Record< + CollaborationAwarenessEvent, + Set<(...args: unknown[]) => void> + > = { + change: new Set(), + update: new Set(), + }; + let offCalls = 0; + const source: CollaborationAwareness = { + clientID: 7, + states: new Map(), + getLocalState: () => null, + getStates: () => new Map(), + setLocalStateField: () => undefined, + on: (event, listener) => sourceListeners[event].add(listener), + off: (event, listener) => { + offCalls += 1; + if (event === 'change') throw privateFailure; + sourceListeners[event].delete(listener); + }, + }; + const scoped = createScopedCollaborationProvider({ awareness: source }); + const changeListener = vi.fn(); + const updateListener = vi.fn(); + + scoped.awareness.on('change', changeListener); + scoped.awareness.on('update', updateListener); + + let observed: unknown; + try { + scoped.dispose(); + } catch (error) { + observed = error; + } + + expect(observed).toBeUndefined(); + expect(offCalls).toBe(2); + expect(sourceListeners.update.size).toBe(0); + + scoped.dispose(); + expect(offCalls).toBe(2); + }); +}); diff --git a/src/collaboration/awarenessIdentityCount.test.ts b/src/collaboration/awarenessIdentityCount.test.ts new file mode 100644 index 00000000..553a8204 --- /dev/null +++ b/src/collaboration/awarenessIdentityCount.test.ts @@ -0,0 +1,187 @@ +import { describe, expect, it, vi } from 'vitest'; +import { countRemoteCollaborators } from './awareness.js'; +import type { CollaborationAwareness } from './types.js'; + +describe('collaboration awareness identity counting', () => { + it('excludes remote identities that violate the public identifier contract', () => { + const states = new Map>([ + [11, { user: { id: 'local-editor' } }], + [12, { user: { id: ' ' } }], + [13, { user: { id: '12345' } }], + [14, { user: { id: `editor-${'a'.repeat(74)}` } }], + [15, { user: { id: 'editor-bob' } }], + [16, { user: { id: `${'a'.repeat(79)}😀` } }], + ]); + const awareness: CollaborationAwareness = { + clientID: 11, + states, + getLocalState: () => states.get(11) ?? null, + getStates: () => states, + setLocalStateField: () => undefined, + on: () => undefined, + off: () => undefined, + }; + + expect(countRemoteCollaborators(awareness)).toBe(2); + }); + + it('rejects oversized remote identifiers before normalization', () => { + const oversizedId = `editor-${'a'.repeat(1_024)}`; + const states = new Map>([ + [11, { user: { id: 'local-editor' } }], + [12, { user: { id: oversizedId } }], + [13, { user: { id: ' editor-bob ' } }], + ]); + const awareness: CollaborationAwareness = { + clientID: 11, + states, + getLocalState: () => states.get(11) ?? null, + getStates: () => states, + setLocalStateField: () => undefined, + on: () => undefined, + off: () => undefined, + }; + const trimSpy = vi.spyOn(String.prototype, 'trim'); + + try { + expect(countRemoteCollaborators(awareness)).toBe(1); + expect( + trimSpy.mock.instances.some((receiver) => String(receiver) === oversizedId), + ).toBe(false); + } finally { + trimSpy.mockRestore(); + } + }); + + it('skips accessor-backed remote identity fields without executing caller code', () => { + let userGetterCalls = 0; + let idGetterCalls = 0; + + const accessorBackedState: Record = {}; + Object.defineProperty(accessorBackedState, 'user', { + enumerable: true, + get() { + userGetterCalls += 1; + throw new Error('private remote user getter must not execute'); + }, + }); + + const accessorBackedUser: Record = {}; + Object.defineProperty(accessorBackedUser, 'id', { + enumerable: true, + get() { + idGetterCalls += 1; + throw new Error('private remote id getter must not execute'); + }, + }); + + const states = new Map>([ + [11, { user: { id: 'local-editor' } }], + [12, accessorBackedState], + [13, { user: accessorBackedUser }], + [14, { user: { id: 'editor-bob' } }], + ]); + const awareness: CollaborationAwareness = { + clientID: 11, + states, + getLocalState: () => states.get(11) ?? null, + getStates: () => states, + setLocalStateField: () => undefined, + on: () => undefined, + off: () => undefined, + }; + + expect(countRemoteCollaborators(awareness)).toBe(1); + expect(userGetterCalls).toBe(0); + expect(idGetterCalls).toBe(0); + }); + + it('ignores inherited and non-enumerable remote identity fields', () => { + const inheritedState = Object.create({ user: { id: 'editor-inherited' } }) as Record< + string, + unknown + >; + const nonEnumerableState: Record = {}; + Object.defineProperty(nonEnumerableState, 'user', { + enumerable: false, + value: { id: 'editor-hidden' }, + }); + const missingIdState = { user: { name: 'missing-id' } }; + const nonEnumerableIdUser: Record = {}; + Object.defineProperty(nonEnumerableIdUser, 'id', { + enumerable: false, + value: 'editor-hidden-id', + }); + + const states = new Map>([ + [11, { user: { id: 'local-editor' } }], + [12, inheritedState], + [13, nonEnumerableState], + [14, missingIdState], + [15, { user: nonEnumerableIdUser }], + [16, { user: { id: 'editor-bob' } }], + ]); + const awareness: CollaborationAwareness = { + clientID: 11, + states, + getLocalState: () => states.get(11) ?? null, + getStates: () => states, + setLocalStateField: () => undefined, + on: () => undefined, + off: () => undefined, + }; + + expect(countRemoteCollaborators(awareness)).toBe(1); + }); + + it('skips null and primitive remote states before descriptor reflection', () => { + const runtimeStates = new Map([ + [11, { user: { id: 'local-editor' } }], + [12, null], + [13, 'not-an-awareness-state'], + [14, { user: { id: 'editor-bob' } }], + ]); + const states = runtimeStates as unknown as Map< + number, + Record + >; + const awareness: CollaborationAwareness = { + clientID: 11, + states, + getLocalState: () => states.get(11) ?? null, + getStates: () => states, + setLocalStateField: () => undefined, + on: () => undefined, + off: () => undefined, + }; + + expect(countRemoteCollaborators(awareness)).toBe(1); + }); + + it('skips reflection-hostile remote identity shapes without leaking trap failures', () => { + const hostileState = new Proxy>( + {}, + { + getOwnPropertyDescriptor() { + throw new Error('private remote descriptor trap must not escape'); + }, + }, + ); + const states = new Map>([ + [11, { user: { id: 'local-editor' } }], + [12, hostileState], + [13, { user: { id: 'editor-bob' } }], + ]); + const awareness: CollaborationAwareness = { + clientID: 11, + states, + getLocalState: () => states.get(11) ?? null, + getStates: () => states, + setLocalStateField: () => undefined, + on: () => undefined, + off: () => undefined, + }; + + expect(countRemoteCollaborators(awareness)).toBe(1); + }); +}); diff --git a/src/collaboration/awarenessListenerContainment.test.ts b/src/collaboration/awarenessListenerContainment.test.ts new file mode 100644 index 00000000..e2b7625b --- /dev/null +++ b/src/collaboration/awarenessListenerContainment.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it, vi } from 'vitest'; +import { createScopedCollaborationProvider } from './awareness.js'; +import type { CollaborationAwareness } from './types.js'; + +function awarenessWithListenerRegistrationFailure(): { + awareness: CollaborationAwareness; + registrationAttempts: () => number; +} { + const states = new Map>(); + let attempts = 0; + const privateFailure = new Error('private provider listener registration failure'); + const awareness: CollaborationAwareness = { + clientID: 11, + states, + getLocalState: () => null, + getStates: () => states, + setLocalStateField: () => undefined, + on: () => { + attempts += 1; + if (attempts === 1) throw privateFailure; + }, + off: () => undefined, + }; + return { awareness, registrationAttempts: () => attempts }; +} + +function awarenessWithListenerRemovalFailure(): { + awareness: CollaborationAwareness; + removalAttempts: () => number; +} { + const states = new Map>(); + let attempts = 0; + const privateFailure = new Error('private provider listener removal failure'); + const awareness: CollaborationAwareness = { + clientID: 11, + states, + getLocalState: () => null, + getStates: () => states, + setLocalStateField: () => undefined, + on: () => undefined, + off: () => { + attempts += 1; + if (attempts === 1) throw privateFailure; + }, + }; + return { awareness, removalAttempts: () => attempts }; +} + +describe('scoped collaboration provider listener containment', () => { + it('redacts a rejected listener registration and permits a clean retry', () => { + const source = awarenessWithListenerRegistrationFailure(); + const scoped = createScopedCollaborationProvider({ awareness: source.awareness }); + const listener = vi.fn(); + + expect(() => scoped.awareness.on('change', listener)).toThrowError( + new Error('collaboration awareness listener registration failed'), + ); + expect(source.registrationAttempts()).toBe(1); + + expect(() => scoped.awareness.on('change', listener)).not.toThrow(); + expect(source.registrationAttempts()).toBe(2); + }); + + it('redacts a rejected listener removal and retains state for retry', () => { + const source = awarenessWithListenerRemovalFailure(); + const scoped = createScopedCollaborationProvider({ awareness: source.awareness }); + const listener = vi.fn(); + + scoped.awareness.on('change', listener); + expect(() => scoped.awareness.off('change', listener)).toThrowError( + new Error('collaboration awareness listener removal failed'), + ); + expect(source.removalAttempts()).toBe(1); + + expect(() => scoped.awareness.off('change', listener)).not.toThrow(); + expect(source.removalAttempts()).toBe(2); + }); +}); diff --git a/src/collaboration/awarenessLocalSourceBounds.test.ts b/src/collaboration/awarenessLocalSourceBounds.test.ts new file mode 100644 index 00000000..16da8f1a --- /dev/null +++ b/src/collaboration/awarenessLocalSourceBounds.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it, vi } from 'vitest'; +import { serializeCollaborationUser } from './awareness.js'; + +const MAX_LOCAL_FIELD_SOURCE_CODE_UNITS = 1_024; + +/** Prove impossible local identity metadata is rejected before full-string trim. */ +describe('local collaboration awareness source bounds', () => { + it.each(['userId', 'displayName', 'cursorColor'] as const)( + 'rejects oversized %s before normalization', + (field) => { + const originalTrim = String.prototype.trim; + let oversizedTrimObserved = false; + const trimSpy = vi + .spyOn(String.prototype, 'trim') + .mockImplementation(function (this: string) { + if (this.length > MAX_LOCAL_FIELD_SOURCE_CODE_UNITS) { + oversizedTrimObserved = true; + } + return originalTrim.call(this); + }); + const user = { + userId: 'editor-alice', + displayName: 'Alice', + cursorColor: '#123456', + [field]: ' '.repeat(MAX_LOCAL_FIELD_SOURCE_CODE_UNITS + 1), + }; + + try { + expect(() => serializeCollaborationUser(user)).toThrow(); + expect(oversizedTrimObserved).toBe(false); + } finally { + trimSpy.mockRestore(); + } + }, + ); +}); diff --git a/src/collaboration/awarenessLocalUserFailureRedaction.test.ts b/src/collaboration/awarenessLocalUserFailureRedaction.test.ts new file mode 100644 index 00000000..e3bcd4b9 --- /dev/null +++ b/src/collaboration/awarenessLocalUserFailureRedaction.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest'; +import { serializeCollaborationUser } from './awareness.js'; +import type { CollaborationUser } from './types.js'; + +describe('local collaboration user failure redaction', () => { + it.each(['userId', 'displayName', 'cursorColor'] as const)( + 'normalizes a hostile %s property failure without reflecting the thrown value', + (field) => { + const privateFailure = { marker: 'private-local-user-sentinel' }; + const user = new Proxy( + { + userId: 'editor-alice', + displayName: 'Alice', + cursorColor: '#123456', + }, + { + get(target, property, receiver) { + if (property === field) throw privateFailure; + return Reflect.get(target, property, receiver); + }, + }, + ); + + let caught: unknown; + try { + serializeCollaborationUser(user); + } catch (error) { + caught = error; + } + + expect(caught).toBeInstanceOf(Error); + expect(caught).not.toBe(privateFailure); + expect((caught as Error).message).toBe( + `collaboration ${field} must be a string`, + ); + expect((caught as Error).message).not.toContain( + 'private-local-user-sentinel', + ); + }, + ); +}); diff --git a/src/collaboration/awarenessPayloadBounds.test.ts b/src/collaboration/awarenessPayloadBounds.test.ts new file mode 100644 index 00000000..71d12c5c --- /dev/null +++ b/src/collaboration/awarenessPayloadBounds.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + renderCollaborationCursor, + serializeCollaborationUser, +} from './awareness.js'; + +describe('collaboration awareness payload bounds', () => { + it('limits the broadcast display name to the rendered cursor-label ceiling', () => { + const serialized = serializeCollaborationUser({ + userId: 'editor-alice', + displayName: ` ${'A'.repeat(81)} `, + cursorColor: '#123456', + }); + + expect(serialized.name).toBe('A'.repeat(80)); + expect(serialized.name).toHaveLength(80); + }); + + it('does not split Unicode scalar values at the cursor-label ceiling', () => { + const expected = `${'A'.repeat(79)}😀`; + const oversized = `${expected}tail`; + + const serialized = serializeCollaborationUser({ + userId: 'editor-alice', + displayName: oversized, + cursorColor: '#123456', + }); + const remoteCursor = renderCollaborationCursor({ + name: oversized, + color: '#123456', + }); + + expect(serialized.name).toBe(expected); + expect(remoteCursor.textContent).toBe(expected); + }); + + it('accepts an 80-code-point public identifier even when UTF-16 is longer', () => { + const userId = `${'a'.repeat(79)}😀`; + + expect( + serializeCollaborationUser({ + userId, + displayName: 'Alice', + cursorColor: '#123456', + }).id, + ).toBe(userId); + }); + + it('rejects oversized public identifiers before awareness publication', () => { + expect(() => + serializeCollaborationUser({ + userId: `editor-${'a'.repeat(74)}`, + displayName: 'Alice', + cursorColor: '#123456', + }), + ).toThrow(/userId.*80/); + }); + + it('does not materialize code-point arrays while enforcing public bounds', () => { + const arrayFrom = vi.spyOn(Array, 'from'); + let thrown: unknown; + + try { + serializeCollaborationUser({ + userId: `editor-${'a'.repeat(1_000)}`, + displayName: 'A'.repeat(1_000), + cursorColor: '#123456', + }); + } catch (error) { + thrown = error; + } + + const allocationCalls = arrayFrom.mock.calls.length; + arrayFrom.mockRestore(); + + expect(thrown).toBeInstanceOf(Error); + expect((thrown as Error).message).toMatch(/userId.*80/); + expect(allocationCalls).toBe(0); + }); +}); diff --git a/src/collaboration/awarenessProviderAccess.test.ts b/src/collaboration/awarenessProviderAccess.test.ts new file mode 100644 index 00000000..65eeec0a --- /dev/null +++ b/src/collaboration/awarenessProviderAccess.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest'; +import { + assertCollaborationConfiguration, + createScopedCollaborationProvider, +} from './awareness.js'; +import type { + CollaborationAwareness, + CollaborationProviderLike, +} from './types.js'; + +function validAwareness(): CollaborationAwareness { + const states = new Map>(); + return { + clientID: 11, + states, + getLocalState: () => null, + getStates: () => states, + setLocalStateField: () => undefined, + on: () => undefined, + off: () => undefined, + }; +} + +describe('collaboration provider capability access', () => { + it('does not leak a private provider error when awareness changes after validation', () => { + const awareness = validAwareness(); + let reads = 0; + const provider = Object.defineProperty({}, 'awareness', { + enumerable: true, + get() { + reads += 1; + if (reads === 1) return awareness; + throw new Error('sensitive-provider-internal'); + }, + }) as CollaborationProviderLike; + + expect(() => + assertCollaborationConfiguration(provider, undefined), + ).not.toThrow(); + expect(() => createScopedCollaborationProvider(provider)).toThrowError( + new Error( + 'collaboration provider must expose a compatible Yjs awareness instance', + ), + ); + }); + + it('normalizes private structural awareness access failures', () => { + const privateFailure = new Error('sensitive-awareness-internal'); + const awareness = Object.defineProperty({}, 'clientID', { + enumerable: true, + get() { + throw privateFailure; + }, + }) as CollaborationAwareness; + const provider = { awareness } as CollaborationProviderLike; + + let observed: unknown; + try { + assertCollaborationConfiguration(provider, undefined); + } catch (error) { + observed = error; + } + + expect(observed).toBeInstanceOf(Error); + expect(observed).not.toBe(privateFailure); + expect((observed as Error).message).toBe( + 'collaboration provider must expose a compatible Yjs awareness instance', + ); + }); +}); diff --git a/src/collaboration/awarenessRemotePresentationAccess.test.ts b/src/collaboration/awarenessRemotePresentationAccess.test.ts new file mode 100644 index 00000000..5079bce2 --- /dev/null +++ b/src/collaboration/awarenessRemotePresentationAccess.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest'; +import { + renderCollaborationCursor, + renderCollaborationSelection, +} from './awareness.js'; + +describe('remote collaboration presentation property access', () => { + it('does not execute an accessor-backed remote cursor name', () => { + let nameReads = 0; + const user: Record = { color: '#000000' }; + Object.defineProperty(user, 'name', { + enumerable: true, + get() { + nameReads += 1; + throw new Error('private remote name'); + }, + }); + + const cursor = renderCollaborationCursor(user); + + expect(nameReads).toBe(0); + expect(cursor.textContent).toBe('Collaborator'); + }); + + it('does not execute an accessor-backed remote selection color', () => { + let colorReads = 0; + const user: Record = {}; + Object.defineProperty(user, 'color', { + enumerable: true, + get() { + colorReads += 1; + throw new Error('private remote color'); + }, + }); + + expect(renderCollaborationSelection(user)).toEqual({ + class: 'collaboration-cursor__selection', + style: 'background-color: #47556933', + }); + expect(colorReads).toBe(0); + }); +}); diff --git a/src/collaboration/awarenessRemotePresentationResource.test.ts b/src/collaboration/awarenessRemotePresentationResource.test.ts new file mode 100644 index 00000000..7fcf89c1 --- /dev/null +++ b/src/collaboration/awarenessRemotePresentationResource.test.ts @@ -0,0 +1,25 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { renderCollaborationCursor } from './awareness.js'; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('remote collaboration presentation resource preflight', () => { + it('rejects an oversized remote cursor name before trimming it', () => { + const originalTrim = String.prototype.trim; + vi.spyOn(String.prototype, 'trim').mockImplementation(function (this: string) { + if (this.length > 1_024) { + throw new Error('oversized remote name reached trim'); + } + return originalTrim.call(this); + }); + + const cursor = renderCollaborationCursor({ + name: 'x'.repeat(1_025), + color: '#000000', + }); + + expect(cursor.textContent).toBe('Collaborator'); + }); +}); diff --git a/src/collaboration/awarenessRuntimeFields.test.ts b/src/collaboration/awarenessRuntimeFields.test.ts new file mode 100644 index 00000000..3416b62b --- /dev/null +++ b/src/collaboration/awarenessRuntimeFields.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; +import { serializeCollaborationUser } from './awareness.js'; + +const VALID_USER = { + userId: 'editor-alice', + displayName: 'Alice', + cursorColor: '#123456', +}; + +describe('collaboration user runtime field contract', () => { + it.each([ + ['userId', 42, 'collaboration userId must be a string'], + ['displayName', {}, 'collaboration displayName must be a string'], + ['cursorColor', null, 'collaboration cursorColor must be a string'], + ] as const)( + 'rejects malformed %s before normalization', + (field, value, message) => { + expect(() => + serializeCollaborationUser({ + ...VALID_USER, + [field]: value, + } as never), + ).toThrowError(new Error(message)); + }, + ); + + it('preserves valid trimming, bounded names, and lowercase colors', () => { + expect( + serializeCollaborationUser({ + userId: ' editor-alice ', + displayName: ` ${'A'.repeat(81)} `, + cursorColor: ' #ABCDEF ', + }), + ).toEqual({ + id: 'editor-alice', + name: 'A'.repeat(80), + color: '#abcdef', + }); + }); +}); diff --git a/src/collaboration/providerConfigurationRuntimeBoundary.test.ts b/src/collaboration/providerConfigurationRuntimeBoundary.test.ts new file mode 100644 index 00000000..97f6b5cf --- /dev/null +++ b/src/collaboration/providerConfigurationRuntimeBoundary.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; +import { assertCollaborationConfiguration } from './awareness.js'; +import type { CollaborationProviderLike } from './types.js'; + +describe('collaboration provider configuration runtime boundary', () => { + it('redacts failures while reading the host awareness capability', () => { + const privateFailure = new Error('private host capability detail'); + const provider = Object.defineProperty({}, 'awareness', { + enumerable: true, + get: () => { + throw privateFailure; + }, + }) as CollaborationProviderLike; + + let thrown: unknown; + try { + assertCollaborationConfiguration(provider, undefined); + } catch (error) { + thrown = error; + } + + expect(thrown).toBeInstanceOf(Error); + expect(thrown).not.toBe(privateFailure); + expect((thrown as Error).message).toBe( + 'collaboration provider must expose a compatible Yjs awareness instance', + ); + }); +}); diff --git a/src/collaboration/types.ts b/src/collaboration/types.ts index 99e414ac..e7757a4a 100644 --- a/src/collaboration/types.ts +++ b/src/collaboration/types.ts @@ -3,9 +3,15 @@ import type { CwlEditorProps } from '../types.js'; /** Public, non-sensitive identity propagated through collaboration awareness. */ export interface CollaborationUser { - /** Descriptive nonnumeric public identifier, stable for the collaboration session. */ + /** + * Descriptive nonnumeric public identifier, stable for the collaboration + * session and limited to 80 Unicode code points. + */ userId: string; - /** Human-readable name shown beside the remote caret. */ + /** + * Human-readable name shown beside the remote caret. Inkspan trims it and + * publishes/renders at most 80 Unicode code points. + */ displayName: string; /** Six-digit hexadecimal caret color, for example `#2563eb`. */ cursorColor: string; diff --git a/src/collaborationDocumentationContract.test.ts b/src/collaborationDocumentationContract.test.ts new file mode 100644 index 00000000..daffda2c --- /dev/null +++ b/src/collaborationDocumentationContract.test.ts @@ -0,0 +1,25 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +const collaborationGuide = (): string => + readFileSync(resolve(process.cwd(), 'docs/collaboration.md'), 'utf8'); + +describe('collaboration documentation contract', () => { + it('keeps public awareness bounds and authority semantics explicit', () => { + const guide = collaborationGuide(); + + expect(guide).toMatch( + /`userId` must be nonempty, descriptive, not numeric-only, and no longer than 80\s+Unicode code points/u, + ); + expect(guide).toMatch( + /`displayName` must be nonempty and is published and rendered\s+as at most 80 Unicode code points/u, + ); + expect(guide).toMatch( + /Remote collaborator\s+counts ignore blank, numeric-only, and over-80-code-point public identifiers/u, + ); + expect(guide).toContain('awareness state is ephemeral'); + expect(guide).toContain('intentionally not used as an authorization source'); + }); +}); diff --git a/src/collaborationPublicContract.test.ts b/src/collaborationPublicContract.test.ts new file mode 100644 index 00000000..310d0ae3 --- /dev/null +++ b/src/collaborationPublicContract.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; +import { serializeCollaborationUser } from './collaboration/index.js'; + +describe('public collaboration awareness contract', () => { + it('enforces bounded public presence metadata through the package entrypoint', () => { + const displayName = `${'A'.repeat(80)}extra`; + + expect( + serializeCollaborationUser({ + userId: 'editor-alice', + displayName, + cursorColor: '#2563eb', + }), + ).toEqual({ + id: 'editor-alice', + name: 'A'.repeat(80), + color: '#2563eb', + }); + + expect(() => + serializeCollaborationUser({ + userId: `editor-${'a'.repeat(74)}`, + displayName: 'Alice', + cursorColor: '#2563eb', + }), + ).toThrow(/userId.*80/); + }); +});