@absolutejs/voice 0.0.22-beta.662 → 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:
@@ -905,6 +905,10 @@ export type VoicePluginConfig<TContext = unknown, TSession extends VoiceSessionR
905
905
  }) => string | Promise<string>);
906
906
  stuckCallClose?: {
907
907
  afterMs: number;
908
+ /** Whether this fault-driven close invokes the route completion hook.
909
+ * Defaults to true for backward compatibility. Set false when an
910
+ * interrupted conversation must remain resumable. */
911
+ invokeOnComplete?: boolean;
908
912
  line?: string | ((input: {
909
913
  session: TSession;
910
914
  }) => string | Promise<string>);
@@ -1091,6 +1095,9 @@ export type CreateVoiceSessionOptions<TContext = unknown, TSession extends Voice
1091
1095
  * assistant's own speech, so STT recovery re-prompts can't defer it forever. */
1092
1096
  stuckCallClose?: {
1093
1097
  afterMs: number;
1098
+ /** Whether this fault-driven close invokes the route completion hook.
1099
+ * Defaults to true for backward compatibility. */
1100
+ invokeOnComplete?: boolean;
1094
1101
  line?: string | ((input: {
1095
1102
  session: TSession;
1096
1103
  }) => string | Promise<string>);
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
@@ -4452,6 +4452,7 @@ var createVoiceSession = (options) => {
4452
4452
  }
4453
4453
  await completeInternal(undefined, {
4454
4454
  disposition: "completed",
4455
+ invokeOnComplete: stuckCloseConfig?.invokeOnComplete,
4455
4456
  reason: stuckCloseConfig?.reason ?? "stuck-call-close"
4456
4457
  });
4457
4458
  });
@@ -25345,13 +25346,20 @@ import { Elysia as Elysia37 } from "elysia";
25345
25346
  var TWILIO_MULAW_SAMPLE_RATE = 8000;
25346
25347
  var VOICE_PCM_SAMPLE_RATE = 16000;
