@spotify-confidence/csr-common 0.18.10 → 0.18.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,6 +2,7 @@ import type { CreateUploaderOptions, Frame, Uploader } from './types';
2
2
  import { ClientContext, collectUserAgentContext } from './client-context';
3
3
  import { isSessionActivityEvent } from '../session-activity';
4
4
  import { workerScript } from './worker/worker-script';
5
+ import { WORKER_HASH } from './worker-hash';
5
6
 
6
7
  const STORAGE_TAB_ID = 'csr:tabId';
7
8
  const STORAGE_SESSION = 'csr:session';
@@ -18,6 +19,7 @@ interface PortLike {
18
19
  interface WelcomeMessage {
19
20
  type: 'welcome';
20
21
  result: { sessionId: string; sessionToken: string } | { skipRecording: true };
22
+ workerHash?: string;
21
23
  adoptedFromSessionId?: string;
22
24
  /** Worker-assigned fresh tabId because this tab is a duplicate of another live one. */
23
25
  newTabId?: string;
@@ -154,6 +156,12 @@ export async function createUploader(opts: CreateUploaderOptions): Promise<Uploa
154
156
  ? `tab: welcome (${'sessionId' in welcome.result ? `sessionId=${welcome.result.sessionId}` : 'skipRecording'})`
155
157
  : `tab: dead reason=${welcome.reason}`,
156
158
  );
159
+ if (welcome.type === 'welcome' && urlScheme === 'custom' && welcome.workerHash !== WORKER_HASH) {
160
+ log?.(
161
+ 'tab: WORKER MISMATCH — the self-hosted confidence-worker.js does not match the installed SDK. ' +
162
+ 'Copy the updated file from node_modules/@spotify-confidence/session-recording/dist/confidence-worker.js',
163
+ );
164
+ }
157
165
  if (welcome.type === 'dead') {
158
166
  throw new Error(`uploader: ${welcome.reason}`);
159
167
  }
@@ -13,3 +13,4 @@ export {
13
13
  * SharedWorker sharing (per-document blob URLs defeat sharing).
14
14
  */
15
15
  export { workerScript } from './worker/worker-script';
16
+ export { WORKER_HASH } from './worker-hash';
@@ -10,11 +10,12 @@ export interface CreateUploaderOptions {
10
10
  apiUrl: string;
11
11
  /**
12
12
  * URL of the WebSocket ingest endpoint, including the path (e.g.
13
- * `wss://recording-ws.confidence.dev/sessions/stream`) but **without** any query
14
- * the worker appends `?session_token=…`. Optional: when omitted the worker derives
15
- * one from `apiUrl` by swapping `http(s)://` `ws(s)://` and appending
16
- * `/sessions/stream`. Set this when the init endpoint and the WS ingest live on
17
- * different hosts (e.g. prod).
13
+ * `wss://recording-ws.confidence.dev/sessions/stream`). The worker sends the session
14
+ * token through the WebSocket subprotocol header. A `session_token` query parameter is
15
+ * rejected. Other query parameters are retained. Optional: when omitted the worker
16
+ * derives one from `apiUrl` by swapping `http(s)://` `ws(s)://` and appending
17
+ * `/sessions/stream`. Set this when the init endpoint and the WS ingest live on different
18
+ * hosts (e.g. prod).
18
19
  */
19
20
  websocketUrl?: string;
20
21
  /** Per-tenant secret. Hashed to scope the SharedWorker so different secrets never share a session, and sent in the `initSession` request body. */
@@ -55,7 +56,7 @@ export interface CreateUploaderOptions {
55
56
  onTerminate?: (info: { reason: string }) => void;
56
57
  /**
57
58
  * Optional verbose tracer. Called on key tab- and worker-side events
58
- * (hello/welcome, init-session URL, ws connect URL, retries, transitions).
59
+ * (hello/welcome, init-session URL, credential-free ws connect URL, retries, transitions).
59
60
  * Worker messages are forwarded over the port and tagged so you can tell them apart.
60
61
  */
61
62
  debugLogger?: (msg: string) => void;
@@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest';
2
2
  import { createMockPort, installMockFetch, installMockWsServer, jsonResponse } from '../../test-utils';
3
3
 
4
4
  const API_URL = 'https://api.example';
5
- const WS_URL = 'wss://api.example/sessions/stream?session_token=tok-1';
5
+ const WS_URL = 'wss://api.example/sessions/stream';
6
6
 
7
7
  async function loadCore() {
8
8
  vi.resetModules();
@@ -27,6 +27,7 @@ const isType = (type: string) => (m: unknown) => (m as { type: string }).type ==
27
27
  interface WelcomeMessage {
28
28
  type: 'welcome';
29
29
  result: { sessionId: string; sessionToken: string } | { skipRecording: true };
30
+ workerHash?: string;
30
31
  newTabId?: string;
31
32
  resetCounter?: boolean;
32
33
  adoptedFromSessionId?: string;
@@ -37,15 +38,18 @@ interface DeadMessage {
37
38
  }
38
39
 
39
40
  describe('worker/core', () => {
40
- function setupBackend(initBody: unknown = { sessionId: 'sess-1', sessionToken: 'tok-1' }) {
41
+ function setupBackend(
42
+ initBody: unknown = { sessionId: 'sess-1', sessionToken: 'tok-1' },
43
+ selectProtocol?: (protocols: string[], connectionIndex: number) => string,
44
+ ) {
41
45
  const fetchHarness = installMockFetch(() => jsonResponse(initBody));
42
- const wsHarness = installMockWsServer(WS_URL);
46
+ const wsHarness = installMockWsServer(WS_URL, { selectProtocol });
43
47
  return { fetchHarness, wsHarness };
44
48
  }
45
49
 
46
50
  describe('first hello', () => {
47
51
  it('runs initSession + openTransport, then sends welcome', async () => {
48
- const { fetchHarness } = setupBackend();
52
+ const { fetchHarness, wsHarness } = setupBackend();
49
53
  const { registerPort } = await loadCore();
50
54
  const port = createMockPort();
51
55
  registerPort(port.adapter);
@@ -59,6 +63,34 @@ describe('worker/core', () => {
59
63
  sessionId: 'sess-1',
60
64
  sessionToken: 'tok-1',
61
65
  });
66
+ expect(wsHarness.connections[0].url).toBe(WS_URL);
67
+ expect(wsHarness.protocolOffers).toEqual([['recording.v1', 'auth.tok-1']]);
68
+ });
69
+
70
+ it('includes workerHash in welcome when set on globalThis', async () => {
71
+ (globalThis as Record<string, unknown>).__WORKER_HASH__ = 'abc123';
72
+ setupBackend();
73
+ const { registerPort } = await loadCore();
74
+ const port = createMockPort();
75
+ registerPort(port.adapter);
76
+
77
+ port.tabSends(helloMessage());
78
+ const welcome = await port.next<WelcomeMessage>(isType('welcome'));
79
+
80
+ expect(welcome.workerHash).toBe('abc123');
81
+ delete (globalThis as Record<string, unknown>).__WORKER_HASH__;
82
+ });
83
+
84
+ it('workerHash is undefined when not set on globalThis', async () => {
85
+ setupBackend();
86
+ const { registerPort } = await loadCore();
87
+ const port = createMockPort();
88
+ registerPort(port.adapter);
89
+
90
+ port.tabSends(helloMessage());
91
+ const welcome = await port.next<WelcomeMessage>(isType('welcome'));
92
+
93
+ expect(welcome.workerHash).toBeUndefined();
62
94
  });
63
95
 
64
96
  it('replies with skipRecording when the backend opts out', async () => {
@@ -222,9 +254,119 @@ describe('worker/core', () => {
222
254
  expect(debug.received.some(isType('log'))).toBe(true);
223
255
  expect(quiet.received.some(isType('log'))).toBe(false);
224
256
  });
257
+
258
+ it('does not expose a session token through configured URLs', async () => {
259
+ const token = 'leaky-sensitive';
260
+ setupBackend({ sessionId: 'session-1', sessionToken: token });
261
+ const { registerPort } = await loadCore();
262
+ const port = createMockPort();
263
+ registerPort(port.adapter);
264
+
265
+ port.tabSends(
266
+ helloMessage({
267
+ websocketUrl: `${WS_URL}?session_token=${token}`,
268
+ sessionIdHint: 'session-1',
269
+ sessionTokenHint: token,
270
+ debugLogs: true,
271
+ }),
272
+ );
273
+ await port.next<DeadMessage>(isType('dead'));
274
+
275
+ const logs = port.received.filter(isType('log')).map(message => (message as { msg: string }).msg);
276
+ expect(logs.length).toBeGreaterThan(0);
277
+ for (const log of logs) {
278
+ expect(log).not.toContain(token);
279
+ }
280
+ });
225
281
  });
226
282
 
227
283
  describe('lifecycle after active', () => {
284
+ it('uses a fresh header credential when a session hint fails protocol negotiation', async () => {
285
+ const { fetchHarness, wsHarness } = setupBackend(
286
+ { sessionId: 'fresh-session', sessionToken: 'fresh-sensitive' },
287
+ (protocols, connectionIndex) => (connectionIndex === 0 ? '' : protocols[0]),
288
+ );
289
+ const { registerPort } = await loadCore();
290
+ const port = createMockPort();
291
+ registerPort(port.adapter);
292
+
293
+ port.tabSends(
294
+ helloMessage({
295
+ sessionIdHint: 'stale-session',
296
+ sessionTokenHint: 'stale-sensitive',
297
+ debugLogs: true,
298
+ }),
299
+ );
300
+ const welcome = await port.next<WelcomeMessage>(isType('welcome'));
301
+
302
+ expect(welcome.result).toEqual({
303
+ sessionId: 'fresh-session',
304
+ sessionToken: 'fresh-sensitive',
305
+ });
306
+ expect(fetchHarness.calls).toHaveLength(1);
307
+ expect(wsHarness.protocolOffers).toEqual([
308
+ ['recording.v1', 'auth.stale-sensitive'],
309
+ ['recording.v1', 'auth.fresh-sensitive'],
310
+ ]);
311
+ expect(wsHarness.connections.every(connection => connection.url === WS_URL)).toBe(true);
312
+ const logs = port.received.filter(isType('log')).map(message => (message as { msg: string }).msg);
313
+ expect(logs.some(log => log.includes('sessionIdHint=stale-session'))).toBe(true);
314
+ expect(logs).toContain('adopting sessionIdHint=stale-session');
315
+ expect(logs).toContain('init-session ok sessionId=fresh-session');
316
+ for (const log of logs) {
317
+ expect(log).not.toContain('stale-sensitive');
318
+ expect(log).not.toContain('c3RhbGUtc2Vuc2l0aXZl');
319
+ expect(log).not.toContain('fresh-sensitive');
320
+ expect(log).not.toContain('ZnJlc2gtc2Vuc2l0aXZl');
321
+ }
322
+ });
323
+
324
+ it('stops when a hinted session upgrades with recording.v1 then immediately closes with 4401', async () => {
325
+ const { fetchHarness, wsHarness } = setupBackend();
326
+ // The API checks session state after the upgrade. A valid token for a missing or
327
+ // closed session therefore opens successfully before the server rejects it.
328
+ wsHarness.server.on('connection', () => {
329
+ setTimeout(() => wsHarness.server.close({ code: 4401, reason: 'Unauthorized', wasClean: true }), 0);
330
+ });
331
+ const { registerPort } = await loadCore();
332
+ const port = createMockPort();
333
+ registerPort(port.adapter);
334
+
335
+ port.tabSends(helloMessage({ sessionIdHint: 'stale-session', sessionTokenHint: 'stale-sensitive' }));
336
+ const welcome = await port.next<WelcomeMessage>(isType('welcome'));
337
+ const dead = await port.next<DeadMessage>(isType('dead'));
338
+
339
+ expect(welcome.result).toEqual({ sessionId: 'stale-session', sessionToken: 'stale-sensitive' });
340
+ expect(dead.reason).toBe('close code=4401 wasClean=true');
341
+ expect(fetchHarness.calls).toHaveLength(0);
342
+ expect(wsHarness.protocolOffers).toEqual([['recording.v1', 'auth.stale-sensitive']]);
343
+ expect(wsHarness.connections).toHaveLength(1);
344
+ expect(wsHarness.connections[0].url).toBe(WS_URL);
345
+ });
346
+
347
+ it('stops after one failed header-authenticated reconnect', async () => {
348
+ const { fetchHarness, wsHarness } = setupBackend(undefined, (protocols, connectionIndex) =>
349
+ connectionIndex === 0 ? protocols[0] : '',
350
+ );
351
+ const { registerPort } = await loadCore();
352
+ const port = createMockPort();
353
+ registerPort(port.adapter);
354
+
355
+ port.tabSends(helloMessage({ debugLogs: true }));
356
+ await port.next<WelcomeMessage>(isType('welcome'));
357
+ const first = await wsHarness.waitForConnection();
358
+ first.close({ code: 1000, reason: 'drain', wasClean: true });
359
+
360
+ const dead = await port.next<DeadMessage>(isType('dead'));
361
+ expect(dead.reason).toBe('reconnect-failed');
362
+ expect(fetchHarness.calls).toHaveLength(1);
363
+ expect(wsHarness.protocolOffers).toEqual([
364
+ ['recording.v1', 'auth.tok-1'],
365
+ ['recording.v1', 'auth.tok-1'],
366
+ ]);
367
+ expect(wsHarness.connections.every(connection => connection.url === WS_URL)).toBe(true);
368
+ });
369
+
228
370
  it('broadcasts dead to all ports when the transport closes abruptly', async () => {
229
371
  const { wsHarness } = setupBackend();
230
372
  const { registerPort } = await loadCore();
@@ -2,6 +2,8 @@ import type { ClientContext } from '../client-context';
2
2
  import type { Client, Frame, Transport } from '../types';
3
3
  import { CsrClient } from './csr-client';
4
4
 
5
+ const WORKER_HASH: string | undefined = (globalThis as Record<string, unknown>).__WORKER_HASH__ as string | undefined;
6
+
5
7
  /** Adapter so this module is unaware of whether it's running in a SharedWorker or a dedicated Worker. */
6
8
  export interface PortAdapter {
7
9
  postMessage(message: unknown): void;
@@ -173,7 +175,7 @@ function onHello(handle: PortHandle): void {
173
175
  };
174
176
  log(
175
177
  `hello received apiUrl=${handle.hello!.apiUrl} websocketUrl=${
176
- handle.hello!.websocketUrl ?? '(derive)'
178
+ handle.hello!.websocketUrl ? '(configured)' : '(derive)'
177
179
  } sessionIdHint=${handle.hello!.sessionIdHint ?? '(none)'}`,
178
180
  );
179
181
  state = { phase: 'initializing' };
@@ -202,6 +204,7 @@ function onHello(handle: PortHandle): void {
202
204
  handle.port.postMessage({
203
205
  type: 'welcome',
204
206
  result: { skipRecording: true },
207
+ workerHash: WORKER_HASH,
205
208
  });
206
209
  return;
207
210
  case 'dead':
@@ -332,6 +335,7 @@ function flushPendingWelcomes(): void {
332
335
  handle.port.postMessage({
333
336
  type: 'welcome',
334
337
  result: { skipRecording: true },
338
+ workerHash: WORKER_HASH,
335
339
  });
336
340
  } else if (state.phase === 'dead') {
337
341
  handle.port.postMessage({ type: 'dead', reason: state.reason });
@@ -346,6 +350,7 @@ function sendActiveWelcome(handle: PortHandle, currentSessionId: string, current
346
350
  handle.port.postMessage({
347
351
  type: 'welcome',
348
352
  result: { sessionId: currentSessionId, sessionToken: currentSessionToken },
353
+ workerHash: WORKER_HASH,
349
354
  adoptedFromSessionId: adopted ? hint : undefined,
350
355
  newTabId,
351
356
  resetCounter: adopted || newTabId !== undefined,
@@ -69,48 +69,63 @@ describe('CsrClient.initSession', () => {
69
69
  });
70
70
 
71
71
  describe('CsrClient.openTransport', () => {
72
- it('derives a ws:// URL from an http:// apiUrl when websocketUrl is unset', async () => {
73
- installMockWsServer('ws://api.example/sessions/stream?session_token=tok');
72
+ it('derives the unchanged ws:// endpoint and sends the token as a protocol', async () => {
73
+ const ws = installMockWsServer('ws://api.example/sessions/stream');
74
74
 
75
75
  const client = new CsrClient('http://api.example', 'secret', undefined);
76
- await expect(client.openTransport('tok')).resolves.toBeDefined();
76
+ await expect(client.openTransport('tok-1')).resolves.toBeDefined();
77
+
78
+ expect(ws.connections[0].url).toBe('ws://api.example/sessions/stream');
79
+ expect(ws.protocolOffers).toEqual([['recording.v1', 'auth.tok-1']]);
77
80
  });
78
81
 
79
82
  it('derives a wss:// URL from an https:// apiUrl', async () => {
80
- installMockWsServer('wss://api.example/sessions/stream?session_token=tok');
83
+ installMockWsServer('wss://api.example/sessions/stream');
81
84
 
82
85
  const client = new CsrClient('https://api.example', 'secret', undefined);
83
86
  await expect(client.openTransport('tok')).resolves.toBeDefined();
84
87
  });
85
88
 
86
- it('uses websocketUrl verbatim when provided (split-host prod layout)', async () => {
87
- installMockWsServer('wss://recording-ws.confidence.dev/sessions/stream?session_token=tok');
89
+ it('preserves unrelated query parameters in a configured websocketUrl', async () => {
90
+ const ws = installMockWsServer('wss://recording-ws.confidence.dev/sessions/stream?region=eu');
88
91
 
89
92
  const client = new CsrClient(
90
93
  'https://recording.confidence.dev',
91
94
  'secret',
92
95
  undefined,
93
- 'wss://recording-ws.confidence.dev/sessions/stream',
96
+ 'wss://recording-ws.confidence.dev/sessions/stream?region=eu',
94
97
  );
95
98
  await expect(client.openTransport('tok')).resolves.toBeDefined();
99
+
100
+ expect(ws.connections[0].url).toBe('wss://recording-ws.confidence.dev/sessions/stream?region=eu');
96
101
  });
97
102
 
98
- it('redacts session_token from debug log', async () => {
99
- installMockWsServer('wss://api/sessions/stream?session_token=secret-tok');
103
+ it('keeps the credential out of the URL and debug log', async () => {
104
+ const ws = installMockWsServer('wss://api/sessions/stream');
100
105
 
101
106
  const logs: string[] = [];
102
107
  const client = new CsrClient('https://api', 'secret', undefined, undefined, msg => logs.push(msg));
103
- await client.openTransport('secret-tok');
108
+ await client.openTransport('sensitive-token');
104
109
 
105
110
  expect(logs).toHaveLength(1);
106
- expect(logs[0]).toContain('session_token=[REDACTED]');
107
- expect(logs[0]).not.toContain('secret-tok');
111
+ expect(logs[0]).toBe('WebSocket connect wss://api/sessions/stream');
112
+ for (const value of [ws.connections[0].url, ...logs]) {
113
+ expect(value).not.toContain('sensitive-token');
114
+ }
108
115
  });
109
116
 
110
- it('URL-encodes the session token', async () => {
111
- installMockWsServer('wss://api/sessions/stream?session_token=tok%2Fwith%3Dspecials');
112
-
113
- const client = new CsrClient('https://api', 'secret', undefined);
114
- await expect(client.openTransport('tok/with=specials')).resolves.toBeDefined();
115
- });
117
+ it.each(['wss://api/sessions/stream?session_token=legacy', 'wss://api/sessions/stream?region=eu&session_token='])(
118
+ 'rejects a configured legacy credential before logging or connecting: %s',
119
+ async websocketUrl => {
120
+ const ws = installMockWsServer(websocketUrl);
121
+ const logs: string[] = [];
122
+ const client = new CsrClient('https://api', 'secret', undefined, websocketUrl, msg => logs.push(msg));
123
+
124
+ await expect(client.openTransport('sensitive-token')).rejects.toThrowError(
125
+ 'WebSocket URL must not include a session token',
126
+ );
127
+ expect(logs).toEqual([]);
128
+ expect(ws.connections).toEqual([]);
129
+ },
130
+ );
116
131
  });
@@ -1,6 +1,7 @@
1
1
  import type { ClientContext } from '../client-context';
2
2
  import type { Client, Transport } from '../types';
3
3
  import { WebSocketTransport } from './web-socket-transport';
4
+ import { recordingProtocols } from './websocket-auth';
4
5
 
5
6
  /**
6
7
  * Single Client implementation that talks to the recording backend's REST + WS protocol.
@@ -45,10 +46,18 @@ export class CsrClient implements Client {
45
46
 
46
47
  async openTransport(sessionToken: string): Promise<Transport> {
47
48
  const wsBase = this.websocketUrl ?? `${this.toWsScheme(this.trimSlash(this.apiUrl))}/sessions/stream`;
48
- const sep = wsBase.includes('?') ? '&' : '?';
49
- const url = `${wsBase}${sep}session_token=${encodeURIComponent(sessionToken)}`;
50
- this.log(`WebSocket connect ${url.replace(/session_token=[^&]*/, 'session_token=[REDACTED]')}`);
51
- const transport = new WebSocketTransport(url);
49
+ let parsedUrl: URL;
50
+ try {
51
+ parsedUrl = new URL(wsBase);
52
+ } catch (_error) {
53
+ throw new Error('Invalid WebSocket URL');
54
+ }
55
+ if (parsedUrl.searchParams.has('session_token')) {
56
+ throw new Error('WebSocket URL must not include a session token');
57
+ }
58
+ const protocols = recordingProtocols(sessionToken);
59
+ this.log(`WebSocket connect ${wsBase}`);
60
+ const transport = new WebSocketTransport(wsBase, protocols);
52
61
  await transport.ready();
53
62
  return transport;
54
63
  }
@@ -1,12 +1,24 @@
1
- import { describe, expect, it, vi } from 'vitest';
1
+ import { describe, expect, it, onTestFinished, vi } from 'vitest';
2
2
  import { installMockWsServer } from '../../test-utils';
3
3
  import { WebSocketTransport } from './web-socket-transport';
4
4
 
5
- const URL = 'ws://localhost:1234/sessions/stream?session_token=abc';
5
+ const URL = 'ws://localhost:1234/sessions/stream';
6
+ const PROTOCOLS = ['recording.v1', 'auth.sensitive-token'];
6
7
 
7
8
  describe('WebSocketTransport', () => {
8
9
  const setup = () => installMockWsServer(URL);
9
10
 
11
+ it('copies and supplies the protocols on the first connection', async () => {
12
+ const ws = setup();
13
+ const protocols = [...PROTOCOLS];
14
+ const t = new WebSocketTransport(URL, protocols);
15
+ protocols[0] = 'changed-after-construction';
16
+
17
+ await expect(t.ready()).resolves.toBeUndefined();
18
+
19
+ expect(ws.protocolOffers).toEqual([PROTOCOLS]);
20
+ });
21
+
10
22
  it('resolves ready() once the server accepts the connection', async () => {
11
23
  setup();
12
24
  const t = new WebSocketTransport(URL);
@@ -48,8 +60,8 @@ describe('WebSocketTransport', () => {
48
60
  });
49
61
 
50
62
  it('reconnects on a graceful drain (code 1000) and emits state changes', async () => {
51
- const { waitForConnection } = setup();
52
- const t = new WebSocketTransport(URL);
63
+ const { protocolOffers, waitForConnection } = setup();
64
+ const t = new WebSocketTransport(URL, PROTOCOLS);
53
65
  const states: boolean[] = [];
54
66
  t.onStateChange(({ connected }) => states.push(connected));
55
67
  await t.ready();
@@ -61,6 +73,61 @@ describe('WebSocketTransport', () => {
61
73
  // First open → no state event (welcome implies connected).
62
74
  // Drain → state(false). Reconnect open → state(true).
63
75
  await vi.waitFor(() => expect(states).toEqual([false, true]));
76
+ expect(protocolOffers).toEqual([PROTOCOLS, PROTOCOLS]);
77
+ });
78
+
79
+ it.each([
80
+ ['no protocol', ''],
81
+ ['the authentication protocol', PROTOCOLS[1]],
82
+ ])('rejects readiness and keeps buffered frames when the server selects %s', async (_case, selectedProtocol) => {
83
+ const { messages } = installMockWsServer(URL, { selectProtocol: () => selectedProtocol });
84
+ const t = new WebSocketTransport(URL, PROTOCOLS);
85
+ t.send({ tabId: 'tab-1', eventCounter: 0, data: 'must-not-send' });
86
+
87
+ const error = await t.ready().catch((caught: unknown) => caught);
88
+
89
+ expect(String(error)).toContain('initial-failed');
90
+ expect(String(error)).not.toContain(PROTOCOLS[1]);
91
+ expect(messages).toEqual([]);
92
+ });
93
+
94
+ it('uses the protocols on reconnect and never flushes frames after wrong selection', async () => {
95
+ const ws = installMockWsServer(URL, {
96
+ selectProtocol: (_protocols, connectionIndex) => (connectionIndex === 0 ? PROTOCOLS[0] : PROTOCOLS[1]),
97
+ });
98
+ const t = new WebSocketTransport(URL, PROTOCOLS);
99
+ const closeReasons: string[] = [];
100
+ t.onClose(({ reason }) => closeReasons.push(reason));
101
+ t.onStateChange(({ connected }) => {
102
+ if (!connected) {
103
+ t.send({ tabId: 'tab-1', eventCounter: 0, data: 'must-not-send' });
104
+ }
105
+ });
106
+ await t.ready();
107
+
108
+ const first = await ws.waitForConnection();
109
+ first.close({ code: 1000, reason: 'drain', wasClean: true });
110
+
111
+ await vi.waitFor(() => expect(closeReasons).toEqual(['reconnect-failed']));
112
+ expect(ws.protocolOffers).toEqual([PROTOCOLS, PROTOCOLS]);
113
+ expect(ws.messages).toEqual([]);
114
+ });
115
+
116
+ it('hides protocol values from WebSocket constructor failures', async () => {
117
+ const protocolFromBrowserError = PROTOCOLS[1];
118
+ class ThrowingWebSocket {
119
+ constructor() {
120
+ throw new Error(`synthetic constructor failure for ${protocolFromBrowserError}`);
121
+ }
122
+ }
123
+ onTestFinished(() => vi.unstubAllGlobals());
124
+ vi.stubGlobal('WebSocket', ThrowingWebSocket);
125
+
126
+ const t = new WebSocketTransport(URL, PROTOCOLS);
127
+ const error = await t.ready().catch((caught: unknown) => caught);
128
+
129
+ expect(String(error)).toContain('initial-failed');
130
+ expect(String(error)).not.toContain(protocolFromBrowserError);
64
131
  });
65
132
 
66
133
  it('fires onClose with reason on abrupt close after open', async () => {
@@ -19,8 +19,10 @@ export class WebSocketTransport implements Transport {
19
19
  /** Frames buffered while a (re)connect is in progress. */
20
20
  private pending: Frame[] = [];
21
21
  private readyPromise: Promise<void>;
22
+ private readonly protocols: string[];
22
23
 
23
- constructor(private readonly url: string) {
24
+ constructor(private readonly url: string, protocols: string[] = []) {
25
+ this.protocols = [...protocols];
24
26
  this.readyPromise = new Promise<void>((resolve, reject) => {
25
27
  this.connect(false, resolve, reject);
26
28
  });
@@ -56,11 +58,23 @@ export class WebSocketTransport implements Transport {
56
58
  }
57
59
 
58
60
  private connect(isReconnect: boolean, onReady?: () => void, onReadyFail?: (err: Error) => void): void {
59
- const ws = new WebSocket(this.url);
61
+ let ws: WebSocket;
62
+ try {
63
+ ws = new WebSocket(this.url, [...this.protocols]);
64
+ } catch (_error) {
65
+ this.failConnection(isReconnect, onReadyFail);
66
+ return;
67
+ }
60
68
  this.ws = ws;
61
69
  let opened = false;
62
70
 
63
71
  ws.onopen = () => {
72
+ const expectedProtocol = this.protocols[0];
73
+ if (expectedProtocol !== undefined && ws.protocol !== expectedProtocol) {
74
+ this.failConnection(isReconnect, onReadyFail);
75
+ ws.close(1000, 'protocol-mismatch');
76
+ return;
77
+ }
64
78
  opened = true;
65
79
  onReady?.();
66
80
  // Emit state on every successful open EXCEPT the very first one (welcome already
@@ -75,20 +89,11 @@ export class WebSocketTransport implements Transport {
75
89
  };
76
90
 
77
91
  ws.onclose = event => {
78
- if (this.intentionallyClosed) return;
92
+ if (this.intentionallyClosed || this.dead) return;
79
93
  if (!opened) {
80
- // Server rejected the connection before it opened (e.g. unknown session).
81
- const stage = isReconnect ? 'reconnect' : 'initial';
82
- const reason = `${stage}-failed code=${event.code}`;
83
- if (onReadyFail) {
84
- // First attempt — surface the failure to whoever is awaiting `ready()` so they
85
- // can decide whether to recover (e.g. fall back to a fresh initSession).
86
- onReadyFail(new Error(reason));
87
- this.dead = true;
88
- } else {
89
- // Reconnect failed; the consumer is past `ready()` and only learns about it via onClose.
90
- this.die(reason);
91
- }
94
+ // Browsers do not expose the HTTP status for a failed WebSocket handshake. Keep
95
+ // this failure generic and do not infer an authentication result from close 1006.
96
+ this.failConnection(isReconnect, onReadyFail);
92
97
  return;
93
98
  }
94
99
  // The WS opened and is now closing. Distinguish graceful drain (retry) from app
@@ -106,6 +111,16 @@ export class WebSocketTransport implements Transport {
106
111
  };
107
112
  }
108
113
 
114
+ private failConnection(isReconnect: boolean, onReadyFail?: (err: Error) => void): void {
115
+ const reason = isReconnect ? 'reconnect-failed' : 'initial-failed';
116
+ if (onReadyFail) {
117
+ this.dead = true;
118
+ onReadyFail(new Error(reason));
119
+ } else {
120
+ this.die(reason);
121
+ }
122
+ }
123
+
109
124
  private die(reason: string): void {
110
125
  this.dead = true;
111
126
  this.onCloseCb?.({ reason });
@@ -0,0 +1,29 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { recordingProtocols } from './websocket-auth';
3
+
4
+ describe('recordingProtocols', () => {
5
+ it('offers the issued token unchanged, including dots, hyphens, and underscores', () => {
6
+ expect(recordingProtocols('cGF5bG9hZA.c2lnbmF0dXJl_-')).toEqual(['recording.v1', 'auth.cGF5bG9hZA.c2lnbmF0dXJl_-']);
7
+ });
8
+
9
+ it('allows a token at the 4096-character limit', () => {
10
+ expect(recordingProtocols('a'.repeat(4096))[1]).toHaveLength('auth.'.length + 4096);
11
+ });
12
+
13
+ it('rejects a token longer than 4096 characters without exposing it', () => {
14
+ const token = `sensitive-${'a'.repeat(4097)}`;
15
+ expect(() => recordingProtocols(token)).toThrowError('Session token is too long for WebSocket authentication');
16
+ try {
17
+ recordingProtocols(token);
18
+ } catch (error) {
19
+ expect(String(error)).not.toContain(token);
20
+ }
21
+ });
22
+
23
+ it.each(['', 'sensitive/token', 'sensitive=token', 'sensitive token', 'tøken-☃', 'sensitive\n'])(
24
+ 'rejects unsupported token characters without exposing the token',
25
+ token => {
26
+ expect(() => recordingProtocols(token)).toThrowError('Invalid session token for WebSocket authentication');
27
+ },
28
+ );
29
+ });
@@ -0,0 +1,15 @@
1
+ export const RECORDING_PROTOCOL = 'recording.v1';
2
+
3
+ const AUTH_PROTOCOL_PREFIX = 'auth.';
4
+ const MAX_TOKEN_LENGTH = 4096;
5
+
6
+ export function recordingProtocols(sessionToken: string): string[] {
7
+ if (sessionToken.length > MAX_TOKEN_LENGTH) {
8
+ throw new Error('Session token is too long for WebSocket authentication');
9
+ }
10
+ // Issued tokens are unpadded Base64URL parts joined by a dot; dev tokens are UUIDs.
11
+ if (!sessionToken || /[^A-Za-z0-9._-]/.test(sessionToken)) {
12
+ throw new Error('Invalid session token for WebSocket authentication');
13
+ }
14
+ return [RECORDING_PROTOCOL, `${AUTH_PROTOCOL_PREFIX}${sessionToken}`];
15
+ }