voice-call: embed stream token in URL path instead of query param

Tailscale Funnel strips query parameters from proxied WebSocket upgrade
requests, causing the one-time stream token to be lost and Twilio's
WebSocket connection to be rejected with 401.

Move the token from ?token=<uuid> to a path segment:
  wss://host/voice/stream/realtime/<uuid>

Token extraction in handleWebSocketUpgrade now uses the last path segment
instead of searchParams. Tests updated to match the new URL format.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Forrest Blount 2026-03-12 21:10:32 +00:00
parent 70bd1ffe92
commit 9bff8bc976
2 changed files with 7 additions and 5 deletions

View File

@ -7,7 +7,7 @@ import { RealtimeCallHandler } from "./realtime-handler.js";
/** Extract the stream token from a TwiML body string. */
function extractStreamToken(twiml: string): string | null {
const match = twiml.match(/\?token=([^"&\s]+)/);
const match = twiml.match(/\/voice\/stream\/realtime\/([^"&\s]+)/);
return match?.[1] ?? null;
}
@ -99,7 +99,7 @@ describe("RealtimeCallHandler", () => {
expect(payload.headers?.["Content-Type"]).toBe("text/xml");
expect(payload.body).toContain("<Connect>");
expect(payload.body).toContain("<Stream");
expect(payload.body).toContain("wss://gateway.ts.net/voice/stream/realtime?token=");
expect(payload.body).toMatch(/wss:\/\/gateway\.ts\.net\/voice\/stream\/realtime\/[0-9a-f-]{36}/);
});
it("falls back to localhost when no host header is present", () => {
@ -112,7 +112,7 @@ describe("RealtimeCallHandler", () => {
const req = makeRequest("/voice/webhook", "");
const payload = handler.buildTwiMLPayload(req);
expect(payload.body).toContain("wss://localhost:8443/voice/stream/realtime?token=");
expect(payload.body).toMatch(/wss:\/\/localhost:8443\/voice\/stream\/realtime\/[0-9a-f-]{36}/);
});
it("embeds a unique token on each call", () => {

View File

@ -61,7 +61,9 @@ export class RealtimeCallHandler {
*/
handleWebSocketUpgrade(request: http.IncomingMessage, socket: Duplex, head: Buffer): void {
const url = new URL(request.url ?? "/", "wss://localhost");
const token = url.searchParams.get("token");
// Token is embedded as the last path segment (e.g. /voice/stream/realtime/<uuid>)
// to survive reverse proxies that strip query parameters (e.g. Tailscale Funnel).
const token = url.pathname.split("/").pop() ?? null;
const callerMeta = token ? this.consumeStreamToken(token) : null;
if (!callerMeta) {
console.warn("[voice-call] Rejecting WS upgrade: missing or invalid stream token");
@ -123,7 +125,7 @@ export class RealtimeCallHandler {
from: params?.get("From") ?? undefined,
to: params?.get("To") ?? undefined,
});
const wsUrl = `wss://${host}/voice/stream/realtime?token=${token}`;
const wsUrl = `wss://${host}/voice/stream/realtime/${token}`;
console.log(
`[voice-call] Returning realtime TwiML with WebSocket: wss://${host}/voice/stream/realtime`,
);