Skip to content

Commit d2d0b0f

Browse files
authored
feat(cli): support custom WebSocket query params in collaboration config (SD-2543) (#2799)
* feat(cli): support custom WebSocket query params in collaboration config SD-2543: Adds an optional `params` field to the CLI collaboration config, forwarded as query parameters on the y-websocket URL and as `parameters` on the Hocuspocus provider. Validates that values are strings and rejects the reserved `token` key (set automatically from `tokenEnv`). The field is omitted from the public collaboration summary since it may contain identifying metadata. * fix(cli): preserve params on session rehydration and fingerprint nested fields Follow-up to SD-2543 addressing two correctness bugs surfaced in review: 1. normalizeWebSocketProfile in context.ts dropped params when rehydrating persisted collab metadata from disk, so the reconnect reopened without the user-supplied query params. 2. profileToFingerprint in session-pool.ts used JSON.stringify's array replacer as a sort helper, but that filter applies at every depth and silently stripped nested objects. Two profiles differing only in params hashed identically, causing the pool to reuse the wrong session. Replaces the fingerprint helper with a recursive stable-stringify and adds params validation/preservation to the context normalizer. Adds mocked runtime tests covering the y-websocket and hocuspocus provider handoffs, the params+token merge order, and fingerprint key-order independence. * docs(document-engine): document collaboration params field Adds a "Forward custom WebSocket query parameters" section to the SDK guide covering the new `params` field: scope (y-websocket and Hocuspocus only, not Liveblocks), the string-only shape, and the reserved `token` key. * fix(cli): handle object schemas without explicit properties The CLI's JSON schema validator crashed on `type: 'object'` schemas that omitted the `properties` map (e.g. schemas that only declare `additionalProperties`). `Object.entries(schema.properties)` threw `Cannot convert undefined or null to object` before reaching the downstream parser. Default `schema.properties` to `{}` in both the request and response validators so these schemas pass through to the layer that can actually validate their contents. Exposed by SD-2543's collaboration.params schema, which is the first schema in the repo to use this shape.
1 parent 64d65a7 commit d2d0b0f

14 files changed

Lines changed: 488 additions & 15 deletions

File tree

apps/cli/src/__tests__/lib/context.test.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,68 @@ describe('normalizeContextMetadata', () => {
102102
expect(result.collaboration).toBeUndefined();
103103
});
104104

105+
test('preserves websocket params on rehydration', () => {
106+
const metadata = makeMetadata({
107+
sessionType: 'collab',
108+
collaboration: {
109+
providerType: 'y-websocket',
110+
url: 'ws://localhost:4000',
111+
documentId: 'test-doc',
112+
params: { customAttributions: 'agent_id:abc', region: 'us-east-1' },
113+
} as any,
114+
});
115+
const result = normalizeContextMetadata(metadata);
116+
expect(result.sessionType).toBe('collab');
117+
expect(result.collaboration).toMatchObject({
118+
params: { customAttributions: 'agent_id:abc', region: 'us-east-1' },
119+
});
120+
});
121+
122+
test('preserves websocket profile when params is absent', () => {
123+
const metadata = makeMetadata({
124+
sessionType: 'collab',
125+
collaboration: {
126+
providerType: 'y-websocket',
127+
url: 'ws://localhost:4000',
128+
documentId: 'test-doc',
129+
},
130+
});
131+
const result = normalizeContextMetadata(metadata);
132+
expect(result.sessionType).toBe('collab');
133+
expect(result.collaboration).toBeDefined();
134+
expect((result.collaboration as any).params).toBeUndefined();
135+
});
136+
137+
test('rejects websocket profile with non-object params', () => {
138+
const metadata = makeMetadata({
139+
sessionType: 'collab',
140+
collaboration: {
141+
providerType: 'y-websocket',
142+
url: 'ws://localhost:4000',
143+
documentId: 'test-doc',
144+
params: 'not-an-object',
145+
} as any,
146+
});
147+
const result = normalizeContextMetadata(metadata);
148+
expect(result.sessionType).toBe('local');
149+
expect(result.collaboration).toBeUndefined();
150+
});
151+
152+
test('rejects websocket profile with non-string param values', () => {
153+
const metadata = makeMetadata({
154+
sessionType: 'collab',
155+
collaboration: {
156+
providerType: 'y-websocket',
157+
url: 'ws://localhost:4000',
158+
documentId: 'test-doc',
159+
params: { count: 42 },
160+
} as any,
161+
});
162+
const result = normalizeContextMetadata(metadata);
163+
expect(result.sessionType).toBe('local');
164+
expect(result.collaboration).toBeUndefined();
165+
});
166+
105167
test('preserves Liveblocks collab profile with publicApiKey', () => {
106168
const metadata = makeMetadata({
107169
sessionType: 'collab',

apps/cli/src/__tests__/lib/validate-type-spec.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,3 +142,23 @@ describe('doc.find select schema — accepts canonical and shorthand forms', ()
142142
expect(() => validateValueAgainstTypeSpec({ type: 'text' }, schema, 'select')).toThrow(CliError);
143143
});
144144
});
145+
146+
describe('validateValueAgainstTypeSpec – object without explicit properties', () => {
147+
// type: 'object' schemas that use additionalProperties (or nothing at all)
148+
// must not crash the validator when `properties` is absent.
149+
const schema = {
150+
type: 'object',
151+
additionalProperties: { type: 'string' },
152+
} as unknown as CliTypeSpec;
153+
154+
test('accepts any object when properties is absent', () => {
155+
expect(() => validateValueAgainstTypeSpec({ foo: 'bar' }, schema, 'params')).not.toThrow();
156+
expect(() => validateValueAgainstTypeSpec({}, schema, 'params')).not.toThrow();
157+
});
158+
159+
test('still rejects non-object values', () => {
160+
expect(() => validateValueAgainstTypeSpec('nope', schema, 'params')).toThrow(CliError);
161+
expect(() => validateValueAgainstTypeSpec(42, schema, 'params')).toThrow(CliError);
162+
expect(() => validateValueAgainstTypeSpec(null, schema, 'params')).toThrow(CliError);
163+
});
164+
});

apps/cli/src/cli/operation-params.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -987,6 +987,12 @@ const CLI_ONLY_METADATA: Record<CliOnlyOperationId, CliOperationMetadata> = {
987987
description: 'Room/document identifier. Defaults to session ID if omitted.',
988988
},
989989
tokenEnv: { type: 'string', description: 'Environment variable name containing the auth token.' },
990+
params: {
991+
type: 'object',
992+
description:
993+
'Custom query parameters appended to the WebSocket URL. Values must be strings. Reserved keys: token.',
994+
additionalProperties: { type: 'string' },
995+
},
990996
syncTimeoutMs: { type: 'number', description: 'Max time (ms) to wait for initial sync.' },
991997
onMissing: {
992998
type: 'string',

apps/cli/src/host/session-pool.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,31 @@ describe('InMemorySessionPool', () => {
119119
expect(openCollabCalls.length).toBe(2);
120120
});
121121

122+
test('discards collab session when params differ', async () => {
123+
const { pool, openCollabCalls } = createPool();
124+
125+
const profileA = { ...COLLAB_PROFILE, params: { region: 'us' } };
126+
await pool.acquire('s1', { ...COLLAB_METADATA, collaboration: profileA }, TEST_IO);
127+
128+
const profileB = { ...COLLAB_PROFILE, params: { region: 'eu' } };
129+
await pool.acquire('s1', { ...COLLAB_METADATA, collaboration: profileB }, TEST_IO);
130+
131+
expect(openCollabCalls.length).toBe(2);
132+
});
133+
134+
test('reuses collab session when params match (key order independent)', async () => {
135+
const { pool, openCollabCalls } = createPool();
136+
137+
const profileA = { ...COLLAB_PROFILE, params: { region: 'us', tier: 'pro' } };
138+
const first = await pool.acquire('s1', { ...COLLAB_METADATA, collaboration: profileA }, TEST_IO);
139+
first.dispose();
140+
141+
const profileB = { ...COLLAB_PROFILE, params: { tier: 'pro', region: 'us' } };
142+
await pool.acquire('s1', { ...COLLAB_METADATA, collaboration: profileB }, TEST_IO);
143+
144+
expect(openCollabCalls.length).toBe(1);
145+
});
146+
122147
test('reuses collab session when fingerprint matches', async () => {
123148
const { pool, openCollabCalls } = createPool();
124149

apps/cli/src/host/session-pool.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -74,12 +74,24 @@ export interface SessionPool {
7474
}
7575

7676
// ---------------------------------------------------------------------------
77-
// Collab fingerprint (preserved from old pool)
77+
// Collab fingerprint
7878
// ---------------------------------------------------------------------------
7979

80+
// Stable stringify that sorts keys at every depth so nested objects (e.g.
81+
// `params`) contribute to the hash. JSON.stringify's array replacer is a
82+
// single global allow-list applied at all depths, which silently strips
83+
// unlisted nested keys.
84+
function stableStringify(value: unknown): string {
85+
if (value === null || typeof value !== 'object') return JSON.stringify(value);
86+
if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`;
87+
const entries = Object.keys(value as Record<string, unknown>)
88+
.sort()
89+
.map((key) => `${JSON.stringify(key)}:${stableStringify((value as Record<string, unknown>)[key])}`);
90+
return `{${entries.join(',')}}`;
91+
}
92+
8093
function profileToFingerprint(profile: CollaborationProfile): string {
81-
const sortedJson = JSON.stringify(profile, Object.keys(profile).sort());
82-
return createHash('sha256').update(sortedJson).digest('hex');
94+
return createHash('sha256').update(stableStringify(profile)).digest('hex');
8395
}
8496

8597
// ---------------------------------------------------------------------------

apps/cli/src/lib/collaboration/__tests__/parse.test.ts

Lines changed: 90 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -80,12 +80,65 @@ describe('parseCollaborationInput — websocket', () => {
8080
);
8181
});
8282

83-
test('rejects params field', () => {
84-
expect(() => parseCollaborationInput({ providerType: 'y-websocket', url: 'ws://x', params: {} })).toThrow(
85-
'collaboration.params is not supported',
83+
test('accepts params field with string values', () => {
84+
const input = parseCollaborationInput({
85+
providerType: 'y-websocket',
86+
url: 'ws://x',
87+
params: { customAttributions: 'agent_id:abc', region: 'us-east-1' },
88+
});
89+
expect(input).toMatchObject({
90+
params: { customAttributions: 'agent_id:abc', region: 'us-east-1' },
91+
});
92+
});
93+
94+
test('omits params when not provided', () => {
95+
const input = parseCollaborationInput({
96+
providerType: 'y-websocket',
97+
url: 'ws://x',
98+
}) as WebSocketCollaborationInput;
99+
expect(input.params).toBeUndefined();
100+
});
101+
102+
test('rejects non-object params', () => {
103+
expect(() =>
104+
parseCollaborationInput({ providerType: 'y-websocket', url: 'ws://x', params: 'not-an-object' }),
105+
).toThrow('collaboration.params must be an object of string key-value pairs');
106+
expect(() => parseCollaborationInput({ providerType: 'y-websocket', url: 'ws://x', params: ['a', 'b'] })).toThrow(
107+
'collaboration.params must be an object of string key-value pairs',
86108
);
87109
});
88110

111+
test('rejects non-string param values', () => {
112+
expect(() =>
113+
parseCollaborationInput({ providerType: 'y-websocket', url: 'ws://x', params: { count: 42 } }),
114+
).toThrow('collaboration.params.count must be a string');
115+
expect(() =>
116+
parseCollaborationInput({ providerType: 'y-websocket', url: 'ws://x', params: { flag: true } }),
117+
).toThrow('collaboration.params.flag must be a string');
118+
expect(() =>
119+
parseCollaborationInput({ providerType: 'y-websocket', url: 'ws://x', params: { nested: { a: 'b' } } }),
120+
).toThrow('collaboration.params.nested must be a string');
121+
});
122+
123+
test('rejects reserved token key in params', () => {
124+
expect(() =>
125+
parseCollaborationInput({
126+
providerType: 'y-websocket',
127+
url: 'ws://x',
128+
params: { token: 'secret' },
129+
}),
130+
).toThrow('collaboration.params.token is reserved');
131+
});
132+
133+
test('accepts params with hocuspocus provider', () => {
134+
const input = parseCollaborationInput({
135+
providerType: 'hocuspocus',
136+
url: 'ws://x',
137+
params: { workspaceId: 'ws_123' },
138+
});
139+
expect(input).toMatchObject({ providerType: 'hocuspocus', params: { workspaceId: 'ws_123' } });
140+
});
141+
89142
test('rejects Liveblocks-only fields on websocket providers', () => {
90143
expect(() => parseCollaborationInput({ providerType: 'y-websocket', url: 'ws://x', roomId: 'room' })).toThrow(
91144
'collaboration.roomId is not supported for websocket',
@@ -236,6 +289,17 @@ describe('parseCollaborationInput — liveblocks', () => {
236289
).toThrow('collaboration.headers is not supported');
237290
});
238291

292+
test('rejects params field', () => {
293+
expect(() =>
294+
parseCollaborationInput({
295+
providerType: 'liveblocks',
296+
roomId: 'room',
297+
publicApiKey: 'pk_xxx',
298+
params: { foo: 'bar' },
299+
}),
300+
).toThrow('collaboration.params is not supported for Liveblocks');
301+
});
302+
239303
test('rejects unknown keys', () => {
240304
expect(() =>
241305
parseCollaborationInput({
@@ -272,6 +336,19 @@ describe('resolveCollaborationProfile', () => {
272336
expect(profile.documentId).toBe('explicit-doc');
273337
});
274338

339+
test('websocket: params pass through to profile', () => {
340+
const input = parseCollaborationInput({
341+
providerType: 'y-websocket',
342+
url: 'ws://localhost:4000',
343+
params: { customAttributions: 'agent_id:abc' },
344+
});
345+
const profile = resolveCollaborationProfile(input, 'session');
346+
expect(profile).toMatchObject({
347+
providerType: 'y-websocket',
348+
params: { customAttributions: 'agent_id:abc' },
349+
});
350+
});
351+
275352
test('liveblocks: roomId maps to documentId directly', () => {
276353
const input = parseCollaborationInput({
277354
providerType: 'liveblocks',
@@ -302,6 +379,16 @@ describe('toPublicCollaborationSummary', () => {
302379
});
303380
});
304381

382+
test('websocket: omits params (may contain identifying metadata)', () => {
383+
const summary = toPublicCollaborationSummary({
384+
providerType: 'y-websocket',
385+
url: 'ws://localhost:4000',
386+
documentId: 'doc-1',
387+
params: { userId: 'secret-user-id' },
388+
});
389+
expect(summary).not.toHaveProperty('params');
390+
});
391+
305392
test('liveblocks: excludes auth config', () => {
306393
const summary = toPublicCollaborationSummary({
307394
providerType: 'liveblocks',

0 commit comments

Comments
 (0)