@kodelyth/voice-call 2026.5.39 → 2026.5.42

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.
Files changed (137) hide show
  1. package/README.md +167 -0
  2. package/api.ts +16 -0
  3. package/cli-metadata.ts +10 -0
  4. package/config-api.ts +12 -0
  5. package/dist/api.js +2 -0
  6. package/dist/cli-metadata.js +12 -0
  7. package/dist/config-DAwbG2aw.js +621 -0
  8. package/dist/config-compat-BYfJ5ueI.js +129 -0
  9. package/dist/guarded-json-api-xAIbFPZh.js +591 -0
  10. package/dist/index.js +1341 -0
  11. package/dist/mock-jtSdKDQN.js +135 -0
  12. package/dist/plivo-L-JTeuEc.js +392 -0
  13. package/dist/realtime-handler-5pSItXxX.js +1227 -0
  14. package/dist/realtime-transcription.runtime-CAbQKwCN.js +2 -0
  15. package/dist/realtime-voice.runtime-vCpCAutg.js +2 -0
  16. package/dist/response-generator-B-MjbtsM.js +199 -0
  17. package/dist/runtime-api.js +6 -0
  18. package/dist/runtime-entry-ohPMJR46.js +3435 -0
  19. package/dist/runtime-entry.js +2 -0
  20. package/dist/setup-api.js +37 -0
  21. package/dist/telnyx-BWr9EZ4x.js +278 -0
  22. package/dist/twilio-D9B0zY1k.js +679 -0
  23. package/index.test.ts +1075 -0
  24. package/index.ts +863 -0
  25. package/klaw.plugin.json +30 -133
  26. package/package.json +3 -3
  27. package/runtime-api.ts +20 -0
  28. package/runtime-entry.ts +1 -0
  29. package/setup-api.ts +47 -0
  30. package/src/allowlist.test.ts +18 -0
  31. package/src/allowlist.ts +19 -0
  32. package/src/cli.test.ts +12 -0
  33. package/src/cli.ts +866 -0
  34. package/src/config-compat.test.ts +130 -0
  35. package/src/config-compat.ts +227 -0
  36. package/src/config.test.ts +542 -0
  37. package/src/config.ts +883 -0
  38. package/src/core-bridge.ts +14 -0
  39. package/src/deep-merge.test.ts +40 -0
  40. package/src/deep-merge.ts +23 -0
  41. package/src/gateway-continue-operation.ts +200 -0
  42. package/src/http-headers.test.ts +16 -0
  43. package/src/http-headers.ts +15 -0
  44. package/src/manager/context.ts +50 -0
  45. package/src/manager/events.test.ts +578 -0
  46. package/src/manager/events.ts +332 -0
  47. package/src/manager/lifecycle.ts +53 -0
  48. package/src/manager/lookup.test.ts +52 -0
  49. package/src/manager/lookup.ts +35 -0
  50. package/src/manager/outbound.test.ts +629 -0
  51. package/src/manager/outbound.ts +508 -0
  52. package/src/manager/state.ts +48 -0
  53. package/src/manager/store.ts +107 -0
  54. package/src/manager/timers.test.ts +127 -0
  55. package/src/manager/timers.ts +113 -0
  56. package/src/manager/twiml.test.ts +13 -0
  57. package/src/manager/twiml.ts +17 -0
  58. package/src/manager.closed-loop.test.ts +259 -0
  59. package/src/manager.inbound-allowlist.test.ts +183 -0
  60. package/src/manager.notify.test.ts +390 -0
  61. package/src/manager.restore.test.ts +310 -0
  62. package/src/manager.test-harness.ts +127 -0
  63. package/src/manager.ts +441 -0
  64. package/src/media-stream.test.ts +953 -0
  65. package/src/media-stream.ts +876 -0
  66. package/src/providers/base.ts +99 -0
  67. package/src/providers/mock.test.ts +86 -0
  68. package/src/providers/mock.ts +185 -0
  69. package/src/providers/plivo.test.ts +93 -0
  70. package/src/providers/plivo.ts +601 -0
  71. package/src/providers/shared/call-status.test.ts +24 -0
  72. package/src/providers/shared/call-status.ts +24 -0
  73. package/src/providers/shared/guarded-json-api.test.ts +127 -0
  74. package/src/providers/shared/guarded-json-api.ts +49 -0
  75. package/src/providers/telnyx.test.ts +489 -0
  76. package/src/providers/telnyx.ts +419 -0
  77. package/src/providers/twilio/api.test.ts +184 -0
  78. package/src/providers/twilio/api.ts +100 -0
  79. package/src/providers/twilio/twiml-policy.test.ts +84 -0
  80. package/src/providers/twilio/twiml-policy.ts +87 -0
  81. package/src/providers/twilio/webhook.ts +34 -0
  82. package/src/providers/twilio.test.ts +607 -0
  83. package/src/providers/twilio.ts +861 -0
  84. package/src/providers/twilio.types.ts +17 -0
  85. package/src/realtime-agent-context.test.ts +101 -0
  86. package/src/realtime-agent-context.ts +149 -0
  87. package/src/realtime-defaults.ts +3 -0
  88. package/src/realtime-fast-context.test.ts +74 -0
  89. package/src/realtime-fast-context.ts +27 -0
  90. package/src/realtime-transcription.runtime.ts +4 -0
  91. package/src/realtime-voice.runtime.ts +5 -0
  92. package/src/response-generator.test.ts +385 -0
  93. package/src/response-generator.ts +348 -0
  94. package/src/response-model.test.ts +71 -0
  95. package/src/response-model.ts +23 -0
  96. package/src/runtime.test.ts +625 -0
  97. package/src/runtime.ts +528 -0
  98. package/src/telephony-audio.test.ts +61 -0
  99. package/src/telephony-audio.ts +12 -0
  100. package/src/telephony-tts.test.ts +196 -0
  101. package/src/telephony-tts.ts +235 -0
  102. package/src/test-fixtures.ts +82 -0
  103. package/src/tts-provider-voice.test.ts +34 -0
  104. package/src/tts-provider-voice.ts +21 -0
  105. package/src/tunnel.test.ts +173 -0
  106. package/src/tunnel.ts +314 -0
  107. package/src/types.ts +311 -0
  108. package/src/utils.test.ts +17 -0
  109. package/src/utils.ts +14 -0
  110. package/src/voice-mapping.test.ts +32 -0
  111. package/src/voice-mapping.ts +65 -0
  112. package/src/webhook/realtime-audio-pacer.test.ts +146 -0
  113. package/src/webhook/realtime-audio-pacer.ts +204 -0
  114. package/src/webhook/realtime-handler.test.ts +1450 -0
  115. package/src/webhook/realtime-handler.ts +1382 -0
  116. package/src/webhook/stale-call-reaper.test.ts +89 -0
  117. package/src/webhook/stale-call-reaper.ts +38 -0
  118. package/src/webhook/stream-frame-adapter.test.ts +187 -0
  119. package/src/webhook/stream-frame-adapter.ts +219 -0
  120. package/src/webhook/tailscale.test.ts +216 -0
  121. package/src/webhook/tailscale.ts +129 -0
  122. package/src/webhook-exposure.test.ts +33 -0
  123. package/src/webhook-exposure.ts +84 -0
  124. package/src/webhook-security.test.ts +813 -0
  125. package/src/webhook-security.ts +982 -0
  126. package/src/webhook.hangup-once.lifecycle.test.ts +179 -0
  127. package/src/webhook.test.ts +1615 -0
  128. package/src/webhook.ts +933 -0
  129. package/src/webhook.types.ts +5 -0
  130. package/src/websocket-test-support.ts +72 -0
  131. package/tsconfig.json +16 -0
  132. package/api.js +0 -7
  133. package/cli-metadata.js +0 -7
  134. package/index.js +0 -7
  135. package/runtime-api.js +0 -7
  136. package/runtime-entry.js +0 -7
  137. package/setup-api.js +0 -7
