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

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
  };
@@ -26413,10 +26493,6 @@ var createMemoryVoicePlivoWebhookNonceStore = () => {
26413
26493
  }
26414
26494
  nonces.add(nonce);
26415
26495
  return true;
26416
- },
26417
- has: (nonce) => nonces.has(nonce),
26418
- set: (nonce) => {
26419
- nonces.add(nonce);
26420
26496
  }
26421
26497
  };
26422
26498
  };
@@ -26464,20 +26540,13 @@ var createVoiceSQLitePlivoWebhookNonceStore = (options) => {
26464
26540
  expires_at INTEGER
26465
26541
  )`);
26466
26542
  const pruneExpired = database.query(`DELETE FROM "${tableName}" WHERE expires_at IS NOT NULL AND expires_at <= ?1`);
26467
- const select = database.query(`SELECT nonce FROM "${tableName}" WHERE nonce = ?1 AND (expires_at IS NULL OR expires_at > ?2) LIMIT 1`);
26468
26543
  const insert = database.query(`INSERT OR IGNORE INTO "${tableName}" (nonce, created_at, expires_at) VALUES (?1, ?2, ?3)`);
26469
- const upsert = database.query(`INSERT INTO "${tableName}" (nonce, created_at, expires_at) VALUES (?1, ?2, ?3)
26470
- ON CONFLICT(nonce) DO UPDATE SET expires_at = excluded.expires_at`);
26471
26544
  return {
26472
26545
  claim: (nonce) => {
26473
26546
  const now = Date.now();
26474
26547
  pruneExpired.run(now);
26475
26548
  const result = insert.run(nonce, now, getPlivoNonceExpiresAt(options.ttlSeconds));
26476
26549
  return result.changes > 0;
26477
- },
26478
- has: (nonce) => Boolean(select.get(nonce, Date.now())),
26479
- set: (nonce) => {
26480
- upsert.run(nonce, Date.now(), getPlivoNonceExpiresAt(options.ttlSeconds));
26481
26550
  }
26482
26551
  };
26483
26552
  };
@@ -26531,21 +26600,6 @@ var createVoicePostgresPlivoWebhookNonceStore = (options = {}) => {
26531
26600
  ON CONFLICT (nonce) DO NOTHING
26532
26601
  RETURNING nonce`, [nonce, Date.now(), getPlivoNonceExpiresAt(options.ttlSeconds)]);
26533
26602
  return rows.length > 0;
26534
- },
26535
- has: async (nonce) => {
26536
- await initialized;
26537
- const sql = await client;
26538
- const rows = await sql.unsafe(`SELECT nonce FROM ${qualifiedTableName}
26539
- WHERE nonce = $1 AND (expires_at IS NULL OR expires_at > $2)
26540
- LIMIT 1`, [nonce, Date.now()]);
26541
- return rows.length > 0;
26542
- },
26543
- set: async (nonce) => {
26544
- await initialized;
26545
- const sql = await client;
26546
- await sql.unsafe(`INSERT INTO ${qualifiedTableName} (nonce, created_at, expires_at)
26547
- VALUES ($1, $2, $3)
26548
- ON CONFLICT (nonce) DO UPDATE SET expires_at = EXCLUDED.expires_at`, [nonce, Date.now(), getPlivoNonceExpiresAt(options.ttlSeconds)]);
26549
26603
  }
26550
26604
  };
26551
26605
  };