25347
25348
  var escapeXml2 = (value) => value.replaceAll("&", "&amp;").replaceAll('"', "&quot;").replaceAll("'", "&apos;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
25348
- var resolveRequestOrigin = (request) => {
25349
- const url = new URL(request.url);
25350
- const forwardedHost = request.headers.get("x-forwarded-host");
25351
- const forwardedProto = request.headers.get("x-forwarded-proto");
25352
- const host = forwardedHost ?? request.headers.get("host") ?? url.host;
25353
- const protocol = forwardedProto ?? url.protocol.replace(":", "");
25354
- 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;
25355
25363
  };
25356
25364
  var resolveTwilioStreamUrl = async (options, input) => {
25357
25365
  if (typeof options.twiml?.streamUrl === "function") {
@@ -25360,10 +25368,61 @@ var resolveTwilioStreamUrl = async (options, input) => {
25360
25368
  if (typeof options.twiml?.streamUrl === "string") {
25361
25369
  return options.twiml.streamUrl;
25362
25370
  }
25363
- const origin = resolveRequestOrigin(input.request);
25371
+ const origin = resolveTwilioPublicOrigin(options.security);
25364
25372
  const wsOrigin = origin.replace(/^http:/, "ws:").replace(/^https:/, "wss:");
25365
25373
  return `${wsOrigin}${input.streamPath}`;
25366
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
+ };
25367
25426
  var resolveTwilioStreamParameters = async (parameters, input) => {
25368
25427
  if (typeof parameters === "function") {
25369
25428
  return parameters(input);
@@ -25381,7 +25440,7 @@ var getWebhookVerificationUrl = (webhook, input) => {
25381
25440
  return webhook.verificationUrl;
25382
25441
  };
25383
25442
  var buildTwilioVoiceSetupStatus = async (options, input) => {
25384
- const origin = resolveRequestOrigin(input.request);
25443
+ const origin = resolveTwilioPublicOrigin(options.security);
25385
25444
  const stream = await resolveTwilioStreamUrl(options, input);
25386
25445
  const twiml = joinUrlPath(origin, input.twimlPath);
25387
25446
  const webhook = joinUrlPath(origin, input.webhookPath);
@@ -25465,8 +25524,13 @@ var runTwilioVoiceSmokeTest = async (input) => {
25465
25524
  const twimlUrl = new URL(setup.urls.twiml);
25466
25525
  twimlUrl.searchParams.set("scenarioId", input.options.smoke?.scenarioId ?? "smoke");
25467
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
+ }));
25468
25532
  const twimlResponse = await input.app.handle(new Request(twimlUrl, {
25469
- headers: input.request.headers
25533
+ headers: twimlHeaders
25470
25534
  }));
25471
25535
  const twiml = await twimlResponse.text();
25472
25536
  const streamUrl = extractTwilioStreamUrl(twiml);
@@ -26055,45 +26119,32 @@ var createTwilioVoiceResponse = (options) => {
26055
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>`;
26056
26120
  };
26057
26121
  var createTwilioVoiceRoutes = (options) => {
26122
+ resolveTwilioPublicOrigin(options.security);
26058
26123
  const streamPath = options.streamPath ?? "/api/voice/twilio/stream";
26059
26124
  const twimlPath = options.twiml?.path ?? "/api/voice/twilio";
26060
26125
  const webhookPath = options.webhook?.path ?? "/api/voice/twilio/webhook";
26061
26126
  const setupPath = options.setup?.path === false ? false : options.setup?.path ?? "/api/voice/twilio/setup";
26062
26127
  const smokePath = options.smoke?.path === false ? false : options.smoke?.path ?? "/api/voice/twilio/smoke";
26063
26128
  const bridges = new WeakMap;
26129
+ const admittedSockets = new WeakSet;
26130
+ const startedSockets = new WeakSet;
26064
26131
  const webhookPolicy = options.webhook?.policy ?? options.outcomePolicy ?? createVoiceTelephonyOutcomePolicy();
26065
- const app = new Elysia37({
26066
- name: options.name ?? "absolutejs-voice-twilio"
26067
- }).get(twimlPath, async ({ query, request }) => {
26068
- const streamUrl = await resolveTwilioStreamUrl(options, {
26069
- query,
26070
- request,
26071
- streamPath
26072
- });
26073
- const parameters = await resolveTwilioStreamParameters(options.twiml?.parameters, {
26074
- query,
26075
- request
26076
- });
26077
- return new Response(createTwilioVoiceResponse({
26078
- parameters,
26079
- streamName: options.twiml?.streamName,
26080
- streamUrl,
26081
- track: options.twiml?.track
26082
- }), {
26083
- headers: {
26084
- "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 });
26085
26137
  }
26086
- });
26087
- }).post(twimlPath, async ({ query, request }) => {
26138
+ } catch (error) {
26139
+ if (error instanceof Response)
26140
+ return error;
26141
+ throw error;
26142
+ }
26088
26143
  const streamUrl = await resolveTwilioStreamUrl(options, {
26089
- query,
26090
- request,
26144
+ ...input,
26091
26145
  streamPath
26092
26146
  });
26093
- const parameters = await resolveTwilioStreamParameters(options.twiml?.parameters, {
26094
- query,
26095
- request
26096
- });
26147
+ const parameters = await resolveTwilioStreamParameters(options.twiml?.parameters, input);
26097
26148
  return new Response(createTwilioVoiceResponse({
26098
26149
  parameters,
26099
26150
  streamName: options.twiml?.streamName,
@@ -26104,15 +26155,45 @@ var createTwilioVoiceRoutes = (options) => {
26104
26155
  "content-type": "text/xml; charset=utf-8"
26105
26156
  }
26106
26157
  });
26107
- }).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, {
26108
26163
  close: async (ws, _code, reason) => {
26109
26164
  const key = ws.raw ?? ws;
26110
26165
  const bridge = bridges.get(key);
26111
26166
  bridges.delete(key);
26167
+ admittedSockets.delete(key);
26168
+ startedSockets.delete(key);
26112
26169
  await bridge?.close(reason);
26113
26170
  },
26114
26171
  message: async (ws, raw) => {
26115
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
+ }
26116
26197
  let bridge = bridges.get(key);
26117
26198
  if (!bridge) {
26118
26199
  bridge = createTwilioMediaStreamBridge({
@@ -26205,7 +26286,7 @@ var createTwilioVoiceRoutes = (options) => {
26205
26286
  // src/telephony/plivo.ts
26206
26287
  var escapeXml3 = (value) => value.replaceAll("&", "&amp;").replaceAll('"', "&quot;").replaceAll("'", "&apos;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
26207
26288
  var joinUrlPath2 = (origin, path) => `${origin.replace(/\/$/, "")}${path.startsWith("/") ? path : `/${path}`}`;
26208
- var resolveRequestOrigin2 = (request) => {
26289
+ var resolveRequestOrigin = (request) => {
26209
26290
  const url = new URL(request.url);
26210
26291
  const forwardedHost = request.headers.get("x-forwarded-host");
26211
26292
  const forwardedProto = request.headers.get("x-forwarded-proto");
@@ -26234,7 +26315,7 @@ var resolvePlivoStreamUrl = async (options, input) => {
26234
26315
  if (typeof options.answer?.streamUrl === "string") {
26235
26316
  return options.answer.streamUrl;
26236
26317
  }
26237
- const origin = resolveRequestOrigin2(input.request);
26318
+ const origin = resolveRequestOrigin(input.request);
26238
26319
  const wsOrigin = origin.replace(/^http:/, "ws:").replace(/^https:/, "wss:");
26239
26320
  return `${wsOrigin}${input.streamPath}`;
26240
26321
  };
@@ -26603,7 +26684,7 @@ var createVoiceRedisPlivoWebhookNonceStore = (options = {}) => {
26603
26684
  };
26604
26685
  };
26605
26686
  var buildPlivoVoiceSetupStatus = async (options, input) => {
26606
- const origin = resolveRequestOrigin2(input.request);
26687
+ const origin = resolveRequestOrigin(input.request);
26607
26688
  const stream = await resolvePlivoStreamUrl(options, input);
26608
26689
  const answer = joinUrlPath2(origin, input.answerPath);
26609
26690
  const webhook = joinUrlPath2(origin, input.webhookPath);
@@ -26855,7 +26936,7 @@ import { Database as Database4 } from "bun:sqlite";
26855
26936
  import { Elysia as Elysia39 } from "elysia";
26856
26937
  var escapeXml4 = (value) => value.replaceAll("&", "&amp;").replaceAll('"', "&quot;").replaceAll("'", "&apos;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
26857
26938
  var joinUrlPath3 = (origin, path) => `${origin.replace(/\/$/, "")}${path.startsWith("/") ? path : `/${path}`}`;
26858
- var resolveRequestOrigin3 = (request) => {
26939
+ var resolveRequestOrigin2 = (request) => {
26859
26940
  const url = new URL(request.url);
26860
26941
  const forwardedHost = request.headers.get("x-forwarded-host");
26861
26942
  const forwardedProto = request.headers.get("x-forwarded-proto");
@@ -26877,7 +26958,7 @@ var resolveTelnyxStreamUrl = async (options, input) => {
26877
26958
  if (typeof options.texml?.streamUrl === "string") {
26878
26959
  return options.texml.streamUrl;
26879
26960
  }
26880
- const origin = resolveRequestOrigin3(input.request);
26961
+ const origin = resolveRequestOrigin2(input.request);
26881
26962
  const wsOrigin = origin.replace(/^http:/, "ws:").replace(/^https:/, "wss:");
26882
26963
  return `${wsOrigin}${input.streamPath}`;
26883
26964
  };
@@ -27217,7 +27298,7 @@ var createVoiceTelnyxWebhookVerifier = (options) => async (input) => {
27217
27298
  return verification;
27218
27299
  };
27219
27300
  var buildTelnyxVoiceSetupStatus = async (options, input) => {
27220
- const origin = resolveRequestOrigin3(input.request);
27301
+ const origin = resolveRequestOrigin2(input.request);
27221
27302
  const stream = await resolveTelnyxStreamUrl(options, input);
27222
27303
  const texml = joinUrlPath3(origin, input.texmlPath);
27223
27304
  const webhook = joinUrlPath3(origin, input.webhookPath);
@@ -40713,6 +40794,18 @@ var voice = (config) => {
40713
40794
  if (message.action === "resume") {
40714
40795
  await current.resume();
40715
40796
  }
40797
+ if (message.action === "pause" || message.action === "resume") {
40798
+ const snapshot = await current.snapshot();
40799
+ ws.send(JSON.stringify({
40800
+ pauseExpiresAt: snapshot.pause?.expiresAt,
40801
+ paused: Boolean(snapshot.pause),
40802
+ sessionId: snapshot.id,
40803
+ status: snapshot.status,
40804
+ sessionMetadata: snapshot.metadata,
40805
+ scenarioId: snapshot.scenarioId,
40806
+ type: "session"
40807
+ }));
40808
+ }
40716
40809
  acknowledge(true);
40717
40810
  } catch (error) {
40718
40811
  acknowledge(false, error instanceof Error ? error.message : String(error));
@@ -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;
@@ -7404,6 +7404,7 @@ var createVoiceSession = (options) => {
7404
7404
  }
7405
7405
  await completeInternal(undefined, {
7406
7406
  disposition: "completed",
7407
+ invokeOnComplete: stuckCloseConfig?.invokeOnComplete,
7407
7408
  reason: stuckCloseConfig?.reason ?? "stuck-call-close"
7408
7409
  });
7409
7410
  });
@@ -15018,13 +15019,20 @@ var createVoiceTelephonyWebhookRoutes = (options = {}) => {
15018
15019
  var TWILIO_MULAW_SAMPLE_RATE = 8000;
15019
15020
  var VOICE_PCM_SAMPLE_RATE = 16000;
15020
15021
  var escapeXml2 = (value) => value.replaceAll("&", "&amp;").replaceAll('"', "&quot;").replaceAll("'", "&apos;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
15021
- var resolveRequestOrigin = (request) => {
15022
- const url = new URL(request.url);
15023
- const forwardedHost = request.headers.get("x-forwarded-host");
15024
- const forwardedProto = request.headers.get("x-forwarded-proto");
15025
- const host = forwardedHost ?? request.headers.get("host") ?? url.host;
15026
- const protocol = forwardedProto ?? url.protocol.replace(":", "");
15027
- 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;
15028
15036
  };
15029
15037
  var resolveTwilioStreamUrl = async (options, input) => {
15030
15038
  if (typeof options.twiml?.streamUrl === "function") {
@@ -15033,10 +15041,61 @@ var resolveTwilioStreamUrl = async (options, input) => {
15033
15041
  if (typeof options.twiml?.streamUrl === "string") {
15034
15042
  return options.twiml.streamUrl;
15035
15043
  }
15036
- const origin = resolveRequestOrigin(input.request);
15044
+ const origin = resolveTwilioPublicOrigin(options.security);
15037
15045
  const wsOrigin = origin.replace(/^http:/, "ws:").replace(/^https:/, "wss:");
15038
15046
  return `${wsOrigin}${input.streamPath}`;
15039
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
+ };
15040
15099
  var resolveTwilioStreamParameters = async (parameters, input) => {
15041
15100
  if (typeof parameters === "function") {
15042
15101
  return parameters(input);
@@ -15054,7 +15113,7 @@ var getWebhookVerificationUrl = (webhook, input) => {
15054
15113
  return webhook.verificationUrl;
15055
15114
  };
15056
15115
  var buildTwilioVoiceSetupStatus = async (options, input) => {
15057
- const origin = resolveRequestOrigin(input.request);
15116
+ const origin = resolveTwilioPublicOrigin(options.security);
15058
15117
  const stream = await resolveTwilioStreamUrl(options, input);
15059
15118
  const twiml = joinUrlPath(origin, input.twimlPath);
15060
15119
  const webhook = joinUrlPath(origin, input.webhookPath);
@@ -15138,8 +15197,13 @@ var runTwilioVoiceSmokeTest = async (input) => {
15138
15197
  const twimlUrl = new URL(setup.urls.twiml);
15139
15198
  twimlUrl.searchParams.set("scenarioId", input.options.smoke?.scenarioId ?? "smoke");
15140
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
+ }));
15141
15205
  const twimlResponse = await input.app.handle(new Request(twimlUrl, {
15142
- headers: input.request.headers
15206
+ headers: twimlHeaders
15143
15207
  }));
15144
15208
  const twiml = await twimlResponse.text();
15145
15209
  const streamUrl = extractTwilioStreamUrl(twiml);
@@ -15728,45 +15792,32 @@ var createTwilioVoiceResponse = (options) => {
15728
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>`;
15729
15793
  };
15730
15794
  var createTwilioVoiceRoutes = (options) => {
15795
+ resolveTwilioPublicOrigin(options.security);
15731
15796
  const streamPath = options.streamPath ?? "/api/voice/twilio/stream";
15732
15797
  const twimlPath = options.twiml?.path ?? "/api/voice/twilio";
15733
15798
  const webhookPath = options.webhook?.path ?? "/api/voice/twilio/webhook";
15734
15799
  const setupPath = options.setup?.path === false ? false : options.setup?.path ?? "/api/voice/twilio/setup";
15735
15800
  const smokePath = options.smoke?.path === false ? false : options.smoke?.path ?? "/api/voice/twilio/smoke";
15736
15801
  const bridges = new WeakMap;
15802
+ const admittedSockets = new WeakSet;
15803
+ const startedSockets = new WeakSet;
15737
15804
  const webhookPolicy = options.webhook?.policy ?? options.outcomePolicy ?? createVoiceTelephonyOutcomePolicy();
15738
- const app = new Elysia6({
15739
- name: options.name ?? "absolutejs-voice-twilio"
15740
- }).get(twimlPath, async ({ query, request }) => {
15741
- const streamUrl = await resolveTwilioStreamUrl(options, {
15742
- query,
15743
- request,
15744
- streamPath
15745
- });
15746
- const parameters = await resolveTwilioStreamParameters(options.twiml?.parameters, {
15747
- query,
15748
- request
15749
- });
15750
- return new Response(createTwilioVoiceResponse({
15751
- parameters,
15752
- streamName: options.twiml?.streamName,
15753
- streamUrl,
15754
- track: options.twiml?.track
15755
- }), {
15756
- headers: {
15757
- "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 });
15758
15810
  }
15759
- });
15760
- }).post(twimlPath, async ({ query, request }) => {
15811
+ } catch (error) {
15812
+ if (error instanceof Response)
15813
+ return error;
15814
+ throw error;
15815
+ }
15761
15816
  const streamUrl = await resolveTwilioStreamUrl(options, {
15762
- query,
15763
- request,
15817
+ ...input,
15764
15818
  streamPath
15765
15819
  });
15766
- const parameters = await resolveTwilioStreamParameters(options.twiml?.parameters, {
15767
- query,
15768
- request
15769
- });
15820
+ const parameters = await resolveTwilioStreamParameters(options.twiml?.parameters, input);
15770
15821
  return new Response(createTwilioVoiceResponse({
15771
15822
  parameters,
15772
15823
  streamName: options.twiml?.streamName,
@@ -15777,15 +15828,45 @@ var createTwilioVoiceRoutes = (options) => {
15777
15828
  "content-type": "text/xml; charset=utf-8"
15778
15829
  }
15779
15830
  });
15780
- }).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, {
15781
15836
  close: async (ws, _code, reason) => {
15782
15837
  const key = ws.raw ?? ws;
15783
15838
  const bridge = bridges.get(key);
15784
15839
  bridges.delete(key);
15840
+ admittedSockets.delete(key);
15841
+ startedSockets.delete(key);
15785
15842
  await bridge?.close(reason);
15786
15843
  },
15787
15844
  message: async (ws, raw) => {
15788
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
+ }
15789
15870
  let bridge = bridges.get(key);
15790
15871
  if (!bridge) {
15791
15872
  bridge = createTwilioMediaStreamBridge({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@absolutejs/voice",
3
- "version": "0.0.22-beta.662",
3
+ "version": "0.0.22-beta.664",
4
4
  "description": "Voice primitives and Elysia plugin for AbsoluteJS",
5
5
  "repository": {
6
6
  "type": "git",