From b1583705f29bab8886a2f2a2a3afaedad228e443 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 08:38:06 +0900 Subject: [PATCH 01/54] test(collaboration): prove outbound display-name bound --- src/collaboration/awarenessPayloadBounds.test.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 src/collaboration/awarenessPayloadBounds.test.ts diff --git a/src/collaboration/awarenessPayloadBounds.test.ts b/src/collaboration/awarenessPayloadBounds.test.ts new file mode 100644 index 00000000..641f4460 --- /dev/null +++ b/src/collaboration/awarenessPayloadBounds.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest'; +import { serializeCollaborationUser } from './awareness.js'; + +describe('collaboration awareness outbound 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); + }); +}); From 07f1bceea2e5cfdbc25a92be61b509eb051149fe Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 08:41:45 +0900 Subject: [PATCH 02/54] fix(collaboration): bound outbound display names --- src/collaboration/awareness.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/collaboration/awareness.ts b/src/collaboration/awareness.ts index 0444244d..1286eb99 100644 --- a/src/collaboration/awareness.ts +++ b/src/collaboration/awareness.ts @@ -29,7 +29,7 @@ export function serializeCollaborationUser( user: CollaborationUser, ): CollaborationCursorUser { const id = user.userId.trim(); - const name = user.displayName.trim(); + const name = user.displayName.trim().slice(0, MAX_CURSOR_LABEL_LENGTH); const color = user.cursorColor.trim(); if (id === '') { From 0ce7697adbc85a4e1b849ec08da147d7f5242b38 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 08:46:23 +0900 Subject: [PATCH 03/54] test(collaboration): preserve Unicode cursor labels --- .../awarenessPayloadBounds.test.ts | 25 +++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/src/collaboration/awarenessPayloadBounds.test.ts b/src/collaboration/awarenessPayloadBounds.test.ts index 641f4460..8c0582f7 100644 --- a/src/collaboration/awarenessPayloadBounds.test.ts +++ b/src/collaboration/awarenessPayloadBounds.test.ts @@ -1,7 +1,10 @@ import { describe, expect, it } from 'vitest'; -import { serializeCollaborationUser } from './awareness.js'; +import { + renderCollaborationCursor, + serializeCollaborationUser, +} from './awareness.js'; -describe('collaboration awareness outbound payload bounds', () => { +describe('collaboration awareness payload bounds', () => { it('limits the broadcast display name to the rendered cursor-label ceiling', () => { const serialized = serializeCollaborationUser({ userId: 'editor-alice', @@ -12,4 +15,22 @@ describe('collaboration awareness outbound payload bounds', () => { 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); + }); }); From 86b45e566f890aca3b2bedbf9a276ba632c7ee00 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 08:50:02 +0900 Subject: [PATCH 04/54] fix(collaboration): preserve Unicode cursor labels --- src/collaboration/awareness.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/collaboration/awareness.ts b/src/collaboration/awareness.ts index 1286eb99..7dbd5288 100644 --- a/src/collaboration/awareness.ts +++ b/src/collaboration/awareness.ts @@ -24,12 +24,17 @@ const NUMERIC_IDENTIFIER_PATTERN = /^\d+$/; const FALLBACK_CURSOR_COLOR = '#475569'; const MAX_CURSOR_LABEL_LENGTH = 80; +/** Trim and bound a public cursor label without splitting Unicode code points. */ +function truncateCursorLabel(value: string): string { + return Array.from(value.trim()).slice(0, MAX_CURSOR_LABEL_LENGTH).join(''); +} + /** 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().slice(0, MAX_CURSOR_LABEL_LENGTH); + const name = truncateCursorLabel(user.displayName); const color = user.cursorColor.trim(); if (id === '') { @@ -185,7 +190,7 @@ export function renderCollaborationCursor( const color = collaborationCursorColor(user); const name = typeof user.name === 'string' && user.name.trim() !== '' - ? user.name.trim().slice(0, MAX_CURSOR_LABEL_LENGTH) + ? truncateCursorLabel(user.name) : 'Collaborator'; const caret = document.createElement('span'); From f62caf56982767715b56acc77ca5732d00fa9b80 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 08:53:41 +0900 Subject: [PATCH 05/54] test(collaboration): reject invalid presence identities --- .../awarenessIdentityCount.test.ts | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 src/collaboration/awarenessIdentityCount.test.ts diff --git a/src/collaboration/awarenessIdentityCount.test.ts b/src/collaboration/awarenessIdentityCount.test.ts new file mode 100644 index 00000000..b8d51a3d --- /dev/null +++ b/src/collaboration/awarenessIdentityCount.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } 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-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); + }); +}); From fdc7bbd7d2d3bd7300d8f679eed754509b73ec38 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 09:02:59 +0900 Subject: [PATCH 06/54] fix(collaboration): validate remote presence identifiers --- src/collaboration/awareness.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/collaboration/awareness.ts b/src/collaboration/awareness.ts index 7dbd5288..ca81219c 100644 --- a/src/collaboration/awareness.ts +++ b/src/collaboration/awareness.ts @@ -150,14 +150,17 @@ export function countRemoteCollaborators( for (const [clientId, state] of awareness.getStates()) { if (clientId === awareness.clientID) continue; const user = state.user; + if (typeof user !== 'object' || user === null) continue; + const id = (user as Record).id; + if (typeof id !== 'string') continue; + const normalizedId = id.trim(); if ( - typeof user === 'object' && - user !== null && - typeof (user as Record).id === 'string' && - (user as Record).id !== '' + normalizedId === '' || + NUMERIC_IDENTIFIER_PATTERN.test(normalizedId) ) { - count += 1; + continue; } + count += 1; } return count; } From b34218ba1a7d18b976c1b7bb1227fc3a7a8bebb5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 09:07:56 +0900 Subject: [PATCH 07/54] test(collaboration): bound public awareness identifiers --- src/collaboration/awarenessPayloadBounds.test.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/collaboration/awarenessPayloadBounds.test.ts b/src/collaboration/awarenessPayloadBounds.test.ts index 8c0582f7..c291fc9a 100644 --- a/src/collaboration/awarenessPayloadBounds.test.ts +++ b/src/collaboration/awarenessPayloadBounds.test.ts @@ -33,4 +33,14 @@ describe('collaboration awareness payload bounds', () => { expect(serialized.name).toBe(expected); expect(remoteCursor.textContent).toBe(expected); }); + + it('rejects oversized public identifiers before awareness publication', () => { + expect(() => + serializeCollaborationUser({ + userId: `editor-${'a'.repeat(74)}`, + displayName: 'Alice', + cursorColor: '#123456', + }), + ).toThrow(/userId.*80/); + }); }); From b1b8f7f6c59bbd122bba42e57070b0aefe7fadc0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 09:14:08 +0900 Subject: [PATCH 08/54] fix(collaboration): bound public awareness identifiers --- src/collaboration/awareness.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/collaboration/awareness.ts b/src/collaboration/awareness.ts index ca81219c..22669f25 100644 --- a/src/collaboration/awareness.ts +++ b/src/collaboration/awareness.ts @@ -23,12 +23,18 @@ 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; /** Trim and bound a public cursor label without splitting Unicode code points. */ function truncateCursorLabel(value: string): string { return Array.from(value.trim()).slice(0, MAX_CURSOR_LABEL_LENGTH).join(''); } +/** Count Unicode code points for bounded public awareness metadata. */ +function publicIdentifierLength(value: string): number { + return Array.from(value).length; +} + /** Validate and serialize the only public fields permitted in awareness. */ export function serializeCollaborationUser( user: CollaborationUser, @@ -43,6 +49,11 @@ export function serializeCollaborationUser( if (NUMERIC_IDENTIFIER_PATTERN.test(id)) { throw new Error('collaboration userId must be descriptive and nonnumeric'); } + if (publicIdentifierLength(id) > MAX_PUBLIC_IDENTIFIER_LENGTH) { + throw new Error( + 'collaboration userId must be at most 80 Unicode code points', + ); + } if (name === '') { throw new Error('collaboration displayName must not be empty'); } @@ -156,7 +167,8 @@ export function countRemoteCollaborators( const normalizedId = id.trim(); if ( normalizedId === '' || - NUMERIC_IDENTIFIER_PATTERN.test(normalizedId) + NUMERIC_IDENTIFIER_PATTERN.test(normalizedId) || + publicIdentifierLength(normalizedId) > MAX_PUBLIC_IDENTIFIER_LENGTH ) { continue; } From 561bdef34e05eda5c9afe8efa257842d3ab81104 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 09:14:48 +0900 Subject: [PATCH 09/54] test(collaboration): reject oversized remote identities --- src/collaboration/awarenessIdentityCount.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/collaboration/awarenessIdentityCount.test.ts b/src/collaboration/awarenessIdentityCount.test.ts index b8d51a3d..838aa3dd 100644 --- a/src/collaboration/awarenessIdentityCount.test.ts +++ b/src/collaboration/awarenessIdentityCount.test.ts @@ -8,7 +8,8 @@ describe('collaboration awareness identity counting', () => { [11, { user: { id: 'local-editor' } }], [12, { user: { id: ' ' } }], [13, { user: { id: '12345' } }], - [14, { user: { id: 'editor-bob' } }], + [14, { user: { id: `editor-${'a'.repeat(74)}` } }], + [15, { user: { id: 'editor-bob' } }], ]); const awareness: CollaborationAwareness = { clientID: 11, From e44c75aa7fc324da03da0e0cb90ace9c01ee9923 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 09:16:43 +0900 Subject: [PATCH 10/54] test(collaboration): prove Unicode identifier ceiling --- src/collaboration/awarenessPayloadBounds.test.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/collaboration/awarenessPayloadBounds.test.ts b/src/collaboration/awarenessPayloadBounds.test.ts index c291fc9a..23bcaf79 100644 --- a/src/collaboration/awarenessPayloadBounds.test.ts +++ b/src/collaboration/awarenessPayloadBounds.test.ts @@ -34,6 +34,18 @@ describe('collaboration awareness payload bounds', () => { 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({ From 5e47fc30908288c3ba718f2fb2a44715abcf7d2a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 09:18:05 +0900 Subject: [PATCH 11/54] docs(collaboration): record bounded awareness metadata --- docs/collaboration.md | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) 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 From 68ccadaf88f176d40e2233297f38c402038dffab Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 09:18:59 +0900 Subject: [PATCH 12/54] test(collaboration): prove remote Unicode identity boundary --- src/collaboration/awarenessIdentityCount.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/collaboration/awarenessIdentityCount.test.ts b/src/collaboration/awarenessIdentityCount.test.ts index 838aa3dd..a46b19a8 100644 --- a/src/collaboration/awarenessIdentityCount.test.ts +++ b/src/collaboration/awarenessIdentityCount.test.ts @@ -10,6 +10,7 @@ describe('collaboration awareness identity counting', () => { [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, @@ -21,6 +22,6 @@ describe('collaboration awareness identity counting', () => { off: () => undefined, }; - expect(countRemoteCollaborators(awareness)).toBe(1); + expect(countRemoteCollaborators(awareness)).toBe(2); }); }); From 74b77044319d2998dcdb18f957e66dafbbc9df31 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 09:20:07 +0900 Subject: [PATCH 13/54] test(docs): lock collaboration awareness contract --- ...collaborationDocumentationContract.test.ts | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 src/collaborationDocumentationContract.test.ts 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'); + }); +}); From 7e2f475eaed5d6c867846f4cad88d74e9ee03259 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 09:22:59 +0900 Subject: [PATCH 14/54] docs(api): document collaboration awareness bounds --- src/collaboration/types.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) 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; From 69ccbd9586bc7699e4b3d6cb8a5ef591a1e45e17 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 09:24:16 +0900 Subject: [PATCH 15/54] test(collaboration): lock public awareness entrypoint --- src/collaborationPublicContract.test.ts | 28 +++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 src/collaborationPublicContract.test.ts 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/); + }); +}); From d705e43db615eab9a82cdcf3ff6c40686700e7cc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 09:25:20 +0900 Subject: [PATCH 16/54] test(collaboration): expose metadata array allocation --- .../awarenessPayloadBounds.test.ts | 24 ++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/src/collaboration/awarenessPayloadBounds.test.ts b/src/collaboration/awarenessPayloadBounds.test.ts index 23bcaf79..a3430d01 100644 --- a/src/collaboration/awarenessPayloadBounds.test.ts +++ b/src/collaboration/awarenessPayloadBounds.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { renderCollaborationCursor, serializeCollaborationUser, @@ -55,4 +55,26 @@ describe('collaboration awareness payload bounds', () => { }), ).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(100_000)}`, + displayName: 'A'.repeat(100_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); + }); }); From 2e9140e88a4512f754ea2a58fc98af3609062b30 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 10:06:31 +0900 Subject: [PATCH 17/54] fix(collaboration): bound Unicode awareness scans without arrays --- src/collaboration/awareness.ts | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/src/collaboration/awareness.ts b/src/collaboration/awareness.ts index 22669f25..162353c6 100644 --- a/src/collaboration/awareness.ts +++ b/src/collaboration/awareness.ts @@ -27,12 +27,25 @@ const MAX_PUBLIC_IDENTIFIER_LENGTH = 80; /** Trim and bound a public cursor label without splitting Unicode code points. */ function truncateCursorLabel(value: string): string { - return Array.from(value.trim()).slice(0, MAX_CURSOR_LABEL_LENGTH).join(''); + 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; } -/** Count Unicode code points for bounded public awareness metadata. */ -function publicIdentifierLength(value: string): number { - return Array.from(value).length; +/** 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; } /** Validate and serialize the only public fields permitted in awareness. */ @@ -49,7 +62,7 @@ export function serializeCollaborationUser( if (NUMERIC_IDENTIFIER_PATTERN.test(id)) { throw new Error('collaboration userId must be descriptive and nonnumeric'); } - if (publicIdentifierLength(id) > MAX_PUBLIC_IDENTIFIER_LENGTH) { + if (exceedsPublicIdentifierLength(id)) { throw new Error( 'collaboration userId must be at most 80 Unicode code points', ); @@ -168,7 +181,7 @@ export function countRemoteCollaborators( if ( normalizedId === '' || NUMERIC_IDENTIFIER_PATTERN.test(normalizedId) || - publicIdentifierLength(normalizedId) > MAX_PUBLIC_IDENTIFIER_LENGTH + exceedsPublicIdentifierLength(normalizedId) ) { continue; } From 5828f8143e6f421aee9273a71197b4231ad3fd03 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 20:16:33 +0900 Subject: [PATCH 18/54] test: reject invalid collaboration status --- src/collaboration/awareness.test.ts | 8 ++++++++ 1 file changed, 8 insertions(+) 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', From c95eeefaeee4debbe674420a0e0029d146ddc3d0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 21:02:20 +0900 Subject: [PATCH 19/54] fix: reject invalid collaboration status --- src/collaboration/awareness.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/collaboration/awareness.ts b/src/collaboration/awareness.ts index 162353c6..10a6b0ac 100644 --- a/src/collaboration/awareness.ts +++ b/src/collaboration/awareness.ts @@ -195,6 +195,8 @@ export function collaborationConnectionLabel( status: CollaborationConnectionStatus | undefined, ): string { switch (status) { + case undefined: + return 'Collaboration ready'; case 'connecting': return 'Connecting'; case 'connected': @@ -204,7 +206,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.', + ); } } From 1babeda2aff6ebf64df785a783f009a7f1067ca7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 09:18:28 +0900 Subject: [PATCH 20/54] test(data-integrity): reject malformed collaboration identity fields --- .../awarenessRuntimeFields.test.ts | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 src/collaboration/awarenessRuntimeFields.test.ts 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', + }); + }); +}); From 098c6b58be86ad989adaec33d454009bb668876e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 09:23:50 +0900 Subject: [PATCH 21/54] fix(data-integrity): validate collaboration identity fields --- src/collaboration/awareness.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/collaboration/awareness.ts b/src/collaboration/awareness.ts index 10a6b0ac..e2221099 100644 --- a/src/collaboration/awareness.ts +++ b/src/collaboration/awareness.ts @@ -25,6 +25,16 @@ const FALLBACK_CURSOR_COLOR = '#475569'; const MAX_CURSOR_LABEL_LENGTH = 80; const MAX_PUBLIC_IDENTIFIER_LENGTH = 80; +/** Reject malformed local awareness identity fields before string operations. */ +function assertCollaborationUserStringField( + field: 'userId' | 'displayName' | 'cursorColor', + value: unknown, +): asserts value is string { + if (typeof value !== 'string') { + throw new Error(`collaboration ${field} must be a string`); + } +} + /** Trim and bound a public cursor label without splitting Unicode code points. */ function truncateCursorLabel(value: string): string { const trimmed = value.trim(); @@ -52,6 +62,9 @@ function exceedsPublicIdentifierLength(value: string): boolean { export function serializeCollaborationUser( user: CollaborationUser, ): CollaborationCursorUser { + assertCollaborationUserStringField('userId', user.userId); + assertCollaborationUserStringField('displayName', user.displayName); + assertCollaborationUserStringField('cursorColor', user.cursorColor); const id = user.userId.trim(); const name = truncateCursorLabel(user.displayName); const color = user.cursorColor.trim(); From a660f5431538b9cb8ccf4d4e3616b726df96e4e6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 13:03:00 +0900 Subject: [PATCH 22/54] test(collaboration): reject malformed public contrast colors --- .../awarenessContrastColor.test.ts | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 src/collaboration/awarenessContrastColor.test.ts 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'); + }); +}); From 91efbccefb58f8342cce0f3c76d6ca9456629ac4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 13:07:14 +0900 Subject: [PATCH 23/54] fix(collaboration): validate public contrast colors --- src/collaboration/awareness.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/collaboration/awareness.ts b/src/collaboration/awareness.ts index e2221099..e3d01e87 100644 --- a/src/collaboration/awareness.ts +++ b/src/collaboration/awareness.ts @@ -264,6 +264,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; From caa01c7cb2355f6b3d284763f54880771aba5dae Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 15:47:49 +0900 Subject: [PATCH 24/54] test(collaboration): require raw awareness id preflight --- .../awarenessIdentityCount.test.ts | 30 ++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/src/collaboration/awarenessIdentityCount.test.ts b/src/collaboration/awarenessIdentityCount.test.ts index a46b19a8..0766a877 100644 --- a/src/collaboration/awarenessIdentityCount.test.ts +++ b/src/collaboration/awarenessIdentityCount.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { countRemoteCollaborators } from './awareness.js'; import type { CollaborationAwareness } from './types.js'; @@ -24,4 +24,32 @@ describe('collaboration awareness identity counting', () => { 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(); + } + }); }); From 03e2c620be610e92bd5d84325c4a3f92d6376ec8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 15:51:49 +0900 Subject: [PATCH 25/54] fix(collaboration): preflight remote awareness ids --- src/collaboration/awareness.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/collaboration/awareness.ts b/src/collaboration/awareness.ts index e3d01e87..d49de861 100644 --- a/src/collaboration/awareness.ts +++ b/src/collaboration/awareness.ts @@ -24,6 +24,7 @@ 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_IDENTIFIER_SOURCE_LENGTH = 1_024; /** Reject malformed local awareness identity fields before string operations. */ function assertCollaborationUserStringField( @@ -190,6 +191,7 @@ export function countRemoteCollaborators( if (typeof user !== 'object' || user === null) continue; const id = (user as Record).id; if (typeof id !== 'string') continue; + if (id.length > MAX_REMOTE_IDENTIFIER_SOURCE_LENGTH) continue; const normalizedId = id.trim(); if ( normalizedId === '' || From 386ea996f50690d2eb9d8604d0bc849eacae8148 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 09:05:39 +0900 Subject: [PATCH 26/54] test(collaboration): preflight local awareness metadata --- .../awarenessLocalSourceBounds.test.ts | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 src/collaboration/awarenessLocalSourceBounds.test.ts 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(); + } + }, + ); +}); From 72913d0f7189410bdb995ac69ceee1e7cd999d9e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 09:08:04 +0900 Subject: [PATCH 27/54] fix(collaboration): bound local awareness metadata before normalization --- src/collaboration/awareness.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/collaboration/awareness.ts b/src/collaboration/awareness.ts index d49de861..482a0cc1 100644 --- a/src/collaboration/awareness.ts +++ b/src/collaboration/awareness.ts @@ -25,8 +25,9 @@ const FALLBACK_CURSOR_COLOR = '#475569'; const MAX_CURSOR_LABEL_LENGTH = 80; const MAX_PUBLIC_IDENTIFIER_LENGTH = 80; const MAX_REMOTE_IDENTIFIER_SOURCE_LENGTH = 1_024; +const MAX_LOCAL_FIELD_SOURCE_LENGTH = 1_024; -/** Reject malformed local awareness identity fields before string operations. */ +/** Reject malformed or oversized local identity fields before normalization. */ function assertCollaborationUserStringField( field: 'userId' | 'displayName' | 'cursorColor', value: unknown, @@ -34,6 +35,11 @@ function assertCollaborationUserStringField( 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`, + ); + } } /** Trim and bound a public cursor label without splitting Unicode code points. */ @@ -293,4 +299,4 @@ function collaborationCursorColor(user: Record): string { CURSOR_COLOR_PATTERN.test(user.color) ? user.color.toLowerCase() : FALLBACK_CURSOR_COLOR; -} +} \ No newline at end of file From 0be2d92c1b727e3963483ffb47e537301cdbb4b6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 09:11:20 +0900 Subject: [PATCH 28/54] test(collaboration): keep identifier allocation proof within source ceiling --- src/collaboration/awarenessPayloadBounds.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/collaboration/awarenessPayloadBounds.test.ts b/src/collaboration/awarenessPayloadBounds.test.ts index a3430d01..71d12c5c 100644 --- a/src/collaboration/awarenessPayloadBounds.test.ts +++ b/src/collaboration/awarenessPayloadBounds.test.ts @@ -62,8 +62,8 @@ describe('collaboration awareness payload bounds', () => { try { serializeCollaborationUser({ - userId: `editor-${'a'.repeat(100_000)}`, - displayName: 'A'.repeat(100_000), + userId: `editor-${'a'.repeat(1_000)}`, + displayName: 'A'.repeat(1_000), cursorColor: '#123456', }); } catch (error) { From 843bb18a79c3da38e3245b7f67e8f5f1e2c61d27 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 04:33:31 +0900 Subject: [PATCH 29/54] test(reliability): reject accessor-backed remote awareness identity --- .../awarenessIdentityCount.test.ts | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/src/collaboration/awarenessIdentityCount.test.ts b/src/collaboration/awarenessIdentityCount.test.ts index 0766a877..d52e5e5e 100644 --- a/src/collaboration/awarenessIdentityCount.test.ts +++ b/src/collaboration/awarenessIdentityCount.test.ts @@ -52,4 +52,47 @@ describe('collaboration awareness identity counting', () => { 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); + }); }); From ba9315e654e8e972e7c0770f577a440fd2af6807 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 04:35:44 +0900 Subject: [PATCH 30/54] test(reliability): contain remote awareness reflection failures --- .../awarenessIdentityCount.test.ts | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/src/collaboration/awarenessIdentityCount.test.ts b/src/collaboration/awarenessIdentityCount.test.ts index d52e5e5e..bb1b3f95 100644 --- a/src/collaboration/awarenessIdentityCount.test.ts +++ b/src/collaboration/awarenessIdentityCount.test.ts @@ -95,4 +95,31 @@ describe('collaboration awareness identity counting', () => { expect(userGetterCalls).toBe(0); expect(idGetterCalls).toBe(0); }); + + 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); + }); }); From 6551d86b46d0fd135a2128e25eee45f9c119bf30 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 04:36:18 +0900 Subject: [PATCH 31/54] fix(reliability): contain hostile remote awareness accessors --- src/collaboration/awareness.ts | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/src/collaboration/awareness.ts b/src/collaboration/awareness.ts index 482a0cc1..0a9cb35a 100644 --- a/src/collaboration/awareness.ts +++ b/src/collaboration/awareness.ts @@ -185,6 +185,22 @@ export function createScopedCollaborationProvider( }; } +/** 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); + return descriptor?.enumerable === true && 'value' in descriptor + ? descriptor.value + : undefined; + } catch { + return undefined; + } +} + /** Count remote awareness clients carrying a valid public user identifier. */ export function countRemoteCollaborators( awareness: CollaborationAwareness | undefined, @@ -193,9 +209,9 @@ export function countRemoteCollaborators( let count = 0; for (const [clientId, state] of awareness.getStates()) { if (clientId === awareness.clientID) continue; - const user = state.user; + const user = ownEnumerableDataValue(state, 'user'); if (typeof user !== 'object' || user === null) continue; - const id = (user as Record).id; + const id = ownEnumerableDataValue(user, 'id'); if (typeof id !== 'string') continue; if (id.length > MAX_REMOTE_IDENTIFIER_SOURCE_LENGTH) continue; const normalizedId = id.trim(); @@ -299,4 +315,4 @@ function collaborationCursorColor(user: Record): string { CURSOR_COLOR_PATTERN.test(user.color) ? user.color.toLowerCase() : FALLBACK_CURSOR_COLOR; -} \ No newline at end of file +} From 98f8b42ea626aef9b6af6dcfc53ca8b1145f2074 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 04:40:15 +0900 Subject: [PATCH 32/54] test(collaboration): cover remote descriptor visibility branches --- .../awarenessIdentityCount.test.ts | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/src/collaboration/awarenessIdentityCount.test.ts b/src/collaboration/awarenessIdentityCount.test.ts index bb1b3f95..2afa19fe 100644 --- a/src/collaboration/awarenessIdentityCount.test.ts +++ b/src/collaboration/awarenessIdentityCount.test.ts @@ -96,6 +96,36 @@ describe('collaboration awareness identity counting', () => { 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 states = new Map>([ + [11, { user: { id: 'local-editor' } }], + [12, inheritedState], + [13, nonEnumerableState], + [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); + }); + it('skips reflection-hostile remote identity shapes without leaking trap failures', () => { const hostileState = new Proxy>( {}, From b99cfcbed8232488f87a1e4640302216e78c6684 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 04:44:56 +0900 Subject: [PATCH 33/54] test(collaboration): cover remote id descriptor branches --- src/collaboration/awarenessIdentityCount.test.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/collaboration/awarenessIdentityCount.test.ts b/src/collaboration/awarenessIdentityCount.test.ts index 2afa19fe..4bfa703f 100644 --- a/src/collaboration/awarenessIdentityCount.test.ts +++ b/src/collaboration/awarenessIdentityCount.test.ts @@ -106,12 +106,20 @@ describe('collaboration awareness identity counting', () => { 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, { user: { id: 'editor-bob' } }], + [14, missingIdState], + [15, { user: nonEnumerableIdUser }], + [16, { user: { id: 'editor-bob' } }], ]); const awareness: CollaborationAwareness = { clientID: 11, From b1401f5d6363482bf8febbf58d0a86aa361b5696 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 04:49:11 +0900 Subject: [PATCH 34/54] refactor(collaboration): make remote descriptor guards explicit --- src/collaboration/awareness.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/collaboration/awareness.ts b/src/collaboration/awareness.ts index 0a9cb35a..c7f1c8a5 100644 --- a/src/collaboration/awareness.ts +++ b/src/collaboration/awareness.ts @@ -193,9 +193,10 @@ function ownEnumerableDataValue( if (typeof value !== 'object' || value === null) return undefined; try { const descriptor = Object.getOwnPropertyDescriptor(value, property); - return descriptor?.enumerable === true && 'value' in descriptor - ? descriptor.value - : undefined; + if (!descriptor) return undefined; + if (!descriptor.enumerable) return undefined; + if (!('value' in descriptor)) return undefined; + return descriptor.value; } catch { return undefined; } From 65cc671dd6c280da8e8c7121dd17f39220dd6ded Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 06:14:42 +0900 Subject: [PATCH 35/54] test(collaboration): cover malformed remote state shapes --- .../awarenessIdentityCount.test.ts | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/collaboration/awarenessIdentityCount.test.ts b/src/collaboration/awarenessIdentityCount.test.ts index 4bfa703f..553a8204 100644 --- a/src/collaboration/awarenessIdentityCount.test.ts +++ b/src/collaboration/awarenessIdentityCount.test.ts @@ -134,6 +134,30 @@ describe('collaboration awareness identity counting', () => { 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>( {}, From b74eed73ebd5dd73d52bbf49cbf3dcda57a6807d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 18:07:47 -0700 Subject: [PATCH 36/54] test(collaboration): reject remote presentation accessor execution --- .../awarenessRemotePresentationAccess.test.ts | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 src/collaboration/awarenessRemotePresentationAccess.test.ts 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); + }); +}); From dbd42a4a52c385d7e2c92e6120abac1b588f6a9b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 18:08:55 -0700 Subject: [PATCH 37/54] fix(collaboration): avoid executing remote presentation accessors --- src/collaboration/awareness.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/collaboration/awareness.ts b/src/collaboration/awareness.ts index c7f1c8a5..1a185fd0 100644 --- a/src/collaboration/awareness.ts +++ b/src/collaboration/awareness.ts @@ -258,9 +258,10 @@ export function renderCollaborationCursor( user: Record, ): HTMLElement { const color = collaborationCursorColor(user); + const remoteName = ownEnumerableDataValue(user, 'name'); const name = - typeof user.name === 'string' && user.name.trim() !== '' - ? truncateCursorLabel(user.name) + typeof remoteName === 'string' && remoteName.trim() !== '' + ? truncateCursorLabel(remoteName) : 'Collaborator'; const caret = document.createElement('span'); @@ -312,8 +313,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; } From bf82a04424138e69ea51085318a5c3348d524a2a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 18:09:46 -0700 Subject: [PATCH 38/54] test(collaboration): preflight oversized remote cursor names --- ...warenessRemotePresentationResource.test.ts | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 src/collaboration/awarenessRemotePresentationResource.test.ts 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'); + }); +}); From 9aab1381cc007c42d714db1a756a8c7a0966d48b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 18:10:18 -0700 Subject: [PATCH 39/54] fix(collaboration): preflight remote cursor name source length --- src/collaboration/awareness.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/collaboration/awareness.ts b/src/collaboration/awareness.ts index 1a185fd0..2fa4f2ca 100644 --- a/src/collaboration/awareness.ts +++ b/src/collaboration/awareness.ts @@ -24,7 +24,7 @@ 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_IDENTIFIER_SOURCE_LENGTH = 1_024; +const MAX_REMOTE_FIELD_SOURCE_LENGTH = 1_024; const MAX_LOCAL_FIELD_SOURCE_LENGTH = 1_024; /** Reject malformed or oversized local identity fields before normalization. */ @@ -157,7 +157,7 @@ export function createScopedCollaborationProvider( source.setLocalStateField(field, value), on: (event, listener) => { if (listenerWrappers[event].has(listener)) return; - const wrapper = (...args: unknown[]) => listener(...args); + const wrapper = (...args: unknown[]) => void listener(...args); listenerWrappers[event].set(listener, wrapper); source.on(event, wrapper); }, @@ -214,7 +214,7 @@ export function countRemoteCollaborators( if (typeof user !== 'object' || user === null) continue; const id = ownEnumerableDataValue(user, 'id'); if (typeof id !== 'string') continue; - if (id.length > MAX_REMOTE_IDENTIFIER_SOURCE_LENGTH) continue; + if (id.length > MAX_REMOTE_FIELD_SOURCE_LENGTH) continue; const normalizedId = id.trim(); if ( normalizedId === '' || @@ -260,7 +260,9 @@ export function renderCollaborationCursor( const color = collaborationCursorColor(user); const remoteName = ownEnumerableDataValue(user, 'name'); const name = - typeof remoteName === 'string' && remoteName.trim() !== '' + typeof remoteName === 'string' && + remoteName.length <= MAX_REMOTE_FIELD_SOURCE_LENGTH && + remoteName.trim() !== '' ? truncateCursorLabel(remoteName) : 'Collaborator'; From 86521179d615be192a96ff9c04d59ce08d51502c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 18:11:04 -0700 Subject: [PATCH 40/54] fix(collaboration): preserve scoped listener return semantics --- src/collaboration/awareness.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/collaboration/awareness.ts b/src/collaboration/awareness.ts index 2fa4f2ca..6455797b 100644 --- a/src/collaboration/awareness.ts +++ b/src/collaboration/awareness.ts @@ -157,7 +157,7 @@ export function createScopedCollaborationProvider( source.setLocalStateField(field, value), on: (event, listener) => { if (listenerWrappers[event].has(listener)) return; - const wrapper = (...args: unknown[]) => void listener(...args); + const wrapper = (...args: unknown[]) => listener(...args); listenerWrappers[event].set(listener, wrapper); source.on(event, wrapper); }, From 14e640be45b9013b02b863580d67788186f41676 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 18:34:41 -0700 Subject: [PATCH 41/54] test(collaboration): redact provider capability access failures --- ...oviderConfigurationRuntimeBoundary.test.ts | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 src/collaboration/providerConfigurationRuntimeBoundary.test.ts 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', + ); + }); +}); From a7ae99c9d4a8470dea1e8246d98bacc79df442f7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 18:38:43 -0700 Subject: [PATCH 42/54] fix(collaboration): contain provider capability access failures --- src/collaboration/awareness.ts | 32 +++++++++++++++++++------------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/src/collaboration/awareness.ts b/src/collaboration/awareness.ts index 6455797b..bb18a814 100644 --- a/src/collaboration/awareness.ts +++ b/src/collaboration/awareness.ts @@ -109,19 +109,25 @@ 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' - ) { + let isCompatibleAwareness = false; + try { + const awareness = provider.awareness as + | Partial + | undefined; + isCompatibleAwareness = + 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'; + } catch { + isCompatibleAwareness = false; + } + + if (!isCompatibleAwareness) { throw new Error( 'collaboration provider must expose a compatible Yjs awareness instance', ); From 7209a86d45908ab9bd9118591445ac6cf666cf49 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 19:06:47 -0700 Subject: [PATCH 43/54] test(collaboration): contain provider awareness reread failures --- .../awarenessProviderAccess.test.ts | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 src/collaboration/awarenessProviderAccess.test.ts diff --git a/src/collaboration/awarenessProviderAccess.test.ts b/src/collaboration/awarenessProviderAccess.test.ts new file mode 100644 index 00000000..fea71da5 --- /dev/null +++ b/src/collaboration/awarenessProviderAccess.test.ts @@ -0,0 +1,46 @@ +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', + ), + ); + }); +}); From eda95121085bfd42c14756002b7c531b88b008ae Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 19:11:01 -0700 Subject: [PATCH 44/54] fix(collaboration): normalize provider awareness access failures --- src/collaboration/awareness.ts | 55 +++++++++++++++++++--------------- 1 file changed, 31 insertions(+), 24 deletions(-) diff --git a/src/collaboration/awareness.ts b/src/collaboration/awareness.ts index bb18a814..f2b63a08 100644 --- a/src/collaboration/awareness.ts +++ b/src/collaboration/awareness.ts @@ -65,6 +65,35 @@ function exceedsPublicIdentifierLength(value: string): boolean { 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, @@ -109,29 +138,7 @@ export function assertCollaborationConfiguration( } if (!provider) return; - let isCompatibleAwareness = false; - try { - const awareness = provider.awareness as - | Partial - | undefined; - isCompatibleAwareness = - 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'; - } catch { - isCompatibleAwareness = false; - } - - if (!isCompatibleAwareness) { - throw new Error( - 'collaboration provider must expose a compatible Yjs awareness instance', - ); - } + readCompatibleCollaborationAwareness(provider); } /** @@ -141,7 +148,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> From de6f54b44fa9b7f771784c9f6635a451374b3029 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 19:24:20 -0700 Subject: [PATCH 45/54] test(collaboration): lock structural awareness error redaction --- .../awarenessProviderAccess.test.ts | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/collaboration/awarenessProviderAccess.test.ts b/src/collaboration/awarenessProviderAccess.test.ts index fea71da5..65eeec0a 100644 --- a/src/collaboration/awarenessProviderAccess.test.ts +++ b/src/collaboration/awarenessProviderAccess.test.ts @@ -43,4 +43,28 @@ describe('collaboration provider capability access', () => { ), ); }); + + 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', + ); + }); }); From 76df70b303ed2a74ee5f33212a1e71fcd65ba001 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 21:02:23 -0700 Subject: [PATCH 46/54] test(collaboration): redact hostile local user field failures --- ...awarenessLocalUserFailureRedaction.test.ts | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 src/collaboration/awarenessLocalUserFailureRedaction.test.ts 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', + ); + }, + ); +}); From 4b74b65cb52becc4e18f72ad480fe9d9c206ddfb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 21:04:00 -0700 Subject: [PATCH 47/54] fix(collaboration): contain hostile local user field access --- src/collaboration/awareness.ts | 30 +++++++++++++++++++++++------- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/src/collaboration/awareness.ts b/src/collaboration/awareness.ts index f2b63a08..ba486fc1 100644 --- a/src/collaboration/awareness.ts +++ b/src/collaboration/awareness.ts @@ -26,10 +26,11 @@ 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: 'userId' | 'displayName' | 'cursorColor', + field: CollaborationUserField, value: unknown, ): asserts value is string { if (typeof value !== 'string') { @@ -42,6 +43,21 @@ function assertCollaborationUserStringField( } } +/** 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(); @@ -98,12 +114,12 @@ function readCompatibleCollaborationAwareness( export function serializeCollaborationUser( user: CollaborationUser, ): CollaborationCursorUser { - assertCollaborationUserStringField('userId', user.userId); - assertCollaborationUserStringField('displayName', user.displayName); - assertCollaborationUserStringField('cursorColor', user.cursorColor); - const id = user.userId.trim(); - const name = truncateCursorLabel(user.displayName); - 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'); From f07819b4252a0735cbe41619ef3c9117d5b7dc44 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 22:03:48 -0700 Subject: [PATCH 48/54] test(collaboration): contain awareness count failures --- .../awarenessCountFailureContainment.test.ts | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 src/collaboration/awarenessCountFailureContainment.test.ts 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); + }); +}); From 6a01457ad7a044405a100a5b563f1b718fe3d220 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 22:07:09 -0700 Subject: [PATCH 49/54] fix(collaboration): contain awareness count failures --- src/collaboration/awareness.ts | 41 +++++++++++++++++++--------------- 1 file changed, 23 insertions(+), 18 deletions(-) diff --git a/src/collaboration/awareness.ts b/src/collaboration/awareness.ts index ba486fc1..76e0cd5f 100644 --- a/src/collaboration/awareness.ts +++ b/src/collaboration/awareness.ts @@ -231,30 +231,35 @@ function ownEnumerableDataValue( } } -/** Count remote awareness clients carrying a valid public user identifier. */ +/** 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 = 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; + 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; } - count += 1; + return count; + } catch { + return 0; } - return count; } /** Convert a host connection state into concise status-region text. */ From d14dcd5aa935e21a3e7d10c12797f5ee8863b1b4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 22:32:31 -0700 Subject: [PATCH 50/54] test(collaboration): prove cleanup failure containment --- ...awarenessDisposeFailureContainment.test.ts | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 src/collaboration/awarenessDisposeFailureContainment.test.ts 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); + }); +}); From a03c7d04a11f7d7c7274e466247b38057fc19bb7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 22:33:16 -0700 Subject: [PATCH 51/54] fix(collaboration): contain listener teardown failures --- src/collaboration/awareness.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/collaboration/awareness.ts b/src/collaboration/awareness.ts index 76e0cd5f..4cf66bf1 100644 --- a/src/collaboration/awareness.ts +++ b/src/collaboration/awareness.ts @@ -206,7 +206,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(); } From be5e0a1a1d87a297160a259d3df24cca90c0ca47 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 22:59:54 -0700 Subject: [PATCH 52/54] test(collaboration): expose listener registration false-success --- .../awarenessListenerContainment.test.ts | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 src/collaboration/awarenessListenerContainment.test.ts diff --git a/src/collaboration/awarenessListenerContainment.test.ts b/src/collaboration/awarenessListenerContainment.test.ts new file mode 100644 index 00000000..28e7e919 --- /dev/null +++ b/src/collaboration/awarenessListenerContainment.test.ts @@ -0,0 +1,41 @@ +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 }; +} + +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); + }); +}); From 3694bfaafc96501c0e164668a43870568ee74ce9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 23:01:30 -0700 Subject: [PATCH 53/54] test(collaboration): cover listener removal containment --- .../awarenessListenerContainment.test.ts | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/src/collaboration/awarenessListenerContainment.test.ts b/src/collaboration/awarenessListenerContainment.test.ts index 28e7e919..e2b7625b 100644 --- a/src/collaboration/awarenessListenerContainment.test.ts +++ b/src/collaboration/awarenessListenerContainment.test.ts @@ -24,6 +24,28 @@ function awarenessWithListenerRegistrationFailure(): { 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(); @@ -38,4 +60,19 @@ describe('scoped collaboration provider listener containment', () => { 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); + }); }); From d8407c5fc65027233c880cae2d1f2d14d5261d91 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 23:02:28 -0700 Subject: [PATCH 54/54] fix(collaboration): contain listener registration failures --- src/collaboration/awareness.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/collaboration/awareness.ts b/src/collaboration/awareness.ts index 4cf66bf1..fa345a3c 100644 --- a/src/collaboration/awareness.ts +++ b/src/collaboration/awareness.ts @@ -187,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); }, };