@@ -26572,16 +26626,9 @@ var createVoicePlivoWebhookVerifier = (options) => async (input) => {
26572
26626
  if (!nonce) {
26573
26627
  return { ok: false, reason: "invalid-signature" };
26574
26628
  }
26575
- if (nonceStore.claim) {
26576
- if (!await nonceStore.claim(nonce)) {
26577
- return { ok: false, reason: "invalid-signature" };
26578
- }
26579
- return verification;
26580
- }
26581
- if (await nonceStore.has(nonce)) {
26629
+ if (!await nonceStore.claim(nonce)) {
26582
26630
  return { ok: false, reason: "invalid-signature" };
26583
26631
  }
26584
- await nonceStore.set(nonce);
26585
26632
  return verification;
26586
26633
  };
26587
26634
  var createVoiceRedisPlivoWebhookNonceStore = (options = {}) => {
@@ -26596,15 +26643,11 @@ var createVoiceRedisPlivoWebhookNonceStore = (options = {}) => {
26596
26643
  return client.set(key, "1", ...nx ? ["NX"] : []);
26597
26644
  };
26598
26645
  return {
26599
- claim: async (nonce) => await setNonce(nonce, true) === "OK",
26600
- has: async (nonce) => Boolean(await client.exists(getPlivoRedisNonceKey(keyPrefix, nonce))),
26601
- set: async (nonce) => {
26602
- await setNonce(nonce, false);
26603
- }
26646
+ claim: async (nonce) => await setNonce(nonce, true) === "OK"
26604
26647
  };
26605
26648
  };
26606
26649
  var buildPlivoVoiceSetupStatus = async (options, input) => {
26607
- const origin = resolveRequestOrigin2(input.request);
26650
+ const origin = resolveRequestOrigin(input.request);
26608
26651
  const stream = await resolvePlivoStreamUrl(options, input);
26609
26652
  const answer = joinUrlPath2(origin, input.answerPath);
26610
26653
  const webhook = joinUrlPath2(origin, input.webhookPath);
@@ -26856,7 +26899,7 @@ import { Database as Database4 } from "bun:sqlite";
26856
26899
  import { Elysia as Elysia39 } from "elysia";
26857
26900
  var escapeXml4 = (value) => value.replaceAll("&", "&amp;").replaceAll('"', "&quot;").replaceAll("'", "&apos;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
26858
26901
  var joinUrlPath3 = (origin, path) => `${origin.replace(/\/$/, "")}${path.startsWith("/") ? path : `/${path}`}`;
26859
- var resolveRequestOrigin3 = (request) => {
26902
+ var resolveRequestOrigin2 = (request) => {
26860
26903
  const url = new URL(request.url);
26861
26904
  const forwardedHost = request.headers.get("x-forwarded-host");
26862
26905
  const forwardedProto = request.headers.get("x-forwarded-proto");
@@ -26878,7 +26921,7 @@ var resolveTelnyxStreamUrl = async (options, input) => {
26878
26921
  if (typeof options.texml?.streamUrl === "string") {
26879
26922
  return options.texml.streamUrl;
26880
26923
  }
26881
- const origin = resolveRequestOrigin3(input.request);
26924
+ const origin = resolveRequestOrigin2(input.request);
26882
26925
  const wsOrigin = origin.replace(/^http:/, "ws:").replace(/^https:/, "wss:");
26883
26926
  return `${wsOrigin}${input.streamPath}`;
26884
26927
  };
@@ -27012,10 +27055,6 @@ var createMemoryVoiceTelnyxWebhookEventStore = () => {
27012
27055
  }
27013
27056
  eventIds.add(eventId);
27014
27057
  return true;
27015
- },
27016
- has: (eventId) => eventIds.has(eventId),
27017
- set: (eventId) => {
27018
- eventIds.add(eventId);
27019
27058
  }
27020
27059
  };
27021
27060
  };
@@ -27065,20 +27104,13 @@ var createVoiceSQLiteTelnyxWebhookEventStore = (options) => {
27065
27104
  expires_at INTEGER
27066
27105
  )`);
27067
27106
  const pruneExpired = database.query(`DELETE FROM "${tableName}" WHERE expires_at IS NOT NULL AND expires_at <= ?1`);
27068
- const select = database.query(`SELECT event_id FROM "${tableName}" WHERE event_id = ?1 AND (expires_at IS NULL OR expires_at > ?2) LIMIT 1`);
27069
27107
  const insert = database.query(`INSERT OR IGNORE INTO "${tableName}" (event_id, created_at, expires_at) VALUES (?1, ?2, ?3)`);
27070
- const upsert = database.query(`INSERT INTO "${tableName}" (event_id, created_at, expires_at) VALUES (?1, ?2, ?3)
27071
- ON CONFLICT(event_id) DO UPDATE SET expires_at = excluded.expires_at`);
27072
27108
  return {
27073
27109
  claim: (eventId) => {
27074
27110
  const now = Date.now();
27075
27111
  pruneExpired.run(now);
27076
27112
  const result = insert.run(eventId, now, getTelnyxEventExpiresAt(options.ttlSeconds));
27077
27113
  return result.changes > 0;
27078
- },
27079
- has: (eventId) => Boolean(select.get(eventId, Date.now())),
27080
- set: (eventId) => {
27081
- upsert.run(eventId, Date.now(), getTelnyxEventExpiresAt(options.ttlSeconds));
27082
27114
  }
27083
27115
  };
27084
27116
  };
@@ -27132,21 +27164,6 @@ var createVoicePostgresTelnyxWebhookEventStore = (options = {}) => {
27132
27164
  ON CONFLICT (event_id) DO NOTHING
27133
27165
  RETURNING event_id`, [eventId, Date.now(), getTelnyxEventExpiresAt(options.ttlSeconds)]);
27134
27166
  return rows.length > 0;
27135
- },
27136
- has: async (eventId) => {
27137
- await initialized;
27138
- const sql = await client;
27139
- const rows = await sql.unsafe(`SELECT event_id FROM ${qualifiedTableName}
27140
- WHERE event_id = $1 AND (expires_at IS NULL OR expires_at > $2)
27141
- LIMIT 1`, [eventId, Date.now()]);
27142
- return rows.length > 0;
27143
- },
27144
- set: async (eventId) => {
27145
- await initialized;
27146
- const sql = await client;
27147
- await sql.unsafe(`INSERT INTO ${qualifiedTableName} (event_id, created_at, expires_at)
27148
- VALUES ($1, $2, $3)
27149
- ON CONFLICT (event_id) DO UPDATE SET expires_at = EXCLUDED.expires_at`, [eventId, Date.now(), getTelnyxEventExpiresAt(options.ttlSeconds)]);
27150
27167
  }
