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:
parent
70bd1ffe92
commit
9bff8bc976
@ -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", () => {
|
||||
|
||||
@ -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`,
|
||||
);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user