@absolutejs/voice 0.0.22-beta.663 → 0.0.22-beta.664

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/README.md CHANGED
@@ -1454,6 +1454,11 @@ export const app = new Elysia()
1454
1454
  options: {
1455
1455
  context: {},
1456
1456
  outcomePolicy,
1457
+ security: {
1458
+ authToken: process.env.TWILIO_AUTH_TOKEN!,
1459
+ expectedAccountSid: process.env.TWILIO_ACCOUNT_SID!,
1460
+ publicOrigin: "https://voice.example.com",
1461
+ },
1457
1462
  session: runtime.session,
1458
1463
  stt: deepgram({ apiKey: process.env.DEEPGRAM_API_KEY! }),
1459
1464
  streamPath: "/api/voice/twilio/stream",
@@ -4274,6 +4279,14 @@ await bridge.handleMessage(mediaMessageFromTwilio);
4274
4279
 
4275
4280
  The bridge also sends Twilio `clear` events on new inbound media after assistant audio has started streaming, so telephony barge-in can stop queued outbound playback.
4276
4281
 
4282
+ When mounting `createTwilioVoiceRoutes(...)` or the Twilio carrier in
4283
+ `createVoicePhoneAgent(...)`, `security` is required. The package validates
4284
+ signed GET/POST TwiML requests against the fixed `publicOrigin`, validates the
4285
+ Media Streams WebSocket signature, and can bind both ingress paths to
4286
+ `expectedAccountSid`. It never derives the signed URL from forwarded host or
4287
+ protocol headers. Set `streamVerificationUrl` only when the exact WebSocket URL
4288
+ Twilio signs differs from `wss://<publicOrigin><streamPath>`.
4289
+
4277
4290
  ## Recommended Production Path
4278
4291
 
4279
4292
  The current best-performing path is:
package/dist/index.d.ts CHANGED
@@ -292,7 +292,7 @@ export { evaluateVoiceTelephonyContract } from "./telephony/contract";
292
292
  export { createMemoryVoiceTelnyxWebhookEventStore, createTelnyxMediaStreamBridge, createTelnyxVoiceResponse, createTelnyxVoiceRoutes, createVoicePostgresTelnyxWebhookEventStore, createVoiceRedisTelnyxWebhookEventStore, createVoiceSQLiteTelnyxWebhookEventStore, createVoiceTelnyxWebhookVerifier, verifyVoiceTelnyxWebhookSignature, } from "./telephony/telnyx";
293
293
  export { createMemoryVoicePlivoWebhookNonceStore, createPlivoMediaStreamBridge, createPlivoVoiceResponse, createPlivoVoiceRoutes, createVoicePostgresPlivoWebhookNonceStore, createVoicePlivoWebhookVerifier, createVoiceRedisPlivoWebhookNonceStore, createVoiceSQLitePlivoWebhookNonceStore, signVoicePlivoWebhook, verifyVoicePlivoWebhookSignature, } from "./telephony/plivo";
294
294
  export { createVoiceTelephonyCarrierMatrix, createVoiceTelephonyCarrierMatrixRoutes, renderVoiceTelephonyCarrierMatrixHTML, } from "./telephony/matrix";
295
- export type { TwilioInboundMessage, TwilioMediaStreamBridge, TwilioMediaStreamBridgeOptions, TwilioMediaStreamSocket, TwilioOutboundClearMessage, TwilioOutboundMarkMessage, TwilioOutboundMediaMessage, TwilioOutboundMessage, TwilioVoiceRouteParameters, TwilioVoiceResponseOptions, TwilioVoiceSmokeCheck, TwilioVoiceSmokeOptions, TwilioVoiceSmokeReport, TwilioVoiceSetupOptions, TwilioVoiceSetupStatus, TwilioVoiceRoutesOptions, } from "./telephony/twilio";
295
+ export type { TwilioInboundMessage, TwilioMediaStreamBridge, TwilioMediaStreamBridgeOptions, TwilioMediaStreamSocket, TwilioOutboundClearMessage, TwilioOutboundMarkMessage, TwilioOutboundMediaMessage, TwilioOutboundMessage, TwilioVoiceRouteParameters, TwilioVoiceResponseOptions, TwilioVoiceSmokeCheck, TwilioVoiceSmokeOptions, TwilioVoiceSmokeReport, TwilioVoiceSetupOptions, TwilioVoiceSetupStatus, TwilioVoiceRoutesOptions, TwilioVoiceSecurityOptions, } from "./telephony/twilio";
296
296
  export type { VoiceTelephonyWebhookSecurityOptions, VoiceTelephonyWebhookSecurityPreset, VoiceTelephonyWebhookSecurityAssertionInput, VoiceTelephonyWebhookSecurityAssertionReport, VoiceTelephonyWebhookSecurityProviderStatus, VoiceTelephonyWebhookSecurityReport, VoiceTelephonyWebhookSecurityRoutesOptions, VoiceTelephonyWebhookSecurityStorePreset, } from "./telephony/security";
297
297
  export type { VoiceTelephonyContractIssue, VoiceTelephonyContractOptions, VoiceTelephonyContractReport, VoiceTelephonyContractRequirement, VoiceTelephonyProvider, VoiceTelephonySetupStatus, VoiceTelephonySmokeCheck, VoiceTelephonySmokeReport, } from "./telephony/contract";
298
298
  export type { TelnyxInboundMessage, TelnyxMediaPayload, TelnyxMediaStreamBridge, TelnyxMediaStreamBridgeOptions, TelnyxMediaStreamSocket, TelnyxOutboundClearMessage, TelnyxOutboundMarkMessage, TelnyxOutboundMediaMessage, TelnyxOutboundMessage, TelnyxVoiceResponseOptions, TelnyxVoiceRoutesOptions, TelnyxVoiceSetupOptions, TelnyxVoiceSetupStatus, TelnyxVoiceSmokeCheck, TelnyxVoiceSmokeOptions, TelnyxVoiceSmokeReport, VoicePostgresTelnyxWebhookEventStoreOptions, VoiceRedisTelnyxWebhookEventClient, VoiceRedisTelnyxWebhookEventStoreOptions, VoiceSQLiteTelnyxWebhookEventStoreOptions, VoiceTelnyxWebhookEventStore, VoiceTelnyxWebhookEventStoreOptions, VoiceTelnyxWebhookVerifierOptions, } from "./telephony/telnyx";
package/dist/index.js CHANGED
@@ -25346,13 +25346,20 @@ import { Elysia as Elysia37 } from "elysia";
25346
25346
  var TWILIO_MULAW_SAMPLE_RATE = 8000;
25347
25347
  var VOICE_PCM_SAMPLE_RATE = 16000;
25348
25348
  var escapeXml2 = (value) => value.replaceAll("&", "&amp;").replaceAll('"', "&quot;").replaceAll("'", "&apos;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
25349
- var resolveRequestOrigin = (request) => {
25350
- const url = new URL(request.url);
25351
- const forwardedHost = request.headers.get("x-forwarded-host");
25352
- const forwardedProto = request.headers.get("x-forwarded-proto");
25353
- const host = forwardedHost ?? request.headers.get("host") ?? url.host;
25354
- const protocol = forwardedProto ?? url.protocol.replace(":", "");
25355
- return `${protocol}://${host}`;
25349
+ var TWILIO_ACCOUNT_SID = /^AC[0-9a-fA-F]{32}$/;
25350
+ var MAX_TWILIO_VOICE_BODY_BYTES = 64 * 1024;
25351
+ var resolveTwilioPublicOrigin = (security) => {
25352
+ if (security.authToken.trim().length === 0) {
25353
+ throw new Error("Twilio voice security.authToken must not be empty.");
25354
+ }
25355
+ if (security.expectedAccountSid !== undefined && !TWILIO_ACCOUNT_SID.test(security.expectedAccountSid)) {
25356
+ throw new Error("Twilio voice security.expectedAccountSid must be an AC SID.");
25357
+ }
25358
+ const origin = new URL(security.publicOrigin);
25359
+ if (origin.protocol !== "https:" || origin.pathname !== "/" || origin.search.length > 0 || origin.hash.length > 0) {
25360
+ throw new Error("Twilio voice security.publicOrigin must be an HTTPS origin without a path, query, or fragment.");
25361
+ }
25362
+ return origin.origin;
25356
25363
  };
25357
25364
  var resolveTwilioStreamUrl = async (options, input) => {
25358
25365
  if (typeof options.twiml?.streamUrl === "function") {
@@ -25361,10 +25368,61 @@ var resolveTwilioStreamUrl = async (options, input) => {
25361
25368
  if (typeof options.twiml?.streamUrl === "string") {
25362
25369
  return options.twiml.streamUrl;
25363
25370
  }
25364
- const origin = resolveRequestOrigin(input.request);
25371
+ const origin = resolveTwilioPublicOrigin(options.security);
25365
25372
  const wsOrigin = origin.replace(/^http:/, "ws:").replace(/^https:/, "wss:");
25366
25373
  return `${wsOrigin}${input.streamPath}`;
25367
25374
  };
25375
+ var resolveTrustedTwilioRequestUrl = (security, request) => {
25376
+ const requestUrl = new URL(request.url);
25377
+ return `${resolveTwilioPublicOrigin(security)}${requestUrl.pathname}${requestUrl.search}`;
25378
+ };
25379
+ var resolveTwilioSocketHeaders = (socket) => {
25380
+ if (!socket.data || typeof socket.data !== "object")
25381
+ return new Headers;
25382
+ const data = socket.data;
25383
+ for (const candidate of [data.request?.headers, data.headers]) {
25384
+ if (!candidate)
25385
+ continue;
25386
+ if (typeof candidate === "object" && "get" in candidate && typeof candidate.get === "function") {
25387
+ return candidate;
25388
+ }
25389
+ try {
25390
+ return new Headers(candidate);
25391
+ } catch {}
25392
+ }
25393
+ return new Headers;
25394
+ };
25395
+ var readTwilioVoiceForm = async (request) => {
25396
+ const declaredLength = Number(request.headers.get("content-length"));
25397
+ if (Number.isFinite(declaredLength) && declaredLength > MAX_TWILIO_VOICE_BODY_BYTES) {
25398
+ throw new Response("Twilio request body is too large.", { status: 413 });
25399
+ }
25400
+ const contentType = request.headers.get("content-type") ?? "";
25401
+ if (!contentType.includes("application/x-www-form-urlencoded")) {
25402
+ throw new Response("Expected a form-encoded Twilio request.", {
25403
+ status: 415
25404
+ });
25405
+ }
25406
+ const text = await request.text();
25407
+ if (Buffer3.byteLength(text, "utf8") > MAX_TWILIO_VOICE_BODY_BYTES) {
25408
+ throw new Response("Twilio request body is too large.", { status: 413 });
25409
+ }
25410
+ return Object.fromEntries(new URLSearchParams(text));
25411
+ };
25412
+ var verifyTwilioVoiceRequest = async (request, security) => {
25413
+ const body = request.method.toUpperCase() === "POST" ? await readTwilioVoiceForm(request.clone()) : {};
25414
+ const verification = await verifyVoiceTwilioWebhookSignature({
25415
+ authToken: security.authToken,
25416
+ body,
25417
+ headers: request.headers,
25418
+ url: resolveTrustedTwilioRequestUrl(security, request)
25419
+ });
25420
+ const requestUrl = new URL(request.url);
25421
+ const accountSid = typeof body.AccountSid === "string" ? body.AccountSid : requestUrl.searchParams.get("AccountSid");
25422
+ return {
25423
+ ok: verification.ok && (security.expectedAccountSid === undefined || accountSid === security.expectedAccountSid)
25424
+ };
25425
+ };
25368
25426
  var resolveTwilioStreamParameters = async (parameters, input) => {
25369
25427
  if (typeof parameters === "function") {
25370
25428
  return parameters(input);
@@ -25382,7 +25440,7 @@ var getWebhookVerificationUrl = (webhook, input) => {
25382
25440
  return webhook.verificationUrl;
25383
25441
  };
25384
25442
  var buildTwilioVoiceSetupStatus = async (options, input) => {
25385
- const origin = resolveRequestOrigin(input.request);
25443
+ const origin = resolveTwilioPublicOrigin(options.security);
25386
25444
  const stream = await resolveTwilioStreamUrl(options, input);
25387
25445
  const twiml = joinUrlPath(origin, input.twimlPath);
25388
25446
  const webhook = joinUrlPath(origin, input.webhookPath);
@@ -25466,8 +25524,13 @@ var runTwilioVoiceSmokeTest = async (input) => {
25466
25524
  const twimlUrl = new URL(setup.urls.twiml);
25467
25525
  twimlUrl.searchParams.set("scenarioId", input.options.smoke?.scenarioId ?? "smoke");
25468
25526
  twimlUrl.searchParams.set("sessionId", input.options.smoke?.sessionId ?? "smoke-session");
25527
+ const twimlHeaders = new Headers(input.request.headers);
25528
+ twimlHeaders.set("x-twilio-signature", await signVoiceTwilioWebhook({
25529
+ authToken: input.options.security.authToken,
25530
+ url: twimlUrl.toString()
25531
+ }));
25469
25532
  const twimlResponse = await input.app.handle(new Request(twimlUrl, {
25470
- headers: input.request.headers
25533
+ headers: twimlHeaders
25471
25534
  }));
25472
25535
  const twiml = await twimlResponse.text();
25473
25536
  const streamUrl = extractTwilioStreamUrl(twiml);
@@ -26056,45 +26119,32 @@ var createTwilioVoiceResponse = (options) => {
26056
26119
  return `<?xml version="1.0" encoding="UTF-8"?><Response><Connect><Stream url="${escapeXml2(options.streamUrl)}"${options.track ? ` track="${escapeXml2(options.track)}"` : ""}${options.streamName ? ` name="${escapeXml2(options.streamName)}"` : ""}>${parameters}</Stream></Connect></Response>`;
26057
26120
  };
26058
26121
  var createTwilioVoiceRoutes = (options) => {
26122
+ resolveTwilioPublicOrigin(options.security);
26059
26123
  const streamPath = options.streamPath ?? "/api/voice/twilio/stream";
26060
26124
  const twimlPath = options.twiml?.path ?? "/api/voice/twilio";
26061
26125
  const webhookPath = options.webhook?.path ?? "/api/voice/twilio/webhook";
26062
26126
  const setupPath = options.setup?.path === false ? false : options.setup?.path ?? "/api/voice/twilio/setup";
26063
26127
  const smokePath = options.smoke?.path === false ? false : options.smoke?.path ?? "/api/voice/twilio/smoke";
26064
26128
  const bridges = new WeakMap;
26129
+ const admittedSockets = new WeakSet;
26130
+ const startedSockets = new WeakSet;
26065
26131
  const webhookPolicy = options.webhook?.policy ?? options.outcomePolicy ?? createVoiceTelephonyOutcomePolicy();
26066
- const app = new Elysia37({
26067
- name: options.name ?? "absolutejs-voice-twilio"
26068
- }).get(twimlPath, async ({ query, request }) => {
26069
- const streamUrl = await resolveTwilioStreamUrl(options, {
26070
- query,
26071
- request,
26072
- streamPath
26073
- });
26074
- const parameters = await resolveTwilioStreamParameters(options.twiml?.parameters, {
26075
- query,
26076
- request
26077
- });
26078
- return new Response(createTwilioVoiceResponse({
26079
- parameters,
26080
- streamName: options.twiml?.streamName,
26081
- streamUrl,
26082
- track: options.twiml?.track
26083
- }), {
26084
- headers: {
26085
- "content-type": "text/xml; charset=utf-8"
26132
+ const renderTwiml = async (input) => {
26133
+ try {
26134
+ const verification = await verifyTwilioVoiceRequest(input.request, options.security);
26135
+ if (!verification.ok) {
26136
+ return new Response("Invalid Twilio signature.", { status: 403 });
26086
26137
  }
26087
- });
26088
- }).post(twimlPath, async ({ query, request }) => {
26138
+ } catch (error) {
26139
+ if (error instanceof Response)
26140
+ return error;
26141
+ throw error;
26142
+ }
26089
26143
  const streamUrl = await resolveTwilioStreamUrl(options, {
26090
- query,
26091
- request,
26144
+ ...input,
26092
26145
  streamPath
26093
26146
  });
26094
- const parameters = await resolveTwilioStreamParameters(options.twiml?.parameters, {
26095
- query,
26096
- request
26097
- });
26147
+ const parameters = await resolveTwilioStreamParameters(options.twiml?.parameters, input);
26098
26148
  return new Response(createTwilioVoiceResponse({
26099
26149
  parameters,
26100
26150
  streamName: options.twiml?.streamName,
@@ -26105,15 +26155,45 @@ var createTwilioVoiceRoutes = (options) => {
26105
26155
  "content-type": "text/xml; charset=utf-8"
26106
26156
  }
26107
26157
  });
26108
- }).ws(streamPath, {
26158
+ };
26159
+ const streamVerificationUrl = options.security.streamVerificationUrl ?? `${resolveTwilioPublicOrigin(options.security).replace(/^https:/, "wss:")}${streamPath}`;
26160
+ const app = new Elysia37({
26161
+ name: options.name ?? "absolutejs-voice-twilio"
26162
+ }).get(twimlPath, renderTwiml).post(twimlPath, renderTwiml, { parse: "none" }).ws(streamPath, {
26109
26163
  close: async (ws, _code, reason) => {
26110
26164
  const key = ws.raw ?? ws;
26111
26165
  const bridge = bridges.get(key);
26112
26166
  bridges.delete(key);
26167
+ admittedSockets.delete(key);
26168
+ startedSockets.delete(key);
26113
26169
  await bridge?.close(reason);
26114
26170
  },
26115
26171
  message: async (ws, raw) => {
26116
26172
  const key = ws.raw ?? ws;
26173
+ if (!admittedSockets.has(key)) {
26174
+ const verification = await verifyVoiceTwilioWebhookSignature({
26175
+ authToken: options.security.authToken,
26176
+ body: {},
26177
+ headers: resolveTwilioSocketHeaders(ws),
26178
+ url: streamVerificationUrl
26179
+ });
26180
+ if (!verification.ok) {
26181
+ ws.close(4403, "invalid Twilio signature");
26182
+ return;
26183
+ }
26184
+ admittedSockets.add(key);
26185
+ }
26186
+ const message = parseTwilioMessage(raw);
26187
+ if (message.event === "start") {
26188
+ if (startedSockets.has(key) || options.security.expectedAccountSid !== undefined && message.start.accountSid !== options.security.expectedAccountSid) {
26189
+ ws.close(4403, "invalid Twilio stream start");
26190
+ return;
26191
+ }
26192
+ startedSockets.add(key);
26193
+ } else if (message.event !== "connected" && !startedSockets.has(key)) {
26194
+ ws.close(4400, "Twilio stream must start before media");
26195
+ return;
26196
+ }
26117
26197
  let bridge = bridges.get(key);
26118
26198
  if (!bridge) {
26119
26199
  bridge = createTwilioMediaStreamBridge({
@@ -26206,7 +26286,7 @@ var createTwilioVoiceRoutes = (options) => {
26206
26286
  // src/telephony/plivo.ts
26207
26287
  var escapeXml3 = (value) => value.replaceAll("&", "&amp;").replaceAll('"', "&quot;").replaceAll("'", "&apos;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
26208
26288
  var joinUrlPath2 = (origin, path) => `${origin.replace(/\/$/, "")}${path.startsWith("/") ? path : `/${path}`}`;
26209
- var resolveRequestOrigin2 = (request) => {
26289
+ var resolveRequestOrigin = (request) => {
26210
26290
  const url = new URL(request.url);
26211
26291
  const forwardedHost = request.headers.get("x-forwarded-host");
26212
26292
  const forwardedProto = request.headers.get("x-forwarded-proto");
@@ -26235,7 +26315,7 @@ var resolvePlivoStreamUrl = async (options, input) => {
26235
26315
  if (typeof options.answer?.streamUrl === "string") {
26236
26316
  return options.answer.streamUrl;
26237
26317
  }
26238
- const origin = resolveRequestOrigin2(input.request);
26318
+ const origin = resolveRequestOrigin(input.request);
26239
26319
  const wsOrigin = origin.replace(/^http:/, "ws:").replace(/^https:/, "wss:");
26240
26320
  return `${wsOrigin}${input.streamPath}`;
26241
26321
  };
@@ -26604,7 +26684,7 @@ var createVoiceRedisPlivoWebhookNonceStore = (options = {}) => {
26604
26684
  };
26605
26685
  };
26606
26686
  var buildPlivoVoiceSetupStatus = async (options, input) => {
26607
- const origin = resolveRequestOrigin2(input.request);
26687
+ const origin = resolveRequestOrigin(input.request);
26608
26688
  const stream = await resolvePlivoStreamUrl(options, input);
26609
26689
  const answer = joinUrlPath2(origin, input.answerPath);
26610
26690
  const webhook = joinUrlPath2(origin, input.webhookPath);
@@ -26856,7 +26936,7 @@ import { Database as Database4 } from "bun:sqlite";
26856
26936
  import { Elysia as Elysia39 } from "elysia";
26857
26937
  var escapeXml4 = (value) => value.replaceAll("&", "&amp;").replaceAll('"', "&quot;").replaceAll("'", "&apos;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
26858
26938
  var joinUrlPath3 = (origin, path) => `${origin.replace(/\/$/, "")}${path.startsWith("/") ? path : `/${path}`}`;
26859
- var resolveRequestOrigin3 = (request) => {
26939
+ var resolveRequestOrigin2 = (request) => {
26860
26940
  const url = new URL(request.url);
26861
26941
  const forwardedHost = request.headers.get("x-forwarded-host");
26862
26942
  const forwardedProto = request.headers.get("x-forwarded-proto");
@@ -26878,7 +26958,7 @@ var resolveTelnyxStreamUrl = async (options, input) => {
26878
26958
  if (typeof options.texml?.streamUrl === "string") {
26879
26959
  return options.texml.streamUrl;
26880
26960
  }
26881
- const origin = resolveRequestOrigin3(input.request);
26961
+ const origin = resolveRequestOrigin2(input.request);
26882
26962
  const wsOrigin = origin.replace(/^http:/, "ws:").replace(/^https:/, "wss:");
26883
26963
  return `${wsOrigin}${input.streamPath}`;
26884
26964
  };
@@ -27218,7 +27298,7 @@ var createVoiceTelnyxWebhookVerifier = (options) => async (input) => {
27218
27298
  return verification;
27219
27299
  };
27220
27300
  var buildTelnyxVoiceSetupStatus = async (options, input) => {
27221
- const origin = resolveRequestOrigin3(input.request);
27301
+ const origin = resolveRequestOrigin2(input.request);
27222
27302
  const stream = await resolveTelnyxStreamUrl(options, input);
27223
27303
  const texml = joinUrlPath3(origin, input.texmlPath);
27224
27304
  const webhook = joinUrlPath3(origin, input.webhookPath);
@@ -225,6 +225,7 @@ export type TwilioVoiceSmokeOptions = {
225
225
  export type TwilioVoiceRoutesOptions<TContext = unknown, TSession extends VoiceSessionRecord = VoiceSessionRecord, TResult = unknown> = TwilioMediaStreamBridgeOptions<TContext, TSession, TResult> & {
226
226
  name?: string;
227
227
  outcomePolicy?: VoiceTelephonyOutcomePolicy;
228
+ security: TwilioVoiceSecurityOptions;
228
229
  smoke?: TwilioVoiceSmokeOptions;
229
230
  setup?: TwilioVoiceSetupOptions;
230
231
  streamPath?: string;
@@ -244,6 +245,16 @@ export type TwilioVoiceRoutesOptions<TContext = unknown, TSession extends VoiceS
244
245
  policy?: VoiceTelephonyOutcomePolicy;
245
246
  };
246
247
  };
248
+ export type TwilioVoiceSecurityOptions = {
249
+ /** Twilio account Auth Token used only to validate inbound signatures. */
250
+ authToken: string;
251
+ /** Optional account binding for TwiML requests and Media Stream start events. */
252
+ expectedAccountSid?: string;
253
+ /** Fixed public HTTPS origin configured in Twilio. Proxy headers are never trusted. */
254
+ publicOrigin: string;
255
+ /** Exact WebSocket URL Twilio signs when it differs from the generated stream URL. */
256
+ streamVerificationUrl?: string;
257
+ };
247
258
  export declare const decodeTwilioMulawBase64: (payload: string) => Int16Array<ArrayBuffer>;
248
259
  export declare const encodeTwilioMulawBase64: (samples: Int16Array) => string;
249
260
  export declare const transcodePCMToTwilioOutboundPayload: (chunk: Uint8Array, format: AudioFormat) => string;
@@ -15019,13 +15019,20 @@ var createVoiceTelephonyWebhookRoutes = (options = {}) => {
15019
15019
  var TWILIO_MULAW_SAMPLE_RATE = 8000;
15020
15020
  var VOICE_PCM_SAMPLE_RATE = 16000;
15021
15021
  var escapeXml2 = (value) => value.replaceAll("&", "&amp;").replaceAll('"', "&quot;").replaceAll("'", "&apos;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
15022
- var resolveRequestOrigin = (request) => {
15023
- const url = new URL(request.url);
15024
- const forwardedHost = request.headers.get("x-forwarded-host");
15025
- const forwardedProto = request.headers.get("x-forwarded-proto");
15026
- const host = forwardedHost ?? request.headers.get("host") ?? url.host;
15027
- const protocol = forwardedProto ?? url.protocol.replace(":", "");
15028
- return `${protocol}://${host}`;
15022
+ var TWILIO_ACCOUNT_SID = /^AC[0-9a-fA-F]{32}$/;
15023
+ var MAX_TWILIO_VOICE_BODY_BYTES = 64 * 1024;
15024
+ var resolveTwilioPublicOrigin = (security) => {
15025
+ if (security.authToken.trim().length === 0) {
15026
+ throw new Error("Twilio voice security.authToken must not be empty.");
15027
+ }
15028
+ if (security.expectedAccountSid !== undefined && !TWILIO_ACCOUNT_SID.test(security.expectedAccountSid)) {
15029
+ throw new Error("Twilio voice security.expectedAccountSid must be an AC SID.");
15030
+ }
15031
+ const origin = new URL(security.publicOrigin);
15032
+ if (origin.protocol !== "https:" || origin.pathname !== "/" || origin.search.length > 0 || origin.hash.length > 0) {
15033
+ throw new Error("Twilio voice security.publicOrigin must be an HTTPS origin without a path, query, or fragment.");
15034
+ }
15035
+ return origin.origin;
15029
15036
  };
15030
15037
  var resolveTwilioStreamUrl = async (options, input) => {
15031
15038
  if (typeof options.twiml?.streamUrl === "function") {
@@ -15034,10 +15041,61 @@ var resolveTwilioStreamUrl = async (options, input) => {
15034
15041
  if (typeof options.twiml?.streamUrl === "string") {
15035
15042
  return options.twiml.streamUrl;
15036
15043
  }
15037
- const origin = resolveRequestOrigin(input.request);
15044
+ const origin = resolveTwilioPublicOrigin(options.security);
15038
15045
  const wsOrigin = origin.replace(/^http:/, "ws:").replace(/^https:/, "wss:");
15039
15046
  return `${wsOrigin}${input.streamPath}`;
15040
15047
  };
15048
+ var resolveTrustedTwilioRequestUrl = (security, request) => {
15049
+ const requestUrl = new URL(request.url);
15050
+ return `${resolveTwilioPublicOrigin(security)}${requestUrl.pathname}${requestUrl.search}`;
15051
+ };
15052
+ var resolveTwilioSocketHeaders = (socket) => {
15053
+ if (!socket.data || typeof socket.data !== "object")
15054
+ return new Headers;
15055
+ const data = socket.data;
15056
+ for (const candidate of [data.request?.headers, data.headers]) {
15057
+ if (!candidate)
15058
+ continue;
15059
+ if (typeof candidate === "object" && "get" in candidate && typeof candidate.get === "function") {
15060
+ return candidate;
15061
+ }
15062
+ try {
15063
+ return new Headers(candidate);
15064
+ } catch {}
15065
+ }
15066
+ return new Headers;
15067
+ };
15068
+ var readTwilioVoiceForm = async (request) => {
15069
+ const declaredLength = Number(request.headers.get("content-length"));
15070
+ if (Number.isFinite(declaredLength) && declaredLength > MAX_TWILIO_VOICE_BODY_BYTES) {
15071
+ throw new Response("Twilio request body is too large.", { status: 413 });
15072
+ }
15073
+ const contentType = request.headers.get("content-type") ?? "";
15074
+ if (!contentType.includes("application/x-www-form-urlencoded")) {
15075
+ throw new Response("Expected a form-encoded Twilio request.", {
15076
+ status: 415
15077
+ });
15078
+ }
15079
+ const text = await request.text();
15080
+ if (Buffer3.byteLength(text, "utf8") > MAX_TWILIO_VOICE_BODY_BYTES) {
15081
+ throw new Response("Twilio request body is too large.", { status: 413 });
15082
+ }
15083
+ return Object.fromEntries(new URLSearchParams(text));
15084
+ };
15085
+ var verifyTwilioVoiceRequest = async (request, security) => {
15086
+ const body = request.method.toUpperCase() === "POST" ? await readTwilioVoiceForm(request.clone()) : {};
15087
+ const verification = await verifyVoiceTwilioWebhookSignature({
15088
+ authToken: security.authToken,
15089
+ body,
15090
+ headers: request.headers,
15091
+ url: resolveTrustedTwilioRequestUrl(security, request)
15092
+ });
15093
+ const requestUrl = new URL(request.url);
15094
+ const accountSid = typeof body.AccountSid === "string" ? body.AccountSid : requestUrl.searchParams.get("AccountSid");
15095
+ return {
15096
+ ok: verification.ok && (security.expectedAccountSid === undefined || accountSid === security.expectedAccountSid)
15097
+ };
15098
+ };
15041
15099
  var resolveTwilioStreamParameters = async (parameters, input) => {
15042
15100
  if (typeof parameters === "function") {
15043
15101
  return parameters(input);
@@ -15055,7 +15113,7 @@ var getWebhookVerificationUrl = (webhook, input) => {
15055
15113
  return webhook.verificationUrl;
15056
15114
  };
15057
15115
  var buildTwilioVoiceSetupStatus = async (options, input) => {
15058
- const origin = resolveRequestOrigin(input.request);
15116
+ const origin = resolveTwilioPublicOrigin(options.security);
15059
15117
  const stream = await resolveTwilioStreamUrl(options, input);
15060
15118
  const twiml = joinUrlPath(origin, input.twimlPath);
15061
15119
  const webhook = joinUrlPath(origin, input.webhookPath);
@@ -15139,8 +15197,13 @@ var runTwilioVoiceSmokeTest = async (input) => {
15139
15197
  const twimlUrl = new URL(setup.urls.twiml);
15140
15198
  twimlUrl.searchParams.set("scenarioId", input.options.smoke?.scenarioId ?? "smoke");
15141
15199
  twimlUrl.searchParams.set("sessionId", input.options.smoke?.sessionId ?? "smoke-session");
15200
+ const twimlHeaders = new Headers(input.request.headers);
15201
+ twimlHeaders.set("x-twilio-signature", await signVoiceTwilioWebhook({
15202
+ authToken: input.options.security.authToken,
15203
+ url: twimlUrl.toString()
15204
+ }));
15142
15205
  const twimlResponse = await input.app.handle(new Request(twimlUrl, {
15143
- headers: input.request.headers
15206
+ headers: twimlHeaders
15144
15207
  }));
15145
15208
  const twiml = await twimlResponse.text();
15146
15209
  const streamUrl = extractTwilioStreamUrl(twiml);
@@ -15729,45 +15792,32 @@ var createTwilioVoiceResponse = (options) => {
15729
15792
  return `<?xml version="1.0" encoding="UTF-8"?><Response><Connect><Stream url="${escapeXml2(options.streamUrl)}"${options.track ? ` track="${escapeXml2(options.track)}"` : ""}${options.streamName ? ` name="${escapeXml2(options.streamName)}"` : ""}>${parameters}</Stream></Connect></Response>`;
15730
15793
  };
15731
15794
  var createTwilioVoiceRoutes = (options) => {
15795
+ resolveTwilioPublicOrigin(options.security);
15732
15796
  const streamPath = options.streamPath ?? "/api/voice/twilio/stream";
15733
15797
  const twimlPath = options.twiml?.path ?? "/api/voice/twilio";
15734
15798
  const webhookPath = options.webhook?.path ?? "/api/voice/twilio/webhook";
15735
15799
  const setupPath = options.setup?.path === false ? false : options.setup?.path ?? "/api/voice/twilio/setup";
15736
15800
  const smokePath = options.smoke?.path === false ? false : options.smoke?.path ?? "/api/voice/twilio/smoke";
15737
15801
  const bridges = new WeakMap;
15802
+ const admittedSockets = new WeakSet;
15803
+ const startedSockets = new WeakSet;
15738
15804
  const webhookPolicy = options.webhook?.policy ?? options.outcomePolicy ?? createVoiceTelephonyOutcomePolicy();
15739
- const app = new Elysia6({
15740
- name: options.name ?? "absolutejs-voice-twilio"
15741
- }).get(twimlPath, async ({ query, request }) => {
15742
- const streamUrl = await resolveTwilioStreamUrl(options, {
15743
- query,
15744
- request,
15745
- streamPath
15746
- });
15747
- const parameters = await resolveTwilioStreamParameters(options.twiml?.parameters, {
15748
- query,
15749
- request
15750
- });
15751
- return new Response(createTwilioVoiceResponse({
15752
- parameters,
15753
- streamName: options.twiml?.streamName,
15754
- streamUrl,
15755
- track: options.twiml?.track
15756
- }), {
15757
- headers: {
15758
- "content-type": "text/xml; charset=utf-8"
15805
+ const renderTwiml = async (input) => {
15806
+ try {
15807
+ const verification = await verifyTwilioVoiceRequest(input.request, options.security);
15808
+ if (!verification.ok) {
15809
+ return new Response("Invalid Twilio signature.", { status: 403 });
15759
15810
  }
15760
- });
15761
- }).post(twimlPath, async ({ query, request }) => {
15811
+ } catch (error) {
15812
+ if (error instanceof Response)
15813
+ return error;
15814
+ throw error;
15815
+ }
15762
15816
  const streamUrl = await resolveTwilioStreamUrl(options, {
15763
- query,
15764
- request,
15817
+ ...input,
15765
15818
  streamPath
15766
15819
  });
15767
- const parameters = await resolveTwilioStreamParameters(options.twiml?.parameters, {
15768
- query,
15769
- request
15770
- });
15820
+ const parameters = await resolveTwilioStreamParameters(options.twiml?.parameters, input);
15771
15821
  return new Response(createTwilioVoiceResponse({
15772
15822
  parameters,
15773
15823
  streamName: options.twiml?.streamName,
@@ -15778,15 +15828,45 @@ var createTwilioVoiceRoutes = (options) => {
15778
15828
  "content-type": "text/xml; charset=utf-8"
15779
15829
  }
15780
15830
  });
15781
- }).ws(streamPath, {
15831
+ };
15832
+ const streamVerificationUrl = options.security.streamVerificationUrl ?? `${resolveTwilioPublicOrigin(options.security).replace(/^https:/, "wss:")}${streamPath}`;
15833
+ const app = new Elysia6({
15834
+ name: options.name ?? "absolutejs-voice-twilio"
15835
+ }).get(twimlPath, renderTwiml).post(twimlPath, renderTwiml, { parse: "none" }).ws(streamPath, {
15782
15836
  close: async (ws, _code, reason) => {
15783
15837
  const key = ws.raw ?? ws;
15784
15838
  const bridge = bridges.get(key);
15785
15839
  bridges.delete(key);
15840
+ admittedSockets.delete(key);
15841
+ startedSockets.delete(key);
15786
15842
  await bridge?.close(reason);
15787
15843
  },
15788
15844
  message: async (ws, raw) => {
15789
15845
  const key = ws.raw ?? ws;
15846
+ if (!admittedSockets.has(key)) {
15847
+ const verification = await verifyVoiceTwilioWebhookSignature({
15848
+ authToken: options.security.authToken,
15849
+ body: {},
15850
+ headers: resolveTwilioSocketHeaders(ws),
15851
+ url: streamVerificationUrl
15852
+ });
15853
+ if (!verification.ok) {
15854
+ ws.close(4403, "invalid Twilio signature");
15855
+ return;
15856
+ }
15857
+ admittedSockets.add(key);
15858
+ }
15859
+ const message = parseTwilioMessage(raw);
15860
+ if (message.event === "start") {
15861
+ if (startedSockets.has(key) || options.security.expectedAccountSid !== undefined && message.start.accountSid !== options.security.expectedAccountSid) {
15862
+ ws.close(4403, "invalid Twilio stream start");
15863
+ return;
15864
+ }
15865
+ startedSockets.add(key);
15866
+ } else if (message.event !== "connected" && !startedSockets.has(key)) {
15867
+ ws.close(4400, "Twilio stream must start before media");
15868
+ return;
15869
+ }
15790
15870
  let bridge = bridges.get(key);
15791
15871
  if (!bridge) {
15792
15872
  bridge = createTwilioMediaStreamBridge({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@absolutejs/voice",
3
- "version": "0.0.22-beta.663",
3
+ "version": "0.0.22-beta.664",
4
4
  "description": "Voice primitives and Elysia plugin for AbsoluteJS",
5
5
  "repository": {
6
6
  "type": "git",