27151
27168
  };
27152
27169
  };
@@ -27163,11 +27180,7 @@ var createVoiceRedisTelnyxWebhookEventStore = (options = {}) => {
27163
27180
  return client.set(key, "1", ...nx ? ["NX"] : []);
27164
27181
  };
27165
27182
  return {
27166
- claim: async (eventId) => await setEvent(eventId, true) === "OK",
27167
- has: async (eventId) => Boolean(await client.exists(getTelnyxRedisEventKey(keyPrefix, eventId))),
27168
- set: async (eventId) => {
27169
- await setEvent(eventId, false);
27170
- }
27183
+ claim: async (eventId) => await setEvent(eventId, true) === "OK"
27171
27184
  };
27172
27185
  };
27173
27186
  var readTelnyxWebhookEventId = (rawBody) => {
@@ -27208,17 +27221,10 @@ var createVoiceTelnyxWebhookVerifier = (options) => async (input) => {
27208
27221
  if (!eventId) {
27209
27222
  return { ok: false, reason: "invalid-signature" };
27210
27223
  }
27211
- if (eventStore.claim) {
27212
- return await eventStore.claim(eventId) ? verification : { ok: false, reason: "invalid-signature" };
27213
- }
27214
- if (await eventStore.has(eventId)) {
27215
- return { ok: false, reason: "invalid-signature" };
27216
- }
27217
- await eventStore.set(eventId);
27218
- return verification;
27224
+ return await eventStore.claim(eventId) ? verification : { ok: false, reason: "invalid-signature" };
27219
27225
  };
27220
27226
  var buildTelnyxVoiceSetupStatus = async (options, input) => {
27221
- const origin = resolveRequestOrigin3(input.request);
27227
+ const origin = resolveRequestOrigin2(input.request);
27222
27228
  const stream = await resolveTelnyxStreamUrl(options, input);
27223
27229
  const texml = joinUrlPath3(origin, input.texmlPath);
27224
27230
  const webhook = joinUrlPath3(origin, input.webhookPath);
@@ -135,9 +135,8 @@ export type PlivoVoiceSmokeOptions = {
135
135
  title?: string;
136
136
  };
137
137
  export type VoicePlivoWebhookNonceStore = {
138
- claim?: (nonce: string) => Promise<boolean> | boolean;
139
- has: (nonce: string) => Promise<boolean> | boolean;
140
- set: (nonce: string) => Promise<void> | void;
138
+ /** Atomically accepts an unseen signature nonce and rejects every replay. */
139
+ claim: (nonce: string) => Promise<boolean> | boolean;
141
140
  };
142
141
  export type VoicePlivoWebhookNonceStoreOptions = {
143
142
  ttlSeconds?: number;
@@ -155,7 +154,7 @@ export type VoicePostgresPlivoWebhookNonceStoreOptions = VoicePlivoWebhookNonceS
155
154
  tableName?: string;
156
155
  tablePrefix?: string;
157
156
  };
158
- export type VoiceRedisPlivoWebhookNonceClient = Pick<RedisClient, "exists" | "set">;
157
+ export type VoiceRedisPlivoWebhookNonceClient = Pick<RedisClient, "set">;
159
158
  export type VoiceRedisPlivoWebhookNonceStoreOptions = VoicePlivoWebhookNonceStoreOptions & {
160
159
  client?: VoiceRedisPlivoWebhookNonceClient;
161
160
  keyPrefix?: string;
@@ -162,9 +162,8 @@ export type TelnyxVoiceRoutesOptions<TContext = unknown, TSession extends VoiceS
162
162
  };
163
163
  };
164
164
  export type VoiceTelnyxWebhookEventStore = {
165
- claim?: (eventId: string) => Promise<boolean> | boolean;
166
- has: (eventId: string) => Promise<boolean> | boolean;
167
- set: (eventId: string) => Promise<void> | void;
165
+ /** Atomically accepts an unseen event id and rejects every duplicate. */
166
+ claim: (eventId: string) => Promise<boolean> | boolean;
168
167
  };
169
168
  export type VoiceTelnyxWebhookEventStoreOptions = {
170
169
  ttlSeconds?: number;
@@ -182,7 +181,7 @@ export type VoicePostgresTelnyxWebhookEventStoreOptions = VoiceTelnyxWebhookEven
182
181
  tableName?: string;
183
182
  tablePrefix?: string;
184
183
  };
185
- export type VoiceRedisTelnyxWebhookEventClient = Pick<RedisClient, "exists" | "set">;
184
+ export type VoiceRedisTelnyxWebhookEventClient = Pick<RedisClient, "set">;
186
185
  export type VoiceRedisTelnyxWebhookEventStoreOptions = VoiceTelnyxWebhookEventStoreOptions & {
187
186
  client?: VoiceRedisTelnyxWebhookEventClient;
188
187
  keyPrefix?: string;
@@ -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.665",
4
4
  "description": "Voice primitives and Elysia plugin for AbsoluteJS",
5
5
  "repository": {
6
6
  "type": "git",