@@ -0,0 +1,591 @@
1
+ import { fetchWithSsrFGuard } from "./runtime-api.js";
2
+ import "./api.js";
3
+ import { a as getHeader } from "./runtime-entry-ohPMJR46.js";
4
+ import { formatErrorMessage } from "klaw/plugin-sdk/error-runtime";
5
+ import { isLoopbackHost } from "klaw/plugin-sdk/gateway-runtime";
6
+ import { normalizeLowercaseStringOrEmpty } from "klaw/plugin-sdk/string-coerce-runtime";
7
+ import crypto from "node:crypto";
8
+ import { safeEqualSecret } from "klaw/plugin-sdk/security-runtime";
9
+ //#region extensions/voice-call/src/webhook-security.ts
10
+ const REPLAY_WINDOW_MS = 600 * 1e3;
11
+ const REPLAY_CACHE_MAX_ENTRIES = 1e4;
12
+ const REPLAY_CACHE_PRUNE_INTERVAL = 64;
13
+ const twilioReplayCache = {
14
+ seenUntil: /* @__PURE__ */ new Map(),
15
+ calls: 0
16
+ };
17
+ const plivoReplayCache = {
18
+ seenUntil: /* @__PURE__ */ new Map(),
19
+ calls: 0
20
+ };
21
+ const telnyxReplayCache = {
22
+ seenUntil: /* @__PURE__ */ new Map(),
23
+ calls: 0
24
+ };
25
+ function sha256Hex(input) {
26
+ return crypto.createHash("sha256").update(input).digest("hex");
27
+ }
28
+ function createSkippedVerificationReplayKey(provider, ctx) {
29
+ return `${provider}:skip:${sha256Hex(`${ctx.method}\n${ctx.url}\n${ctx.rawBody}`)}`;
30
+ }
31
+ function pruneReplayCache(cache, now) {
32
+ for (const [key, expiresAt] of cache.seenUntil) if (expiresAt <= now) cache.seenUntil.delete(key);
33
+ while (cache.seenUntil.size > REPLAY_CACHE_MAX_ENTRIES) {
34
+ const oldest = cache.seenUntil.keys().next().value;
35
+ if (!oldest) break;
36
+ cache.seenUntil.delete(oldest);
37
+ }
38
+ }
39
+ function markReplay(cache, replayKey) {
40
+ const now = Date.now();
41
+ cache.calls += 1;
42
+ if (cache.calls % REPLAY_CACHE_PRUNE_INTERVAL === 0) pruneReplayCache(cache, now);
43
+ const existing = cache.seenUntil.get(replayKey);
44
+ if (existing && existing > now) return true;
45
+ cache.seenUntil.set(replayKey, now + REPLAY_WINDOW_MS);
46
+ if (cache.seenUntil.size > REPLAY_CACHE_MAX_ENTRIES) pruneReplayCache(cache, now);
47
+ return false;
48
+ }
49
+ /**
50
+ * Validate Twilio webhook signature using HMAC-SHA1.
51
+ *
52
+ * Twilio signs requests by concatenating the URL with sorted POST params,
53
+ * then computing HMAC-SHA1 with the auth token.
54
+ *
55
+ * @see https://www.twilio.com/docs/usage/webhooks/webhooks-security
56
+ */
57
+ function validateTwilioSignature(authToken, signature, url, params) {
58
+ if (!signature) return false;
59
+ const dataToSign = buildTwilioDataToSign(url, params);
60
+ return timingSafeEqual(signature, crypto.createHmac("sha1", authToken).update(dataToSign).digest("base64"));
61
+ }
62
+ function buildTwilioDataToSign(url, params) {
63
+ let dataToSign = url;
64
+ const sortedParams = Array.from(params.entries()).toSorted((a, b) => a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0);
65
+ for (const [key, value] of sortedParams) dataToSign += key + value;
66
+ return dataToSign;
67
+ }
68
+ function buildCanonicalTwilioParamString(params) {
69
+ return Array.from(params.entries()).toSorted((a, b) => a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0).map(([key, value]) => `${key}=${value}`).join("&");
70
+ }
71
+ /**
72
+ * Timing-safe string comparison to prevent timing attacks.
73
+ */
74
+ function timingSafeEqual(a, b) {
75
+ return safeEqualSecret(a, b);
76
+ }
77
+ /**
78
+ * Validate that a hostname matches RFC 1123 format.
79
+ * Prevents injection of malformed hostnames.
80
+ */
81
+ function isValidHostname(hostname) {
82
+ if (!hostname || hostname.length > 253) return false;
83
+ return /^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/.test(hostname);
84
+ }
85
+ /**
86
+ * Safely extract hostname from a host header value.
87
+ * Handles IPv6 addresses and prevents injection via malformed values.
88
+ */
89
+ function extractHostname(hostHeader) {
90
+ if (!hostHeader) return null;
91
+ let hostname;
92
+ if (hostHeader.startsWith("[")) {
93
+ const endBracket = hostHeader.indexOf("]");
94
+ if (endBracket === -1) return null;
95
+ hostname = hostHeader.slice(1, endBracket);
96
+ return normalizeLowercaseStringOrEmpty(hostname);
97
+ }
98
+ if (hostHeader.includes("@")) return null;
99
+ hostname = hostHeader.split(":")[0];
100
+ if (!isValidHostname(hostname)) return null;
101
+ return normalizeLowercaseStringOrEmpty(hostname);
102
+ }
103
+ function extractHostnameFromHeader(headerValue) {
104
+ const first = headerValue.split(",")[0]?.trim();
105
+ if (!first) return null;
106
+ return extractHostname(first);
107
+ }
108
+ function normalizeAllowedHosts(allowedHosts) {
109
+ if (!allowedHosts || allowedHosts.length === 0) return null;
110
+ const normalized = /* @__PURE__ */ new Set();
111
+ for (const host of allowedHosts) {
112
+ const extracted = extractHostname(host.trim());
113
+ if (extracted) normalized.add(extracted);
114
+ }
115
+ return normalized.size > 0 ? normalized : null;
116
+ }
117
+ /**
118
+ * Reconstruct the public webhook URL from request headers.
119
+ *
120
+ * SECURITY: This function validates host headers to prevent host header
121
+ * injection attacks. When using forwarding headers (X-Forwarded-Host, etc.),
122
+ * always provide allowedHosts to whitelist valid hostnames.
123
+ *
124
+ * When behind a reverse proxy (Tailscale, nginx, ngrok), the original URL
125
+ * used by Twilio differs from the local request URL. We use standard
126
+ * forwarding headers to reconstruct it.
127
+ *
128
+ * Priority order:
129
+ * 1. X-Forwarded-Proto + X-Forwarded-Host (standard proxy headers)
130
+ * 2. X-Original-Host (nginx)
131
+ * 3. Ngrok-Forwarded-Host (ngrok specific)
132
+ * 4. Host header (direct connection)
133
+ */
134
+ function reconstructWebhookUrl(ctx, options) {
135
+ const { headers } = ctx;
136
+ const allowedHosts = normalizeAllowedHosts(options?.allowedHosts);
137
+ const hasAllowedHosts = allowedHosts !== null;
138
+ const explicitlyTrusted = options?.trustForwardingHeaders === true;
139
+ const trustedProxyIPs = options?.trustedProxyIPs?.filter(Boolean) ?? [];
140
+ const hasTrustedProxyIPs = trustedProxyIPs.length > 0;
141
+ const remoteIP = options?.remoteIP ?? ctx.remoteAddress;
142
+ const fromTrustedProxy = !hasTrustedProxyIPs || (remoteIP ? trustedProxyIPs.includes(remoteIP) : false);
143
+ const shouldTrustForwardingHeaders = (hasAllowedHosts || explicitlyTrusted) && fromTrustedProxy;
144
+ const isAllowedForwardedHost = (host) => !allowedHosts || allowedHosts.has(host);
145
+ let proto = "https";
146
+ if (shouldTrustForwardingHeaders) {
147
+ const forwardedProto = getHeader(headers, "x-forwarded-proto");
148
+ if (forwardedProto === "http" || forwardedProto === "https") proto = forwardedProto;
149
+ }
150
+ let host = null;
151
+ if (shouldTrustForwardingHeaders) for (const headerName of [
152
+ "x-forwarded-host",
153
+ "x-original-host",
154
+ "ngrok-forwarded-host"
155
+ ]) {
156
+ const headerValue = getHeader(headers, headerName);
157
+ if (headerValue) {
158
+ const extracted = extractHostnameFromHeader(headerValue);
159
+ if (extracted && isAllowedForwardedHost(extracted)) {
160
+ host = extracted;
161
+ break;
162
+ }
163
+ }
164
+ }
165
+ if (!host) {
166
+ const hostHeader = getHeader(headers, "host");
167
+ if (hostHeader) {
168
+ const extracted = extractHostnameFromHeader(hostHeader);
169
+ if (extracted) host = extracted;
170
+ }
171
+ }
172
+ if (!host) try {
173
+ const extracted = extractHostname(new URL(ctx.url).host);
174
+ if (extracted) host = extracted;
175
+ } catch {
176
+ host = "";
177
+ }
178
+ if (!host) host = "";
179
+ let path = "/";
180
+ try {
181
+ const parsed = new URL(ctx.url);
182
+ path = parsed.pathname + parsed.search;
183
+ } catch {}
184
+ return `${proto}://${host}${path}`;
185
+ }
186
+ function buildTwilioVerificationUrl(ctx, publicUrl, urlOptions) {
187
+ if (!publicUrl) return reconstructWebhookUrl(ctx, urlOptions);
188
+ try {
189
+ const base = new URL(publicUrl);
190
+ const requestUrl = new URL(ctx.url);
191
+ base.pathname = requestUrl.pathname;
192
+ base.search = requestUrl.search;
193
+ return base.toString();
194
+ } catch {
195
+ return publicUrl;
196
+ }
197
+ }
198
+ function stripPortFromUrl(url) {
199
+ try {
200
+ const parsed = new URL(url);
201
+ if (!parsed.port) return url;
202
+ parsed.port = "";
203
+ return parsed.toString();
204
+ } catch {
205
+ return url;
206
+ }
207
+ }
208
+ function setPortOnUrl(url, port) {
209
+ try {
210
+ const parsed = new URL(url);
211
+ parsed.port = port;
212
+ return parsed.toString();
213
+ } catch {
214
+ return url;
215
+ }
216
+ }
217
+ function extractPortFromHostHeader(hostHeader) {
218
+ if (!hostHeader) return;
219
+ try {
220
+ return new URL(`https://${hostHeader}`).port || void 0;
221
+ } catch {
222
+ return;
223
+ }
224
+ }
225
+ function createTwilioReplayKey(params) {
226
+ const canonicalParams = buildCanonicalTwilioParamString(params.requestParams);
227
+ return `twilio:req:${sha256Hex(`${params.verificationUrl}\n${canonicalParams}\n${params.signature}`)}`;
228
+ }
229
+ function decodeBase64OrBase64Url(input) {
230
+ const normalized = input.replace(/-/g, "+").replace(/_/g, "/");
231
+ const padLen = (4 - normalized.length % 4) % 4;
232
+ const padded = normalized + "=".repeat(padLen);
233
+ return Buffer.from(padded, "base64");
234
+ }
235
+ function base64UrlEncode(buf) {
236
+ return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
237
+ }
238
+ function importEd25519PublicKey(publicKey) {
239
+ const trimmed = publicKey.trim();
240
+ if (trimmed.startsWith("-----BEGIN")) return trimmed;
241
+ const decoded = decodeBase64OrBase64Url(trimmed);
242
+ if (decoded.length === 32) return crypto.createPublicKey({
243
+ key: {
244
+ kty: "OKP",
245
+ crv: "Ed25519",
246
+ x: base64UrlEncode(decoded)
247
+ },
248
+ format: "jwk"
249
+ });
250
+ return crypto.createPublicKey({
251
+ key: decoded,
252
+ format: "der",
253
+ type: "spki"
254
+ });
255
+ }
256
+ /**
257
+ * Verify Telnyx webhook signature using Ed25519.
258
+ *
259
+ * Telnyx signs `timestamp|payload` and provides:
260
+ * - `telnyx-signature-ed25519` (Base64 signature)
261
+ * - `telnyx-timestamp` (Unix seconds)
262
+ */
263
+ function verifyTelnyxWebhook(ctx, publicKey, options) {
264
+ if (options?.skipVerification) {
265
+ const replayKey = createSkippedVerificationReplayKey("telnyx", ctx);
266
+ return {
267
+ ok: true,
268
+ reason: "verification skipped (dev mode)",
269
+ isReplay: markReplay(telnyxReplayCache, replayKey),
270
+ verifiedRequestKey: replayKey
271
+ };
272
+ }
273
+ if (!publicKey) return {
274
+ ok: false,
275
+ reason: "Missing telnyx.publicKey (configure to verify webhooks)"
276
+ };
277
+ const signature = getHeader(ctx.headers, "telnyx-signature-ed25519");
278
+ const timestamp = getHeader(ctx.headers, "telnyx-timestamp");
279
+ if (!signature || !timestamp) return {
280
+ ok: false,
281
+ reason: "Missing signature or timestamp header"
282
+ };
283
+ const eventTimeSec = Number.parseInt(timestamp, 10);
284
+ if (!Number.isFinite(eventTimeSec)) return {
285
+ ok: false,
286
+ reason: "Invalid timestamp header"
287
+ };
288
+ try {
289
+ const signedPayload = `${timestamp}|${ctx.rawBody}`;
290
+ const signatureBuffer = decodeBase64OrBase64Url(signature);
291
+ const canonicalSignature = signatureBuffer.toString("base64");
292
+ const key = importEd25519PublicKey(publicKey);
293
+ if (!crypto.verify(null, Buffer.from(signedPayload), key, signatureBuffer)) return {
294
+ ok: false,
295
+ reason: "Invalid signature"
296
+ };
297
+ const maxSkewMs = options?.maxSkewMs ?? 300 * 1e3;
298
+ const eventTimeMs = eventTimeSec * 1e3;
299
+ if (Math.abs(Date.now() - eventTimeMs) > maxSkewMs) return {
300
+ ok: false,
301
+ reason: "Timestamp too old"
302
+ };
303
+ const replayKey = `telnyx:${sha256Hex(`${timestamp}\n${canonicalSignature}\n${ctx.rawBody}`)}`;
304
+ return {
305
+ ok: true,
306
+ isReplay: markReplay(telnyxReplayCache, replayKey),
307
+ verifiedRequestKey: replayKey
308
+ };
309
+ } catch (err) {
310
+ return {
311
+ ok: false,
312
+ reason: `Verification error: ${formatErrorMessage(err)}`
313
+ };
314
+ }
315
+ }
316
+ /**
317
+ * Verify Twilio webhook with full context and detailed result.
318
+ */
319
+ function verifyTwilioWebhook(ctx, authToken, options) {
320
+ if (options?.skipVerification) {
321
+ const replayKey = createSkippedVerificationReplayKey("twilio", ctx);
322
+ return {
323
+ ok: true,
324
+ reason: "verification skipped (dev mode)",
325
+ isReplay: markReplay(twilioReplayCache, replayKey),
326
+ verifiedRequestKey: replayKey
327
+ };
328
+ }
329
+ const signature = getHeader(ctx.headers, "x-twilio-signature");
330
+ if (!signature) return {
331
+ ok: false,
332
+ reason: "Missing X-Twilio-Signature header"
333
+ };
334
+ const isLoopback = isLoopbackHost(options?.remoteIP ?? ctx.remoteAddress ?? "");
335
+ const allowLoopbackForwarding = options?.allowNgrokFreeTierLoopbackBypass && isLoopback;
336
+ const verificationUrl = buildTwilioVerificationUrl(ctx, options?.publicUrl, {
337
+ allowedHosts: options?.allowedHosts,
338
+ trustForwardingHeaders: options?.trustForwardingHeaders || allowLoopbackForwarding,
339
+ trustedProxyIPs: options?.trustedProxyIPs,
340
+ remoteIP: options?.remoteIP
341
+ });
342
+ const params = new URLSearchParams(ctx.rawBody);
343
+ if (validateTwilioSignature(authToken, signature, verificationUrl, params)) {
344
+ const replayKey = createTwilioReplayKey({
345
+ verificationUrl,
346
+ signature,
347
+ requestParams: params
348
+ });
349
+ return {
350
+ ok: true,
351
+ verificationUrl,
352
+ isReplay: markReplay(twilioReplayCache, replayKey),
353
+ verifiedRequestKey: replayKey
354
+ };
355
+ }
356
+ const variants = /* @__PURE__ */ new Set();
357
+ variants.add(verificationUrl);
358
+ variants.add(stripPortFromUrl(verificationUrl));
359
+ if (options?.publicUrl) try {
360
+ const publicPort = new URL(options.publicUrl).port;
361
+ if (publicPort) variants.add(setPortOnUrl(verificationUrl, publicPort));
362
+ } catch {}
363
+ const hostHeaderPort = extractPortFromHostHeader(getHeader(ctx.headers, "host"));
364
+ if (hostHeaderPort) variants.add(setPortOnUrl(verificationUrl, hostHeaderPort));
365
+ for (const candidateUrl of variants) {
366
+ if (candidateUrl === verificationUrl) continue;
367
+ if (!validateTwilioSignature(authToken, signature, candidateUrl, params)) continue;
368
+ const replayKey = createTwilioReplayKey({
369
+ verificationUrl: candidateUrl,
370
+ signature,
371
+ requestParams: params
372
+ });
373
+ return {
374
+ ok: true,
375
+ verificationUrl: candidateUrl,
376
+ isReplay: markReplay(twilioReplayCache, replayKey),
377
+ verifiedRequestKey: replayKey
378
+ };
379
+ }
380
+ const isNgrokFreeTier = verificationUrl.includes(".ngrok-free.app") || verificationUrl.includes(".ngrok.io");
381
+ return {
382
+ ok: false,
383
+ reason: `Invalid signature for URL: ${verificationUrl}`,
384
+ verificationUrl,
385
+ isNgrokFreeTier
386
+ };
387
+ }
388
+ function normalizeSignatureBase64(input) {
389
+ return Buffer.from(input, "base64").toString("base64");
390
+ }
391
+ function getBaseUrlNoQuery(url) {
392
+ const u = new URL(url);
393
+ return `${u.protocol}//${u.host}${u.pathname}`;
394
+ }
395
+ function createPlivoV2ReplayKey(url, nonce) {
396
+ return `plivo:v2:${sha256Hex(`${getBaseUrlNoQuery(url)}\n${nonce}`)}`;
397
+ }
398
+ function createPlivoV3ReplayKey(params) {
399
+ return `plivo:v3:${sha256Hex(`${constructPlivoV3BaseUrl({
400
+ method: params.method,
401
+ url: params.url,
402
+ postParams: params.postParams
403
+ })}\n${params.nonce}`)}`;
404
+ }
405
+ function timingSafeEqualString(a, b) {
406
+ return safeEqualSecret(a, b);
407
+ }
408
+ function validatePlivoV2Signature(params) {
409
+ const baseUrl = getBaseUrlNoQuery(params.url);
410
+ return timingSafeEqualString(normalizeSignatureBase64(crypto.createHmac("sha256", params.authToken).update(baseUrl + params.nonce).digest("base64")), normalizeSignatureBase64(params.signature));
411
+ }
412
+ function toParamMapFromSearchParams(sp) {
413
+ const map = {};
414
+ for (const [key, value] of sp.entries()) {
415
+ if (!map[key]) map[key] = [];
416
+ map[key].push(value);
417
+ }
418
+ return map;
419
+ }
420
+ function sortedQueryString(params) {
421
+ const parts = [];
422
+ for (const key of Object.keys(params).toSorted()) {
423
+ const values = [...params[key]].toSorted();
424
+ for (const value of values) parts.push(`${key}=${value}`);
425
+ }
426
+ return parts.join("&");
427
+ }
428
+ function sortedParamsString(params) {
429
+ const parts = [];
430
+ for (const key of Object.keys(params).toSorted()) {
431
+ const values = [...params[key]].toSorted();
432
+ for (const value of values) parts.push(`${key}${value}`);
433
+ }
434
+ return parts.join("");
435
+ }
436
+ function constructPlivoV3BaseUrl(params) {
437
+ const hasPostParams = Object.keys(params.postParams).length > 0;
438
+ const u = new URL(params.url);
439
+ const baseNoQuery = `${u.protocol}//${u.host}${u.pathname}`;
440
+ const queryString = sortedQueryString(toParamMapFromSearchParams(u.searchParams));
441
+ let baseUrl = baseNoQuery;
442
+ if (queryString.length > 0 || hasPostParams) baseUrl = `${baseNoQuery}?${queryString}`;
443
+ if (queryString.length > 0 && hasPostParams) baseUrl = `${baseUrl}.`;
444
+ if (params.method === "GET") return baseUrl;
445
+ return baseUrl + sortedParamsString(params.postParams);
446
+ }
447
+ function validatePlivoV3Signature(params) {
448
+ const hmacBase = `${constructPlivoV3BaseUrl({
449
+ method: params.method,
450
+ url: params.url,
451
+ postParams: params.postParams
452
+ })}.${params.nonce}`;
453
+ const expected = normalizeSignatureBase64(crypto.createHmac("sha256", params.authToken).update(hmacBase).digest("base64"));
454
+ const provided = params.signatureHeader.split(",").map((s) => s.trim()).filter(Boolean).map((s) => normalizeSignatureBase64(s));
455
+ for (const sig of provided) if (timingSafeEqualString(expected, sig)) return true;
456
+ return false;
457
+ }
458
+ /**
459
+ * Verify Plivo webhooks using V3 signature if present; fall back to V2.
460
+ *
461
+ * Header names (case-insensitive; Node provides lower-case keys):
462
+ * - V3: X-Plivo-Signature-V3 / X-Plivo-Signature-V3-Nonce
463
+ * - V2: X-Plivo-Signature-V2 / X-Plivo-Signature-V2-Nonce
464
+ */
465
+ function verifyPlivoWebhook(ctx, authToken, options) {
466
+ if (options?.skipVerification) {
467
+ const replayKey = createSkippedVerificationReplayKey("plivo", ctx);
468
+ return {
469
+ ok: true,
470
+ reason: "verification skipped (dev mode)",
471
+ isReplay: markReplay(plivoReplayCache, replayKey),
472
+ verifiedRequestKey: replayKey
473
+ };
474
+ }
475
+ const signatureV3 = getHeader(ctx.headers, "x-plivo-signature-v3");
476
+ const nonceV3 = getHeader(ctx.headers, "x-plivo-signature-v3-nonce");
477
+ const signatureV2 = getHeader(ctx.headers, "x-plivo-signature-v2");
478
+ const nonceV2 = getHeader(ctx.headers, "x-plivo-signature-v2-nonce");
479
+ const reconstructed = reconstructWebhookUrl(ctx, {
480
+ allowedHosts: options?.allowedHosts,
481
+ trustForwardingHeaders: options?.trustForwardingHeaders,
482
+ trustedProxyIPs: options?.trustedProxyIPs,
483
+ remoteIP: options?.remoteIP
484
+ });
485
+ let verificationUrl = reconstructed;
486
+ if (options?.publicUrl) try {
487
+ const req = new URL(reconstructed);
488
+ const base = new URL(options.publicUrl);
489
+ base.pathname = req.pathname;
490
+ base.search = req.search;
491
+ verificationUrl = base.toString();
492
+ } catch {
493
+ verificationUrl = reconstructed;
494
+ }
495
+ if (signatureV3 && nonceV3) {
496
+ const method = ctx.method === "GET" || ctx.method === "POST" ? ctx.method : null;
497
+ if (!method) return {
498
+ ok: false,
499
+ version: "v3",
500
+ verificationUrl,
501
+ reason: `Unsupported HTTP method for Plivo V3 signature: ${ctx.method}`
502
+ };
503
+ const postParams = toParamMapFromSearchParams(new URLSearchParams(ctx.rawBody));
504
+ if (!validatePlivoV3Signature({
505
+ authToken,
506
+ signatureHeader: signatureV3,
507
+ nonce: nonceV3,
508
+ method,
509
+ url: verificationUrl,
510
+ postParams
511
+ })) return {
512
+ ok: false,
513
+ version: "v3",
514
+ verificationUrl,
515
+ reason: "Invalid Plivo V3 signature"
516
+ };
517
+ const replayKey = createPlivoV3ReplayKey({
518
+ method,
519
+ url: verificationUrl,
520
+ postParams,
521
+ nonce: nonceV3
522
+ });
523
+ const isReplay = markReplay(plivoReplayCache, replayKey);
524
+ return {
525
+ ok: true,
526
+ version: "v3",
527
+ verificationUrl,
528
+ isReplay,
529
+ verifiedRequestKey: replayKey
530
+ };
531
+ }
532
+ if (signatureV2 && nonceV2) {
533
+ if (!validatePlivoV2Signature({
534
+ authToken,
535
+ signature: signatureV2,
536
+ nonce: nonceV2,
537
+ url: verificationUrl
538
+ })) return {
539
+ ok: false,
540
+ version: "v2",
541
+ verificationUrl,
542
+ reason: "Invalid Plivo V2 signature"
543
+ };
544
+ const replayKey = createPlivoV2ReplayKey(verificationUrl, nonceV2);
545
+ const isReplay = markReplay(plivoReplayCache, replayKey);
546
+ return {
547
+ ok: true,
548
+ version: "v2",
549
+ verificationUrl,
550
+ isReplay,
551
+ verifiedRequestKey: replayKey
552
+ };
553
+ }
554
+ return {
555
+ ok: false,
556
+ reason: "Missing Plivo signature headers (V3 or V2)",
557
+ verificationUrl
558
+ };
559
+ }
560
+ //#endregion
561
+ //#region extensions/voice-call/src/providers/shared/guarded-json-api.ts
562
+ async function guardedJsonApiRequest(params) {
563
+ const { response, release } = await fetchWithSsrFGuard({
564
+ url: params.url,
565
+ init: {
566
+ method: params.method,
567
+ headers: params.headers,
568
+ body: params.body ? JSON.stringify(params.body) : void 0
569
+ },
570
+ policy: { allowedHostnames: params.allowedHostnames },
571
+ auditContext: params.auditContext
572
+ });
573
+ try {
574
+ if (!response.ok) {
575
+ if (params.allowNotFound && response.status === 404) return;
576
+ const errorText = await response.text();
577
+ throw new Error(`${params.errorPrefix}: ${response.status} ${errorText}`);
578
+ }
579
+ const text = await response.text();
580
+ if (!text) return;
581
+ try {
582
+ return JSON.parse(text);
583
+ } catch {
584
+ throw new Error(`${params.errorPrefix}: malformed JSON response`);
585
+ }
586
+ } finally {
587
+ await release();
588
+ }
589
+ }
590
+ //#endregion
591
+ export { verifyTwilioWebhook as a, verifyTelnyxWebhook as i, reconstructWebhookUrl as n, verifyPlivoWebhook as r, guardedJsonApiRequest as t };