Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
54 commits
Select commit Hold shift + click to select a range
b158370
test(collaboration): prove outbound display-name bound
seonghobae Aug 10, 2026
07f1bce
fix(collaboration): bound outbound display names
seonghobae Aug 10, 2026
0ce7697
test(collaboration): preserve Unicode cursor labels
seonghobae Aug 10, 2026
86b45e5
fix(collaboration): preserve Unicode cursor labels
seonghobae Aug 10, 2026
f62caf5
test(collaboration): reject invalid presence identities
seonghobae Aug 10, 2026
fdc7bbd
fix(collaboration): validate remote presence identifiers
seonghobae Aug 11, 2026
b34218b
test(collaboration): bound public awareness identifiers
seonghobae Aug 11, 2026
b1b8f7f
fix(collaboration): bound public awareness identifiers
seonghobae Aug 11, 2026
561bdef
test(collaboration): reject oversized remote identities
seonghobae Aug 11, 2026
e44c75a
test(collaboration): prove Unicode identifier ceiling
seonghobae Aug 11, 2026
5e47fc3
docs(collaboration): record bounded awareness metadata
seonghobae Aug 11, 2026
68ccada
test(collaboration): prove remote Unicode identity boundary
seonghobae Aug 11, 2026
74b7704
test(docs): lock collaboration awareness contract
seonghobae Aug 11, 2026
7e2f475
docs(api): document collaboration awareness bounds
seonghobae Aug 11, 2026
69ccbd9
test(collaboration): lock public awareness entrypoint
seonghobae Aug 11, 2026
d705e43
test(collaboration): expose metadata array allocation
seonghobae Aug 11, 2026
2e9140e
fix(collaboration): bound Unicode awareness scans without arrays
seonghobae Aug 11, 2026
5828f81
test: reject invalid collaboration status
seonghobae Aug 11, 2026
c95eeef
fix: reject invalid collaboration status
seonghobae Aug 11, 2026
1babeda
test(data-integrity): reject malformed collaboration identity fields
seonghobae Aug 13, 2026
098c6b5
fix(data-integrity): validate collaboration identity fields
seonghobae Aug 13, 2026
a660f54
test(collaboration): reject malformed public contrast colors
seonghobae Aug 13, 2026
91efbcc
fix(collaboration): validate public contrast colors
seonghobae Aug 13, 2026
caa01c7
test(collaboration): require raw awareness id preflight
seonghobae Aug 13, 2026
03e2c62
fix(collaboration): preflight remote awareness ids
seonghobae Aug 13, 2026
386ea99
test(collaboration): preflight local awareness metadata
seonghobae Aug 15, 2026
72913d0
fix(collaboration): bound local awareness metadata before normalization
seonghobae Aug 15, 2026
0be2d92
test(collaboration): keep identifier allocation proof within source c…
seonghobae Aug 15, 2026
843bb18
test(reliability): reject accessor-backed remote awareness identity
seonghobae Aug 17, 2026
ba9315e
test(reliability): contain remote awareness reflection failures
seonghobae Aug 17, 2026
6551d86
fix(reliability): contain hostile remote awareness accessors
seonghobae Aug 17, 2026
98f8b42
test(collaboration): cover remote descriptor visibility branches
seonghobae Aug 17, 2026
b99cfcb
test(collaboration): cover remote id descriptor branches
seonghobae Aug 17, 2026
b1401f5
refactor(collaboration): make remote descriptor guards explicit
seonghobae Aug 17, 2026
65cc671
test(collaboration): cover malformed remote state shapes
seonghobae Aug 17, 2026
b74eed7
test(collaboration): reject remote presentation accessor execution
seonghobae Aug 19, 2026
dbd42a4
fix(collaboration): avoid executing remote presentation accessors
seonghobae Aug 19, 2026
bf82a04
test(collaboration): preflight oversized remote cursor names
seonghobae Aug 19, 2026
9aab138
fix(collaboration): preflight remote cursor name source length
seonghobae Aug 19, 2026
8652117
fix(collaboration): preserve scoped listener return semantics
seonghobae Aug 19, 2026
14e640b
test(collaboration): redact provider capability access failures
seonghobae Aug 19, 2026
a7ae99c
fix(collaboration): contain provider capability access failures
seonghobae Aug 19, 2026
7209a86
test(collaboration): contain provider awareness reread failures
seonghobae Aug 19, 2026
eda9512
fix(collaboration): normalize provider awareness access failures
seonghobae Aug 19, 2026
de6f54b
test(collaboration): lock structural awareness error redaction
seonghobae Aug 19, 2026
76df70b
test(collaboration): redact hostile local user field failures
seonghobae Aug 19, 2026
4b74b65
fix(collaboration): contain hostile local user field access
seonghobae Aug 19, 2026
f07819b
test(collaboration): contain awareness count failures
seonghobae Aug 19, 2026
6a01457
fix(collaboration): contain awareness count failures
seonghobae Aug 19, 2026
d14dcd5
test(collaboration): prove cleanup failure containment
seonghobae Aug 19, 2026
a03c7d0
fix(collaboration): contain listener teardown failures
seonghobae Aug 19, 2026
be5e0a1
test(collaboration): expose listener registration false-success
seonghobae Aug 19, 2026
3694bfa
test(collaboration): cover listener removal containment
seonghobae Aug 19, 2026
d8407c5
fix(collaboration): contain listener registration failures
seonghobae Aug 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 10 additions & 7 deletions docs/collaboration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
8 changes: 8 additions & 0 deletions src/collaboration/awareness.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
219 changes: 177 additions & 42 deletions src/collaboration/awareness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,21 +23,115 @@ 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<CollaborationAwareness>
| 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');
}
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');
}
Expand All @@ -60,23 +154,7 @@ export function assertCollaborationConfiguration(
}
if (!provider) return;

const awareness = provider.awareness as
| Partial<CollaborationAwareness>
| 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);
}

/**
Expand All @@ -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>
Expand All @@ -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);
},
};
Expand All @@ -128,40 +214,74 @@ 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();
}
},
};
}

/** 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<string, unknown>).id === 'string' &&
(user as Record<string, unknown>).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. */
export function collaborationConnectionLabel(
status: CollaborationConnectionStatus | undefined,
): string {
switch (status) {
case undefined:
return 'Collaboration ready';
case 'connecting':
return 'Connecting';
case 'connected':
Expand All @@ -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.',
);
}
}

Expand All @@ -183,9 +305,12 @@ export function renderCollaborationCursor(
user: Record<string, unknown>,
): 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');
Expand Down Expand Up @@ -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;
Expand All @@ -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, unknown>): 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;
}
29 changes: 29 additions & 0 deletions src/collaboration/awarenessContrastColor.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
Loading
Loading