@kuralle-syrinx/server-websocket 3.1.0 → 4.0.0

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kuralle-syrinx/server-websocket",
3
- "version": "3.1.0",
3
+ "version": "4.0.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -15,8 +15,8 @@
15
15
  "dependencies": {
16
16
  "@evan/opus": "1.0.3",
17
17
  "ws": "^8.18.0",
18
- "@kuralle-syrinx/core": "3.1.0",
19
- "@kuralle-syrinx/ws": "3.1.0"
18
+ "@kuralle-syrinx/core": "4.0.0",
19
+ "@kuralle-syrinx/ws": "4.0.0"
20
20
  },
21
21
  "devDependencies": {
22
22
  "@types/node": "^22.0.0",
@@ -79,6 +79,29 @@ describe("WT-08 admission control and upgrade routing", () => {
79
79
  await server.close();
80
80
  });
81
81
 
82
+ it("rejects an unauthorized upgrade with 4401 and admits an authorized one", async () => {
83
+ const server = registerServer(await createVoiceWebSocketServer({
84
+ port: 0,
85
+ authorize: (request) => new URL(request.url ?? "/", "http://x").searchParams.get("token") === "secret",
86
+ createSession: () => new VoiceAgentSession({ plugins: {} }),
87
+ }));
88
+ const address = server.address();
89
+ if (!address || typeof address === "string") throw new Error("Expected TCP address");
90
+
91
+ const rejected = registerSocket(new WebSocket(`${websocketUrl(address.port)}?token=wrong`));
92
+ await new Promise<void>((resolve, reject) => {
93
+ rejected.once("open", resolve);
94
+ rejected.once("error", reject);
95
+ });
96
+ expect(await waitForClose(rejected)).toBe(4401);
97
+
98
+ const authorized = await openBrowserSocketReady(`${websocketUrl(address.port)}?token=secret`);
99
+ expect(authorized.readyState).toBe(WebSocket.OPEN);
100
+
101
+ authorized.close();
102
+ await server.close();
103
+ });
104
+
82
105
  it("destroys sockets on unmatched upgrade paths when this router is the sole upgrade handler", async () => {
83
106
  const httpServer = registerHttpServer(createServer());
84
107
  const server = registerServer(await createVoiceWebSocketServer({
@@ -168,7 +168,7 @@ describe("WT-03 Browser outbound pacing", () => {
168
168
  expect(c.type).toBe("tts_chunk");
169
169
  expect(c.turnId).toBe("test-turn");
170
170
  expect(c.sequence).toBeGreaterThan(0);
171
- expect(c.sampleRateHz).toBe(16000);
171
+ expect(c.sampleRateHz).toBe(48000); // opus frames carry the 48 kHz codec rate
172
172
  expect(c.encoding).toBe("opus");
173
173
  expect(c.channels).toBe(1);
174
174
  expect(c.byteLength).toBeGreaterThan(0);
@@ -159,6 +159,32 @@ describe("Twilio edge ingress", () => {
159
159
  expect(socket.disposed).toBe(true);
160
160
  });
161
161
 
162
+ it("actually closes the session on hangup (no provider-socket leak)", async () => {
163
+ // Regression for the R1 leak: cleanup released without decrementing
164
+ // connectionCount, so release early-returned and session.close() never ran —
165
+ // Deepgram/TTS sockets leaked until DO eviction on every phone call.
166
+ const socket = new FakeSocket();
167
+ let closed = false;
168
+ const bus = new PipelineBusImpl();
169
+ const session = {
170
+ bus,
171
+ async start() { void bus.start(); },
172
+ async close() { closed = true; bus.stop(); },
173
+ on() {}, off() {}, requestClientInterrupt() {},
174
+ } as unknown as VoiceAgentSession;
175
+
176
+ await runTwilioEdgeWebSocketConnection(
177
+ socket,
178
+ new Request("https://edge.test/twilio?sessionId=tw-close"),
179
+ { sessionStore: new InMemorySessionStore(), createSession: () => session, keepAliveIntervalMs: 0 },
180
+ );
181
+ socket.emit(JSON.stringify({ event: "start", streamSid: "MZ9", start: { streamSid: "MZ9", callSid: "CA9" } }));
182
+ socket.emit(JSON.stringify({ event: "stop", streamSid: "MZ9" }));
183
+ await new Promise((r) => setTimeout(r, 10));
184
+
185
+ expect(closed).toBe(true);
186
+ });
187
+
162
188
  it("rotates the uplink contextId after each completed turn", async () => {
163
189
  const received: UserAudioReceivedPacket[] = [];
164
190
  const { socket, session } = await startConnection(received);
@@ -30,7 +30,7 @@ import {
30
30
  createWorkersInboundSocket,
31
31
  type WorkersDurableObjectWebSocketContext,
32
32
  } from "@kuralle-syrinx/ws/workers";
33
- import type { SessionStore } from "./session-store.js";
33
+ import type { SessionStore, ManagedSession } from "./session-store.js";
34
34
 
35
35
  const TWILIO_SAMPLE_RATE_HZ = 8_000;
36
36
  const DEFAULT_RESUME_WINDOW_MS = 15_000;
@@ -82,6 +82,8 @@ export async function runTwilioEdgeWebSocketConnection(
82
82
  const downlinkResamplers = new Map<string, StreamingPcm16Resampler>();
83
83
  const disposers: Array<() => void> = [];
84
84
  let session: VoiceAgentSession | null = null;
85
+ let managed: ManagedSession | null = null;
86
+ let pendingLease: Promise<{ managed: ManagedSession }> | null = null;
85
87
  let sessionId = "";
86
88
  let streamSid = "";
87
89
  let contextId = "";
@@ -100,9 +102,26 @@ export async function runTwilioEdgeWebSocketConnection(
100
102
  if (closed) return;
101
103
  closed = true;
102
104
  scheduler.cancel(KEEP_ALIVE_KEY);
105
+ scheduler.cancel("voice.edge.twilio.startup");
103
106
  for (const dispose of disposers.splice(0)) dispose();
104
- if (session && sessionId) {
107
+ if (managed && sessionId) {
108
+ // Decrement the connection count BEFORE releasing (R1) — otherwise release
109
+ // early-returns while connectionCount > 0 and session.close() never runs, so
110
+ // Deepgram/TTS provider sockets + the reasoner leak until DO eviction. A
111
+ // caller-hangup (`stopped`) releases immediately (retain 0); a transient drop
112
+ // keeps the session warm for the resume window.
113
+ managed.connectionCount = Math.max(0, managed.connectionCount - 1);
105
114
  void options.sessionStore.release(sessionId, stopped ? 0 : resumeWindowMs);
115
+ } else if (pendingLease && sessionId) {
116
+ // Closed before the lease was adopted (e.g. startup-timeout race): tear the
117
+ // in-flight session down when it resolves so it is never orphaned in the store.
118
+ const id = sessionId;
119
+ void pendingLease
120
+ .then((leased) => {
121
+ leased.managed.connectionCount = Math.max(0, leased.managed.connectionCount - 1);
122
+ return options.sessionStore.release(id, 0);
123
+ })
124
+ .catch(() => undefined);
106
125
  }
107
126
  };
108
127
 
@@ -141,26 +160,29 @@ export async function runTwilioEdgeWebSocketConnection(
141
160
  reject(new Error("twilio session startup timeout"));
142
161
  });
143
162
  });
144
- const leased = await Promise.race([
145
- options.sessionStore.lease(sessionId, async () => {
146
- const sess = await options.createSession(request);
147
- await sess.start();
148
- return {
149
- id: sessionId,
150
- session: sess,
151
- currentContextId: "",
152
- contextSampleRates: new Map(),
153
- inputSequence: { lastSequence: null },
154
- turnMetricsTurns: new Map(),
155
- closeTimer: null,
156
- connectionCount: 1,
157
- };
158
- }),
159
- startupTimer,
160
- ]);
163
+ pendingLease = options.sessionStore.lease(sessionId, async () => {
164
+ const sess = await options.createSession(request);
165
+ await sess.start();
166
+ return {
167
+ id: sessionId,
168
+ session: sess,
169
+ currentContextId: "",
170
+ contextSampleRates: new Map(),
171
+ inputSequence: { lastSequence: null },
172
+ turnMetricsTurns: new Map(),
173
+ closeTimer: null,
174
+ connectionCount: 1,
175
+ };
176
+ });
177
+ const leased = await Promise.race([pendingLease, startupTimer]);
178
+ pendingLease = null;
161
179
  scheduler.cancel("voice.edge.twilio.startup");
162
- session = leased.managed.session;
180
+ managed = leased.managed;
181
+ session = managed.session;
163
182
  if (closed) {
183
+ // The socket closed while the session was starting up — tear the just-started
184
+ // session down instead of orphaning it (decrement so release actually closes it).
185
+ managed.connectionCount = Math.max(0, managed.connectionCount - 1);
164
186
  await options.sessionStore.release(sessionId, 0);
165
187
  return;
166
188
  }
package/src/edge.test.ts CHANGED
@@ -4,6 +4,7 @@ import { describe, expect, it } from "vitest";
4
4
  import {
5
5
  PipelineBusImpl,
6
6
  Route,
7
+ VoiceAgentSession as VoiceAgentSessionImpl,
7
8
  encodeSyrinxAudioEnvelope,
8
9
  type Scheduler,
9
10
  type ScheduledCallback,
@@ -381,6 +382,84 @@ describe("edge barge-in downlink", () => {
381
382
  });
382
383
  });
383
384
 
385
+ describe("edge tool-call cues (G3/WBS-3)", () => {
386
+ it("surfaces the typed lifecycle as tool_call_* wire messages: started → delayed → complete", async () => {
387
+ const socket = new FakeSocket();
388
+ const scheduler = new ManualScheduler();
389
+ const session = new VoiceAgentSessionImpl({ plugins: {}, delayCueAfterMs: 20 });
390
+ await runVoiceEdgeWebSocketConnection(socket, new Request("https://edge.test/ws?sessionId=s1"), {
391
+ sessionStore: new InMemorySessionStore(),
392
+ scheduler,
393
+ createSession: () => session,
394
+ });
395
+ waitForReady(socket);
396
+
397
+ session.bus.push(Route.Main, {
398
+ kind: "llm.tool_call",
399
+ contextId: "turn-1",
400
+ timestampMs: Date.now(),
401
+ toolId: "t1",
402
+ toolName: "consult_knowledge",
403
+ toolArgs: { query: "fees" },
404
+ });
405
+ await new Promise((resolve) => setTimeout(resolve, 50));
406
+ session.bus.push(Route.Main, {
407
+ kind: "llm.tool_result",
408
+ contextId: "turn-1",
409
+ timestampMs: Date.now(),
410
+ toolId: "t1",
411
+ toolName: "consult_knowledge",
412
+ result: "answer",
413
+ });
414
+ await new Promise((resolve) => setTimeout(resolve, 20));
415
+
416
+ expect(socket.json()).toContainEqual({
417
+ type: "tool_call_started", turnId: "turn-1", toolId: "t1", toolName: "consult_knowledge",
418
+ });
419
+ expect(socket.json()).toContainEqual({
420
+ type: "tool_call_delayed", turnId: "turn-1", toolId: "t1", toolName: "consult_knowledge", afterMs: 20,
421
+ });
422
+ expect(socket.json()).toContainEqual({
423
+ type: "tool_call_complete", turnId: "turn-1", toolId: "t1", toolName: "consult_knowledge",
424
+ });
425
+ });
426
+
427
+ it("surfaces tool_call_failed when a barge-in aborts the pending delegate (R5)", async () => {
428
+ const socket = new FakeSocket();
429
+ const scheduler = new ManualScheduler();
430
+ const session = new VoiceAgentSessionImpl({ plugins: {}, delayCueAfterMs: 0 });
431
+ await runVoiceEdgeWebSocketConnection(socket, new Request("https://edge.test/ws?sessionId=s1"), {
432
+ sessionStore: new InMemorySessionStore(),
433
+ scheduler,
434
+ createSession: () => session,
435
+ });
436
+ waitForReady(socket);
437
+
438
+ session.bus.push(Route.Main, {
439
+ kind: "llm.tool_call",
440
+ contextId: "turn-1",
441
+ timestampMs: Date.now(),
442
+ toolId: "t1",
443
+ toolName: "consult_knowledge",
444
+ toolArgs: {},
445
+ });
446
+ await new Promise((resolve) => setTimeout(resolve, 10));
447
+ session.bus.push(Route.Critical, {
448
+ kind: "interrupt.detected",
449
+ contextId: "turn-1",
450
+ timestampMs: Date.now(),
451
+ source: "vad",
452
+ });
453
+ await new Promise((resolve) => setTimeout(resolve, 10));
454
+
455
+ expect(socket.json()).toContainEqual({
456
+ type: "tool_call_failed", turnId: "turn-1", toolId: "t1", toolName: "consult_knowledge",
457
+ });
458
+ // Barge-in downlink unaffected — the flush messages still went out.
459
+ expect(socket.json().some((m) => m.type === "audio_clear")).toBe(true);
460
+ });
461
+ });
462
+
384
463
  describe("edge client playout progress", () => {
385
464
  interface ProgressPacket {
386
465
  contextId: string;
package/src/edge.ts CHANGED
@@ -367,6 +367,19 @@ function wireEdgeSessionEvents(
367
367
  onSession("agent_text_delta", (event) => {
368
368
  sendJson(socket, { type: "agent_chunk", turnId: event.turnId, text: event.delta });
369
369
  });
370
+ // G3 (RFC bimodel-delegate-seam): typed preamble/filler lifecycle. The standard
371
+ // "thinking" wire cue — clients key earcons/indicators on these instead of an
372
+ // app-invented message. started fires before the reasoner runs; delayed is the
373
+ // time-triggered "still working"; complete/failed end the wait (incl. barge-in).
374
+ onSession("tool_call_cue", (event) => {
375
+ sendJson(socket, {
376
+ type: `tool_call_${event.phase}`,
377
+ turnId: event.turnId,
378
+ toolId: event.toolId,
379
+ toolName: event.toolName,
380
+ ...(event.afterMs !== undefined ? { afterMs: event.afterMs } : {}),
381
+ });
382
+ });
370
383
  onSession("agent_finished", (event) => {
371
384
  sendJson(socket, { type: "agent_end", turnId: event.turnId });
372
385
  });
@@ -0,0 +1,58 @@
1
+ // SPDX-License-Identifier: MIT
2
+
3
+ import { describe, expect, it } from "vitest";
4
+ import { encodeSyrinxAudioEnvelope } from "@kuralle-syrinx/core";
5
+ import { decodeInboundBinaryAudio, resampleAudioBytes, type OpusIngressDecoder } from "./inbound-audio.js";
6
+
7
+ // Regression for the opus mic-uplink double-resample P0: opus ingress is decoded
8
+ // AND resampled to the engine rate inside decodeInboundBinaryAudio, so it must
9
+ // report the ENGINE rate — not the 48 kHz header rate — or the caller resamples
10
+ // the already-16 kHz audio a second time and delivers ~1/3 the samples (3× fast).
11
+ describe("decodeInboundBinaryAudio opus rate", () => {
12
+ const ENGINE_RATE = 16000;
13
+ const WIRE_RATE = 48000;
14
+
15
+ // A fake opus decoder returning PCM at the opus native rate (48 kHz), as the real
16
+ // decoder does. decodeInboundBinaryAudio then resamples it ONCE to the engine rate.
17
+ // 20 ms at 48 kHz mono PCM16 = 960 samples = 1920 bytes → 320 samples = 640 bytes @ 16 kHz.
18
+ const opusNativePcm = new Uint8Array(1920);
19
+ const fakeOpusDecoder: OpusIngressDecoder = () => opusNativePcm;
20
+
21
+ function opusEnvelope(): Uint8Array {
22
+ // The opus payload bytes are opaque to the decoder mock.
23
+ const opusPayload = new Uint8Array([1, 2, 3, 4]);
24
+ return encodeSyrinxAudioEnvelope(
25
+ {
26
+ type: "audio",
27
+ contextId: "turn-1",
28
+ sampleRateHz: WIRE_RATE,
29
+ sequence: 1,
30
+ encoding: "opus",
31
+ channels: 1,
32
+ byteLength: opusPayload.byteLength,
33
+ durationMs: 20,
34
+ },
35
+ opusPayload,
36
+ );
37
+ }
38
+
39
+ it("reports the engine rate, not the header rate, so the caller does not resample again", () => {
40
+ const decoded = decodeInboundBinaryAudio(
41
+ opusEnvelope(),
42
+ ENGINE_RATE,
43
+ false,
44
+ ENGINE_RATE,
45
+ new Map(),
46
+ fakeOpusDecoder,
47
+ );
48
+
49
+ // Decoded once from 48 kHz → 16 kHz inside decodeInboundBinaryAudio: 640 bytes.
50
+ expect(decoded.sampleRateHz).toBe(ENGINE_RATE);
51
+ expect(decoded.audio.byteLength).toBe(640);
52
+
53
+ // The caller's final resample (reported rate → engine rate) is now an identity:
54
+ // 320 samples in, 320 samples out (not ~107 as under the old 48 kHz label → 3× fast).
55
+ const final = resampleAudioBytes(decoded.audio, decoded.sampleRateHz, ENGINE_RATE, new Map());
56
+ expect(final.byteLength).toBe(640);
57
+ });
58
+ });
@@ -42,12 +42,19 @@ export function decodeInboundBinaryAudio(
42
42
  }
43
43
  const { header, audio } = decodeSyrinxAudioEnvelope(data);
44
44
  const sampleRateHz = requirePositiveIntegerFromHeader(header.sampleRateHz) ?? defaultSampleRateHz;
45
- const wireAudio = header.encoding === "opus"
45
+ const isOpus = header.encoding === "opus";
46
+ const wireAudio = isOpus
46
47
  ? decodeOpusIngressMessage(audio, sampleRateHz, decodeOpusIngress, engineInputSampleRateHz, streamingResamplers)
47
48
  : audio;
49
+ // Opus ingress is decoded AND resampled to the engine rate inside
50
+ // decodeOpusIngressMessage, so the returned PCM is already at engineInputSampleRateHz.
51
+ // Report that as its rate — reporting the 48 kHz *header* rate made the caller
52
+ // resample the already-16 kHz audio a second time, delivering ~1/3 the samples
53
+ // (3× sped-up audio → STT garbage). PCM carries its true header source rate.
54
+ const effectiveSampleRateHz = isOpus ? engineInputSampleRateHz : sampleRateHz;
48
55
  return {
49
56
  contextId: typeof header.contextId === "string" && header.contextId.length > 0 ? header.contextId : undefined,
50
- sampleRateHz,
57
+ sampleRateHz: effectiveSampleRateHz,
51
58
  sequence: header.sequence,
52
59
  audio: wireAudio,
53
60
  };
package/src/index.test.ts CHANGED
@@ -601,7 +601,7 @@ describe("createVoiceWebSocketServer", () => {
601
601
  type: "tts_chunk",
602
602
  turnId: "turn-2",
603
603
  sequence: 1,
604
- sampleRateHz: 16000,
604
+ sampleRateHz: 48000, // opus frames are labeled at the 48 kHz codec rate
605
605
  encoding: "opus",
606
606
  channels: 1,
607
607
  });
@@ -610,7 +610,7 @@ describe("createVoiceWebSocketServer", () => {
610
610
  type: "audio",
611
611
  contextId: "turn-2",
612
612
  sequence: 1,
613
- sampleRateHz: 16000,
613
+ sampleRateHz: 48000,
614
614
  encoding: "opus",
615
615
  });
616
616
  expect(envelope.audio.byteLength).toBeGreaterThan(0);
@@ -1697,11 +1697,14 @@ describe("createVoiceWebSocketServer", () => {
1697
1697
  });
1698
1698
 
1699
1699
  const envelope = decodeTestBinaryAudioEnvelope(await binaryMessage);
1700
+ // Opus frames are encoded at the codec rate (48 kHz) and MUST be labeled as such,
1701
+ // so the client decodes then downsamples 48→16 kHz. Labeling them 16 kHz made the
1702
+ // client skip the downsample and play assistant audio ~3× slow.
1700
1703
  expect(envelope.header).toMatchObject({
1701
1704
  type: "audio",
1702
1705
  contextId: "turn-tts",
1703
1706
  sequence: 1,
1704
- sampleRateHz: 16000,
1707
+ sampleRateHz: 48000,
1705
1708
  encoding: "opus",
1706
1709
  channels: 1,
1707
1710
  });
package/src/index.ts CHANGED
@@ -58,6 +58,7 @@ export * from "./twilio.js";
58
58
  export * from "./telnyx.js";
59
59
  export * from "./smartpbx.js";
60
60
  export * from "./session-store.js";
61
+ export { validateTwilioSignature } from "./twilio-auth.js";
61
62
  export { installGracefulShutdown, type GracefulClosable } from "./websocket-lifecycle.js";
62
63
 
63
64
  export interface VoiceWebSocketServerOptions {
@@ -100,6 +101,13 @@ export interface VoiceWebSocketServerOptions {
100
101
  readonly maxConcurrentSessions?: number;
101
102
  readonly maxConcurrentSessionsScope?: "path" | "server";
102
103
  readonly onTransportMetric?: (name: string) => void;
104
+ /**
105
+ * Authorize each inbound `/ws` upgrade (shared secret / bearer / signature).
106
+ * Return false or throw to reject with 4401. Unset = OPEN — voice endpoints are
107
+ * unauthenticated by default and each connection incurs provider cost and can
108
+ * attach to a live session; set this before exposing the endpoint publicly.
109
+ */
110
+ readonly authorize?: (request: import("node:http").IncomingMessage) => boolean | Promise<boolean>;
103
111
  }
104
112
 
105
113
  export type { GracefulCloseOptions } from "./transport-host.js";
@@ -127,6 +135,12 @@ type ClientMessage =
127
135
  readonly sequence?: number;
128
136
  }
129
137
  | { readonly type: "codec_capability"; readonly downlinkEncoding: "pcm_s16le" | "opus" }
138
+ | {
139
+ readonly type: "playout_progress";
140
+ readonly contextId?: string;
141
+ readonly playedOutMs: number;
142
+ readonly complete?: boolean;
143
+ }
130
144
  | { readonly type: "ping" };
131
145
 
132
146
  interface BrowserConnectionState {
@@ -159,6 +173,7 @@ export async function createVoiceWebSocketServer(
159
173
  maxConcurrentSessions: positiveInteger(options.maxConcurrentSessions) ?? undefined,
160
174
  maxConcurrentSessionsScope: options.maxConcurrentSessionsScope,
161
175
  onAdmissionRejected: () => options.onTransportMetric?.(TRANSPORT_ADMISSION_REJECTED_METRIC),
176
+ authorize: options.authorize,
162
177
  });
163
178
  const wsServer = routedWebSocket.wsServer;
164
179
  const sessionStore = options.sessionStore ?? new InMemorySessionStore();
@@ -462,6 +477,16 @@ function wireBrowserSessionEvents(
462
477
  onSession("agent_tool_result", (event) => {
463
478
  sendJson(socket, { type: "agent_tool_result", turnId: event.turnId, id: event.id, result: event.result }, maxBufferedAmountBytes);
464
479
  });
480
+ // G3: typed preamble/filler lifecycle — same wire shape as the edge transport.
481
+ onSession("tool_call_cue", (event) => {
482
+ sendJson(socket, {
483
+ type: `tool_call_${event.phase}`,
484
+ turnId: event.turnId,
485
+ toolId: event.toolId,
486
+ toolName: event.toolName,
487
+ ...(event.afterMs !== undefined ? { afterMs: event.afterMs } : {}),
488
+ }, maxBufferedAmountBytes);
489
+ });
465
490
  onSession("agent_finished", (event) => {
466
491
  sendJson(socket, { type: "agent_end", turnId: event.turnId }, maxBufferedAmountBytes);
467
492
  });
@@ -489,6 +514,10 @@ function wireBrowserSessionEvents(
489
514
  wireAudio: Uint8Array,
490
515
  wireEncoding: "pcm_s16le" | "opus",
491
516
  durationMs: number,
517
+ // The sample rate of THIS frame's payload. Opus frames are encoded at the
518
+ // codec rate (48 kHz), not outputSampleRateHz — mislabeling them 16 kHz made
519
+ // the client skip its 48→16 kHz downsample and play assistant audio ~3× slow.
520
+ wireSampleRateHz: number,
492
521
  ): void => {
493
522
  const sequence = (ttsSequences.get(contextId) ?? 0) + 1;
494
523
  ttsSequences.set(contextId, sequence);
@@ -500,7 +529,7 @@ function wireBrowserSessionEvents(
500
529
  type: "tts_chunk",
501
530
  turnId: contextId,
502
531
  sequence,
503
- sampleRateHz: outputSampleRateHz,
532
+ sampleRateHz: wireSampleRateHz,
504
533
  encoding: wireEncoding,
505
534
  channels: 1,
506
535
  byteLength: wireAudio.byteLength,
@@ -512,7 +541,7 @@ function wireBrowserSessionEvents(
512
541
  type: "audio",
513
542
  contextId,
514
543
  sequence,
515
- sampleRateHz: outputSampleRateHz,
544
+ sampleRateHz: wireSampleRateHz,
516
545
  encoding: wireEncoding,
517
546
  channels: 1,
518
547
  byteLength: wireAudio.byteLength,
@@ -532,7 +561,7 @@ function wireBrowserSessionEvents(
532
561
  samples = resamplePcm16Streaming(streamingResamplers, samples, outputSampleRateHz, opusCodec.sampleRateHz);
533
562
  }
534
563
  for (const opus of opusCodec.encodePcm16Frame(samples, false)) {
535
- pushWireFrame(opus, "opus", BROWSER_OPUS_FRAME_DURATION_MS);
564
+ pushWireFrame(opus, "opus", BROWSER_OPUS_FRAME_DURATION_MS, opusCodec.sampleRateHz);
536
565
  }
537
566
  }
538
567
  return frames;
@@ -540,7 +569,7 @@ function wireBrowserSessionEvents(
540
569
 
541
570
  for (let offset = 0; offset < resampled.byteLength; offset += frameBytesCount) {
542
571
  const frameAudio = resampled.subarray(offset, Math.min(resampled.byteLength, offset + frameBytesCount));
543
- pushWireFrame(frameAudio, "pcm_s16le", pcm16DurationMs(frameAudio, outputSampleRateHz));
572
+ pushWireFrame(frameAudio, "pcm_s16le", pcm16DurationMs(frameAudio, outputSampleRateHz), outputSampleRateHz);
544
573
  }
545
574
  return frames;
546
575
  },
@@ -564,7 +593,7 @@ function wireBrowserSessionEvents(
564
593
  type: "tts_chunk",
565
594
  turnId: contextId,
566
595
  sequence,
567
- sampleRateHz: outputSampleRateHz,
596
+ sampleRateHz: opusCodec.sampleRateHz,
568
597
  encoding: "opus",
569
598
  channels: 1,
570
599
  byteLength: opus.byteLength,
@@ -575,7 +604,7 @@ function wireBrowserSessionEvents(
575
604
  type: "audio",
576
605
  contextId,
577
606
  sequence,
578
- sampleRateHz: outputSampleRateHz,
607
+ sampleRateHz: opusCodec.sampleRateHz,
579
608
  encoding: "opus",
580
609
  channels: 1,
581
610
  byteLength: opus.byteLength,
@@ -683,6 +712,22 @@ function handleClientMessage(
683
712
  }
684
713
  return currentContextId;
685
714
  }
715
+ if (message.type === "playout_progress") {
716
+ // The browser reports how much assistant audio it has actually played out. This
717
+ // is a more accurate "heard" clock than the server pacer (which leads the ear by
718
+ // the client jitter buffer), so barge-in truncates history to what was heard (B2).
719
+ const progressContextId = message.contextId ?? currentContextId;
720
+ if (progressContextId) {
721
+ session.bus.push(Route.Main, {
722
+ kind: "tts.playout_progress",
723
+ contextId: progressContextId,
724
+ timestampMs: Date.now(),
725
+ playedOutMs: message.playedOutMs,
726
+ complete: message.complete ?? false,
727
+ });
728
+ }
729
+ return currentContextId;
730
+ }
686
731
  if (message.type === "text") {
687
732
  const nextContextId = message.contextId ?? contextId();
688
733
  if (nextContextId !== currentContextId) {
@@ -750,6 +795,18 @@ function parseClientMessage(value: unknown): ClientMessage {
750
795
  }
751
796
  return { type, downlinkEncoding };
752
797
  }
798
+ if (type === "playout_progress") {
799
+ const playedOutMs = value.playedOutMs;
800
+ if (typeof playedOutMs !== "number" || !Number.isFinite(playedOutMs) || playedOutMs < 0) {
801
+ throw new Error("playout_progress.playedOutMs must be a non-negative number");
802
+ }
803
+ return {
804
+ type,
805
+ contextId: optionalContextId(value.contextId),
806
+ playedOutMs,
807
+ complete: value.complete === true ? true : undefined,
808
+ };
809
+ }
753
810
  throw new Error("Unsupported client message type");
754
811
  }
755
812
 
@@ -883,9 +940,12 @@ function outboundMessageByteLength(data: string | Buffer): number {
883
940
  }
884
941
 
885
942
  function defaultContextId(): string {
886
- return `turn-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
943
+ // crypto-random, not Math.random: ids are guessable resource handles. V8's PRNG
944
+ // is state-recoverable from a few observed outputs, which would let a client
945
+ // predict other sessions' turn/session ids and attach to a live conversation.
946
+ return `turn-${globalThis.crypto.randomUUID()}`;
887
947
  }
888
948
 
889
949
  function defaultSessionId(): string {
890
- return `session-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
950
+ return `session-${globalThis.crypto.randomUUID()}`;
891
951
  }
@@ -5,7 +5,7 @@ import { describe, expect, it } from "vitest";
5
5
  import WebSocket from "ws";
6
6
  import { Route, VoiceAgentSession } from "@kuralle-syrinx/core";
7
7
  import { pcm16SamplesToBytes } from "@kuralle-syrinx/core/audio";
8
- import { wireTelephonyOutboundPipeline } from "./outbound-playout-pipeline.js";
8
+ import { wireTelephonyOutboundPipeline, installTelephonyTurnRotation, type RotatableTurnContext } from "./outbound-playout-pipeline.js";
9
9
 
10
10
  function createMockSocket(): WebSocket {
11
11
  const emitter = new EventEmitter();
@@ -206,3 +206,60 @@ describe("wireTelephonyOutboundPipeline.drainAndClose", () => {
206
206
  for (const dispose of disposers) dispose();
207
207
  });
208
208
  });
209
+
210
+ describe("installTelephonyTurnRotation", () => {
211
+ // Regression for the telephony "deaf after turn 1" P0: a carrier gives one
212
+ // stream per call, but STT/TTS retire a contextId once its turn completes, so
213
+ // reusing a single callSid id muted the agent after turn 1. Every turn must get
214
+ // a fresh per-turn id with a turn.change boundary.
215
+ it("rotates a per-turn contextId and emits turn.change on each completed turn", async () => {
216
+ const session = new VoiceAgentSession({ plugins: {} });
217
+ void session.start();
218
+ const disposers: Array<() => void> = [];
219
+ const state: RotatableTurnContext = {
220
+ contextId: "twilio-CA123",
221
+ contextBase: "twilio-CA123",
222
+ turnCounter: 0,
223
+ };
224
+ const turnChanges: Array<{ contextId: string; previousContextId: string; reason: string }> = [];
225
+ session.bus.on("turn.change", (pkt) => {
226
+ const change = pkt as unknown as { contextId: string; previousContextId: string; reason: string };
227
+ turnChanges.push({ contextId: change.contextId, previousContextId: change.previousContextId, reason: change.reason });
228
+ });
229
+
230
+ installTelephonyTurnRotation(session, disposers, state);
231
+
232
+ // Turn 1 runs on the base id.
233
+ expect(state.contextId).toBe("twilio-CA123");
234
+
235
+ session.bus.push(Route.Main, { kind: "eos.turn_complete", contextId: "twilio-CA123", timestampMs: Date.now(), text: "one", transcripts: [] });
236
+ await new Promise((r) => setTimeout(r, 0));
237
+ expect(state.contextId).toBe("twilio-CA123-t1");
238
+
239
+ session.bus.push(Route.Main, { kind: "eos.turn_complete", contextId: "twilio-CA123-t1", timestampMs: Date.now(), text: "two", transcripts: [] });
240
+ await new Promise((r) => setTimeout(r, 0));
241
+ expect(state.contextId).toBe("twilio-CA123-t2");
242
+
243
+ expect(turnChanges).toEqual([
244
+ { contextId: "twilio-CA123-t1", previousContextId: "twilio-CA123", reason: "telephony_turn_complete" },
245
+ { contextId: "twilio-CA123-t2", previousContextId: "twilio-CA123-t1", reason: "telephony_turn_complete" },
246
+ ]);
247
+
248
+ for (const dispose of disposers) dispose();
249
+ await session.close();
250
+ });
251
+
252
+ it("no-ops until a base is set", async () => {
253
+ const session = new VoiceAgentSession({ plugins: {} });
254
+ void session.start();
255
+ const disposers: Array<() => void> = [];
256
+ const state: RotatableTurnContext = { contextId: "", contextBase: "", turnCounter: 0 };
257
+ installTelephonyTurnRotation(session, disposers, state);
258
+ session.bus.push(Route.Main, { kind: "eos.turn_complete", contextId: "", timestampMs: Date.now(), text: "", transcripts: [] });
259
+ await new Promise((r) => setTimeout(r, 0));
260
+ expect(state.contextId).toBe("");
261
+ expect(state.turnCounter).toBe(0);
262
+ for (const dispose of disposers) dispose();
263
+ await session.close();
264
+ });
265
+ });
@@ -24,6 +24,45 @@ export interface TelephonyOutboundHandle {
24
24
  drainAndClose(socket: WebSocket, deadlineMs: number): Promise<void>;
25
25
  }
26
26
 
27
+ /**
28
+ * Per-turn contextId identity for carrier transports.
29
+ *
30
+ * A phone carrier gives one continuous stream per call, but the engine finishes a
31
+ * turn per contextId and its STT/TTS plugins permanently retire a contextId once it
32
+ * completes (finalized/cancelled sets). Reusing one id for the whole call therefore
33
+ * makes the agent go deaf/mute after turn 1. Every carrier adapter mints a fresh
34
+ * per-turn id (`<base>-t<n>`) and rotates it at each turn boundary, emitting a
35
+ * `turn.change` so STT/metrics/dedup see a consistent boundary — matching the browser
36
+ * transport. Turn 1 uses `contextBase`; rotation begins at `-t1`.
37
+ */
38
+ export interface RotatableTurnContext {
39
+ contextId: string;
40
+ contextBase: string;
41
+ turnCounter: number;
42
+ }
43
+
44
+ export function installTelephonyTurnRotation(
45
+ session: VoiceAgentSession,
46
+ disposers: Array<() => void>,
47
+ state: RotatableTurnContext,
48
+ ): void {
49
+ disposers.push(
50
+ session.bus.on("eos.turn_complete", () => {
51
+ if (!state.contextBase) return;
52
+ state.turnCounter += 1;
53
+ const previousContextId = state.contextId;
54
+ state.contextId = `${state.contextBase}-t${String(state.turnCounter)}`;
55
+ session.bus.push(Route.Main, {
56
+ kind: "turn.change",
57
+ contextId: state.contextId,
58
+ previousContextId,
59
+ timestampMs: Date.now(),
60
+ reason: "telephony_turn_complete",
61
+ });
62
+ }),
63
+ );
64
+ }
65
+
27
66
  export function wireTelephonyOutboundPipeline(args: {
28
67
  readonly session: VoiceAgentSession;
29
68
  readonly socket: WebSocket;
package/src/smartpbx.ts CHANGED
@@ -23,7 +23,7 @@ import {
23
23
  } from "./json-message.js";
24
24
  import { createRoutedWebSocketServer } from "./websocket-upgrade.js";
25
25
  import { runWebSocketConnection, type GracefulCloseOptions, type TransportAdapter, type TransportHostConfig, TRANSPORT_ADMISSION_REJECTED_METRIC } from "./transport-host.js";
26
- import { wireTelephonyOutboundPipeline } from "./outbound-playout-pipeline.js";
26
+ import { wireTelephonyOutboundPipeline, installTelephonyTurnRotation } from "./outbound-playout-pipeline.js";
27
27
  import {
28
28
  decodeStrictBase64,
29
29
  nonNegativeInteger,
@@ -86,6 +86,8 @@ interface SmartPbxConnectionState {
86
86
  callId: string;
87
87
  accountId: string;
88
88
  contextId: string;
89
+ contextBase: string;
90
+ turnCounter: number;
89
91
  codec: SmartPbxCodec;
90
92
  wireSampleRateHz: number;
91
93
  opusDecoder: OpusDecoder | null;
@@ -140,6 +142,8 @@ export async function createSmartPbxMediaStreamServer(
140
142
  callId: "",
141
143
  accountId: "",
142
144
  contextId: "",
145
+ contextBase: "",
146
+ turnCounter: 0,
143
147
  codec: "g711_ulaw",
144
148
  wireSampleRateHz: 8000,
145
149
  opusDecoder: null,
@@ -239,6 +243,7 @@ export async function createSmartPbxMediaStreamServer(
239
243
  },
240
244
  });
241
245
  state.clearPlayout = outbound.clearPlayout;
246
+ installTelephonyTurnRotation(session, disposers, state);
242
247
  gracefulCloseRegistry.set(socket, (deadlineMs) => outbound.drainAndClose(socket, deadlineMs));
243
248
  disposers.push(() => gracefulCloseRegistry.delete(socket));
244
249
  return (reason) => {
@@ -261,6 +266,7 @@ export async function createSmartPbxMediaStreamServer(
261
266
  if (!state.callId) throw new Error("SmartPBX start event is missing callId");
262
267
  if (!state.accountId) throw new Error("SmartPBX start event is missing accountId");
263
268
  state.contextId = contextIdFn(start);
269
+ state.contextBase = state.contextId;
264
270
  state.codec = format.codec;
265
271
  state.wireSampleRateHz = format.sampleRateHz;
266
272
  state.opusDecoder = format.codec === "opus" ? new OpusDecoder({ channels: 1, sample_rate: 48000 }) : null;
package/src/telnyx.ts CHANGED
@@ -25,7 +25,7 @@ import {
25
25
  } from "./json-message.js";
26
26
  import { createRoutedWebSocketServer } from "./websocket-upgrade.js";
27
27
  import { runWebSocketConnection, type GracefulCloseOptions, type TransportAdapter, type TransportHostConfig, TRANSPORT_ADMISSION_REJECTED_METRIC } from "./transport-host.js";
28
- import { wireTelephonyOutboundPipeline } from "./outbound-playout-pipeline.js";
28
+ import { wireTelephonyOutboundPipeline, installTelephonyTurnRotation } from "./outbound-playout-pipeline.js";
29
29
  import {
30
30
  decodeStrictBase64,
31
31
  nonNegativeInteger,
@@ -104,6 +104,8 @@ type TelnyxCodec = "PCMU" | "L16";
104
104
  interface TelnyxConnectionState {
105
105
  streamId: string;
106
106
  contextId: string;
107
+ contextBase: string;
108
+ turnCounter: number;
107
109
  inboundCodec: TelnyxCodec;
108
110
  inboundSampleRateHz: number;
109
111
  readonly outboundCodec: TelnyxCodec;
@@ -168,6 +170,8 @@ export async function createTelnyxMediaStreamServer(
168
170
  createState: () => ({
169
171
  streamId: "",
170
172
  contextId: "",
173
+ contextBase: "",
174
+ turnCounter: 0,
171
175
  inboundCodec: "PCMU",
172
176
  inboundSampleRateHz: 8000,
173
177
  outboundCodec: bidirectionalCodec,
@@ -290,6 +294,7 @@ export async function createTelnyxMediaStreamServer(
290
294
  },
291
295
  });
292
296
  state.clearPlayout = outbound.clearPlayout;
297
+ installTelephonyTurnRotation(session, disposers, state);
293
298
  gracefulCloseRegistry.set(socket, (deadlineMs) => outbound.drainAndClose(socket, deadlineMs));
294
299
  disposers.push(() => gracefulCloseRegistry.delete(socket));
295
300
  return (reason) => state.clearPlayout(reason);
@@ -315,6 +320,7 @@ export async function createTelnyxMediaStreamServer(
315
320
  state.streamId = message.stream_id ?? start.stream_id ?? "";
316
321
  if (!state.streamId) throw new Error("Telnyx start event is missing stream_id");
317
322
  state.contextId = contextIdFn(start);
323
+ state.contextBase = state.contextId;
318
324
  state.inboundCodec = format.codec;
319
325
  state.inboundSampleRateHz = format.sampleRateHz;
320
326
  state.started = true;
@@ -30,6 +30,15 @@ export interface TransportAdmissionOptions {
30
30
  readonly maxConcurrentSessions?: number;
31
31
  readonly maxConcurrentSessionsScope?: "path" | "server";
32
32
  readonly onAdmissionRejected?: () => void;
33
+ /**
34
+ * Authorize an inbound connection before the WebSocket upgrade completes. Return
35
+ * false (or throw) to reject with 4401. Voice endpoints are unauthenticated by
36
+ * default and each connection incurs provider cost and can attach to a live
37
+ * session — set this (shared secret / bearer / Twilio signature) before exposing
38
+ * the endpoint. Receives the raw upgrade request (headers + URL) so it can read
39
+ * an `Authorization` header or a `?token=` query param.
40
+ */
41
+ readonly authorize?: (request: IncomingMessage) => boolean | Promise<boolean>;
33
42
  }
34
43
 
35
44
  export const TRANSPORT_ADMISSION_REJECTED_METRIC = "transport.admission_rejected";
@@ -39,9 +48,11 @@ export function rejectWebSocketAdmission(
39
48
  request: IncomingMessage,
40
49
  socket: Socket,
41
50
  head: Buffer,
51
+ code = 1013,
52
+ reason = "try again later",
42
53
  ): void {
43
54
  wsServer.handleUpgrade(request, socket, head, (websocket) => {
44
- websocket.close(1013, "try again later");
55
+ websocket.close(code, reason);
45
56
  });
46
57
  }
47
58
 
@@ -0,0 +1,36 @@
1
+ // SPDX-License-Identifier: MIT
2
+
3
+ import { describe, expect, it } from "vitest";
4
+ import { validateTwilioSignature } from "./twilio-auth.js";
5
+
6
+ // Vector computed with the canonical HMAC-SHA1 algorithm (verified against
7
+ // node:crypto createHmac("sha1")) for these exact inputs — this pins that our
8
+ // Web Crypto implementation matches Twilio's documented signing scheme.
9
+ describe("validateTwilioSignature", () => {
10
+ const authToken = "12345";
11
+ const url = "https://mycompany.com/myapp.php?foo=1&bar=2";
12
+ const params = {
13
+ CallSid: "CA1234567890ABCDE",
14
+ Caller: "+14158675310",
15
+ Digits: "1234",
16
+ From: "+14158675310",
17
+ To: "+18005551212",
18
+ };
19
+ const validSignature = "GvWf1cFY/Q7PnoempGyD5oXAezc=";
20
+
21
+ it("accepts a correct signature", async () => {
22
+ expect(await validateTwilioSignature({ authToken, signature: validSignature, url, params })).toBe(true);
23
+ });
24
+
25
+ it("rejects a tampered signature", async () => {
26
+ expect(await validateTwilioSignature({ authToken, signature: "AAAA" + validSignature.slice(4), url, params })).toBe(false);
27
+ });
28
+
29
+ it("rejects a wrong auth token", async () => {
30
+ expect(await validateTwilioSignature({ authToken: "wrong", signature: validSignature, url, params })).toBe(false);
31
+ });
32
+
33
+ it("rejects a missing signature", async () => {
34
+ expect(await validateTwilioSignature({ authToken, signature: null, url, params })).toBe(false);
35
+ });
36
+ });
@@ -0,0 +1,55 @@
1
+ // SPDX-License-Identifier: MIT
2
+ //
3
+ // Twilio request-signature validation (X-Twilio-Signature). Runtime-neutral —
4
+ // uses Web Crypto (HMAC-SHA1), so it works on Node and Cloudflare Workers. Twilio
5
+ // signs each webhook: signature = base64(HMAC-SHA1(authToken, fullUrl + concat of
6
+ // POST params sorted by key, each as key+value)). Reference:
7
+ // https://www.twilio.com/docs/usage/security#validating-requests
8
+ //
9
+ // Plug into the transport `authorize` hook, or call directly in a webhook handler.
10
+
11
+ /**
12
+ * Validate a Twilio request signature. `params` are the POST form fields (empty
13
+ * for a bare GET / WS upgrade). Constant-time compares the computed signature
14
+ * against the provided one. Returns false on any mismatch or malformed input.
15
+ */
16
+ export async function validateTwilioSignature(args: {
17
+ readonly authToken: string;
18
+ readonly signature: string | null | undefined;
19
+ readonly url: string;
20
+ readonly params?: Record<string, string>;
21
+ }): Promise<boolean> {
22
+ const { authToken, signature, url } = args;
23
+ if (!authToken || !signature) return false;
24
+
25
+ const params = args.params ?? {};
26
+ let data = url;
27
+ for (const key of Object.keys(params).sort()) {
28
+ data += key + params[key];
29
+ }
30
+
31
+ const key = await crypto.subtle.importKey(
32
+ "raw",
33
+ new TextEncoder().encode(authToken),
34
+ { name: "HMAC", hash: "SHA-1" },
35
+ false,
36
+ ["sign"],
37
+ );
38
+ const mac = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(data));
39
+ const expected = base64FromBytes(new Uint8Array(mac));
40
+ return timingSafeEqual(expected, signature);
41
+ }
42
+
43
+ function base64FromBytes(bytes: Uint8Array): string {
44
+ let binary = "";
45
+ for (const b of bytes) binary += String.fromCharCode(b);
46
+ // btoa exists on Workers and modern Node globals.
47
+ return btoa(binary);
48
+ }
49
+
50
+ function timingSafeEqual(a: string, b: string): boolean {
51
+ if (a.length !== b.length) return false;
52
+ let diff = 0;
53
+ for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
54
+ return diff === 0;
55
+ }
package/src/twilio.ts CHANGED
@@ -22,7 +22,7 @@ import {
22
22
  } from "./json-message.js";
23
23
  import { createRoutedWebSocketServer } from "./websocket-upgrade.js";
24
24
  import { runWebSocketConnection, type GracefulCloseOptions, type TransportAdapter, type TransportHostConfig, TRANSPORT_ADMISSION_REJECTED_METRIC } from "./transport-host.js";
25
- import { wireTelephonyOutboundPipeline } from "./outbound-playout-pipeline.js";
25
+ import { wireTelephonyOutboundPipeline, installTelephonyTurnRotation } from "./outbound-playout-pipeline.js";
26
26
  import {
27
27
  decodeStrictBase64,
28
28
  nonNegativeInteger,
@@ -90,6 +90,8 @@ interface TwilioMediaMessage {
90
90
  interface TwilioConnectionState {
91
91
  streamSid: string;
92
92
  contextId: string;
93
+ contextBase: string;
94
+ turnCounter: number;
93
95
  started: boolean;
94
96
  stopped: boolean;
95
97
  lastInboundSequenceNumber: number | null;
@@ -148,6 +150,8 @@ export async function createTwilioMediaStreamServer(
148
150
  createState: () => ({
149
151
  streamSid: "",
150
152
  contextId: "",
153
+ contextBase: "",
154
+ turnCounter: 0,
151
155
  started: false,
152
156
  stopped: false,
153
157
  lastInboundSequenceNumber: null,
@@ -277,6 +281,7 @@ export async function createTwilioMediaStreamServer(
277
281
  },
278
282
  });
279
283
  state.clearPlayout = outbound.clearPlayout;
284
+ installTelephonyTurnRotation(session, disposers, state);
280
285
  gracefulCloseRegistry.set(socket, (deadlineMs) => outbound.drainAndClose(socket, deadlineMs));
281
286
  disposers.push(() => gracefulCloseRegistry.delete(socket));
282
287
  return (reason) => state.clearPlayout(reason);
@@ -296,6 +301,7 @@ export async function createTwilioMediaStreamServer(
296
301
  state.streamSid = start.streamSid ?? message.streamSid ?? "";
297
302
  if (!state.streamSid) throw new Error("Twilio start event is missing streamSid");
298
303
  state.contextId = contextIdFn(start);
304
+ state.contextBase = state.contextId;
299
305
  state.started = true;
300
306
  return;
301
307
  }
@@ -68,7 +68,7 @@ export function createRoutedWebSocketServer(
68
68
  perMessageDeflate: false,
69
69
  });
70
70
  const router = getOrCreateRouter(httpServer);
71
- const handler: UpgradeHandler = (request, socket, head) => {
71
+ const accept = (request: IncomingMessage, socket: Socket, head: Buffer): void => {
72
72
  const maxConcurrentSessions = admission?.maxConcurrentSessions;
73
73
  const scope = admission?.maxConcurrentSessionsScope ?? "path";
74
74
  const activeSessions = scope === "server"
@@ -86,6 +86,25 @@ export function createRoutedWebSocketServer(
86
86
  wsServer.emit("connection", websocket, request);
87
87
  });
88
88
  };
89
+ const handler: UpgradeHandler = (request, socket, head) => {
90
+ if (!admission?.authorize) {
91
+ accept(request, socket, head);
92
+ return;
93
+ }
94
+ // Authorize before completing the upgrade; reject with 4401 on failure/throw.
95
+ void Promise.resolve()
96
+ .then(() => admission.authorize!(request))
97
+ .then((ok) => {
98
+ if (ok) {
99
+ accept(request, socket, head);
100
+ } else {
101
+ rejectWebSocketAdmission(wsServer, request, socket, head, 4401, "unauthorized");
102
+ }
103
+ })
104
+ .catch(() => {
105
+ rejectWebSocketAdmission(wsServer, request, socket, head, 4401, "unauthorized");
106
+ });
107
+ };
89
108
  router.handlers.set(path, handler);
90
109
  router.servers.add(wsServer);
91
110
  return {