@kodelyth/voice-call 2026.5.42 → 2026.6.1

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 (111) hide show
  1. package/package.json +16 -4
  2. package/api.ts +0 -16
  3. package/cli-metadata.ts +0 -10
  4. package/config-api.ts +0 -12
  5. package/index.test.ts +0 -1075
  6. package/index.ts +0 -863
  7. package/runtime-api.ts +0 -20
  8. package/runtime-entry.ts +0 -1
  9. package/setup-api.ts +0 -47
  10. package/src/allowlist.test.ts +0 -18
  11. package/src/allowlist.ts +0 -19
  12. package/src/cli.test.ts +0 -12
  13. package/src/cli.ts +0 -866
  14. package/src/config-compat.test.ts +0 -130
  15. package/src/config-compat.ts +0 -227
  16. package/src/config.test.ts +0 -542
  17. package/src/config.ts +0 -883
  18. package/src/core-bridge.ts +0 -14
  19. package/src/deep-merge.test.ts +0 -40
  20. package/src/deep-merge.ts +0 -23
  21. package/src/gateway-continue-operation.ts +0 -200
  22. package/src/http-headers.test.ts +0 -16
  23. package/src/http-headers.ts +0 -15
  24. package/src/manager/context.ts +0 -50
  25. package/src/manager/events.test.ts +0 -578
  26. package/src/manager/events.ts +0 -332
  27. package/src/manager/lifecycle.ts +0 -53
  28. package/src/manager/lookup.test.ts +0 -52
  29. package/src/manager/lookup.ts +0 -35
  30. package/src/manager/outbound.test.ts +0 -629
  31. package/src/manager/outbound.ts +0 -508
  32. package/src/manager/state.ts +0 -48
  33. package/src/manager/store.ts +0 -107
  34. package/src/manager/timers.test.ts +0 -127
  35. package/src/manager/timers.ts +0 -113
  36. package/src/manager/twiml.test.ts +0 -13
  37. package/src/manager/twiml.ts +0 -17
  38. package/src/manager.closed-loop.test.ts +0 -259
  39. package/src/manager.inbound-allowlist.test.ts +0 -183
  40. package/src/manager.notify.test.ts +0 -390
  41. package/src/manager.restore.test.ts +0 -310
  42. package/src/manager.test-harness.ts +0 -127
  43. package/src/manager.ts +0 -441
  44. package/src/media-stream.test.ts +0 -953
  45. package/src/media-stream.ts +0 -876
  46. package/src/providers/base.ts +0 -99
  47. package/src/providers/mock.test.ts +0 -86
  48. package/src/providers/mock.ts +0 -185
  49. package/src/providers/plivo.test.ts +0 -93
  50. package/src/providers/plivo.ts +0 -601
  51. package/src/providers/shared/call-status.test.ts +0 -24
  52. package/src/providers/shared/call-status.ts +0 -24
  53. package/src/providers/shared/guarded-json-api.test.ts +0 -127
  54. package/src/providers/shared/guarded-json-api.ts +0 -49
  55. package/src/providers/telnyx.test.ts +0 -489
  56. package/src/providers/telnyx.ts +0 -419
  57. package/src/providers/twilio/api.test.ts +0 -184
  58. package/src/providers/twilio/api.ts +0 -100
  59. package/src/providers/twilio/twiml-policy.test.ts +0 -84
  60. package/src/providers/twilio/twiml-policy.ts +0 -87
  61. package/src/providers/twilio/webhook.ts +0 -34
  62. package/src/providers/twilio.test.ts +0 -607
  63. package/src/providers/twilio.ts +0 -861
  64. package/src/providers/twilio.types.ts +0 -17
  65. package/src/realtime-agent-context.test.ts +0 -101
  66. package/src/realtime-agent-context.ts +0 -149
  67. package/src/realtime-defaults.ts +0 -3
  68. package/src/realtime-fast-context.test.ts +0 -74
  69. package/src/realtime-fast-context.ts +0 -27
  70. package/src/realtime-transcription.runtime.ts +0 -4
  71. package/src/realtime-voice.runtime.ts +0 -5
  72. package/src/response-generator.test.ts +0 -385
  73. package/src/response-generator.ts +0 -348
  74. package/src/response-model.test.ts +0 -71
  75. package/src/response-model.ts +0 -23
  76. package/src/runtime.test.ts +0 -625
  77. package/src/runtime.ts +0 -528
  78. package/src/telephony-audio.test.ts +0 -61
  79. package/src/telephony-audio.ts +0 -12
  80. package/src/telephony-tts.test.ts +0 -196
  81. package/src/telephony-tts.ts +0 -235
  82. package/src/test-fixtures.ts +0 -82
  83. package/src/tts-provider-voice.test.ts +0 -34
  84. package/src/tts-provider-voice.ts +0 -21
  85. package/src/tunnel.test.ts +0 -173
  86. package/src/tunnel.ts +0 -314
  87. package/src/types.ts +0 -311
  88. package/src/utils.test.ts +0 -17
  89. package/src/utils.ts +0 -14
  90. package/src/voice-mapping.test.ts +0 -32
  91. package/src/voice-mapping.ts +0 -65
  92. package/src/webhook/realtime-audio-pacer.test.ts +0 -146
  93. package/src/webhook/realtime-audio-pacer.ts +0 -204
  94. package/src/webhook/realtime-handler.test.ts +0 -1450
  95. package/src/webhook/realtime-handler.ts +0 -1382
  96. package/src/webhook/stale-call-reaper.test.ts +0 -89
  97. package/src/webhook/stale-call-reaper.ts +0 -38
  98. package/src/webhook/stream-frame-adapter.test.ts +0 -187
  99. package/src/webhook/stream-frame-adapter.ts +0 -219
  100. package/src/webhook/tailscale.test.ts +0 -216
  101. package/src/webhook/tailscale.ts +0 -129
  102. package/src/webhook-exposure.test.ts +0 -33
  103. package/src/webhook-exposure.ts +0 -84
  104. package/src/webhook-security.test.ts +0 -813
  105. package/src/webhook-security.ts +0 -982
  106. package/src/webhook.hangup-once.lifecycle.test.ts +0 -179
  107. package/src/webhook.test.ts +0 -1615
  108. package/src/webhook.ts +0 -933
  109. package/src/webhook.types.ts +0 -5
  110. package/src/websocket-test-support.ts +0 -72
  111. package/tsconfig.json +0 -16
@@ -1,982 +0,0 @@
1
- import crypto from "node:crypto";
2
- import { formatErrorMessage } from "klaw/plugin-sdk/error-runtime";
3
- import { isLoopbackHost } from "klaw/plugin-sdk/gateway-runtime";
4
- import { safeEqualSecret } from "klaw/plugin-sdk/security-runtime";
5
- import { normalizeLowercaseStringOrEmpty } from "klaw/plugin-sdk/string-coerce-runtime";
6
- import { getHeader } from "./http-headers.js";
7
- import type { WebhookContext } from "./types.js";
8
-
9
- const REPLAY_WINDOW_MS = 10 * 60 * 1000;
10
- const REPLAY_CACHE_MAX_ENTRIES = 10_000;
11
- const REPLAY_CACHE_PRUNE_INTERVAL = 64;
12
-
13
- type ReplayCache = {
14
- seenUntil: Map<string, number>;
15
- calls: number;
16
- };
17
-
18
- const twilioReplayCache: ReplayCache = {
19
- seenUntil: new Map<string, number>(),
20
- calls: 0,
21
- };
22
-
23
- const plivoReplayCache: ReplayCache = {
24
- seenUntil: new Map<string, number>(),
25
- calls: 0,
26
- };
27
-
28
- const telnyxReplayCache: ReplayCache = {
29
- seenUntil: new Map<string, number>(),
30
- calls: 0,
31
- };
32
-
33
- function sha256Hex(input: string): string {
34
- return crypto.createHash("sha256").update(input).digest("hex");
35
- }
36
-
37
- function createSkippedVerificationReplayKey(provider: string, ctx: WebhookContext): string {
38
- return `${provider}:skip:${sha256Hex(`${ctx.method}\n${ctx.url}\n${ctx.rawBody}`)}`;
39
- }
40
-
41
- function pruneReplayCache(cache: ReplayCache, now: number): void {
42
- for (const [key, expiresAt] of cache.seenUntil) {
43
- if (expiresAt <= now) {
44
- cache.seenUntil.delete(key);
45
- }
46
- }
47
- while (cache.seenUntil.size > REPLAY_CACHE_MAX_ENTRIES) {
48
- const oldest = cache.seenUntil.keys().next().value;
49
- if (!oldest) {
50
- break;
51
- }
52
- cache.seenUntil.delete(oldest);
53
- }
54
- }
55
-
56
- function markReplay(cache: ReplayCache, replayKey: string): boolean {
57
- const now = Date.now();
58
- cache.calls += 1;
59
- if (cache.calls % REPLAY_CACHE_PRUNE_INTERVAL === 0) {
60
- pruneReplayCache(cache, now);
61
- }
62
-
63
- const existing = cache.seenUntil.get(replayKey);
64
- if (existing && existing > now) {
65
- return true;
66
- }
67
-
68
- cache.seenUntil.set(replayKey, now + REPLAY_WINDOW_MS);
69
- if (cache.seenUntil.size > REPLAY_CACHE_MAX_ENTRIES) {
70
- pruneReplayCache(cache, now);
71
- }
72
- return false;
73
- }
74
-
75
- /**
76
- * Validate Twilio webhook signature using HMAC-SHA1.
77
- *
78
- * Twilio signs requests by concatenating the URL with sorted POST params,
79
- * then computing HMAC-SHA1 with the auth token.
80
- *
81
- * @see https://www.twilio.com/docs/usage/webhooks/webhooks-security
82
- */
83
- function validateTwilioSignature(
84
- authToken: string,
85
- signature: string | undefined,
86
- url: string,
87
- params: URLSearchParams,
88
- ): boolean {
89
- if (!signature) {
90
- return false;
91
- }
92
-
93
- const dataToSign = buildTwilioDataToSign(url, params);
94
-
95
- // HMAC-SHA1 with auth token, then base64 encode
96
- const expectedSignature = crypto
97
- .createHmac("sha1", authToken)
98
- .update(dataToSign)
99
- .digest("base64");
100
-
101
- // Use timing-safe comparison to prevent timing attacks
102
- return timingSafeEqual(signature, expectedSignature);
103
- }
104
-
105
- function buildTwilioDataToSign(url: string, params: URLSearchParams): string {
106
- let dataToSign = url;
107
- const sortedParams = Array.from(params.entries()).toSorted((a, b) =>
108
- a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0,
109
- );
110
- for (const [key, value] of sortedParams) {
111
- dataToSign += key + value;
112
- }
113
- return dataToSign;
114
- }
115
-
116
- function buildCanonicalTwilioParamString(params: URLSearchParams): string {
117
- return Array.from(params.entries())
118
- .toSorted((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
119
- .map(([key, value]) => `${key}=${value}`)
120
- .join("&");
121
- }
122
-
123
- /**
124
- * Timing-safe string comparison to prevent timing attacks.
125
- */
126
- function timingSafeEqual(a: string, b: string): boolean {
127
- return safeEqualSecret(a, b);
128
- }
129
-
130
- /**
131
- * Configuration for secure URL reconstruction.
132
- */
133
- interface WebhookUrlOptions {
134
- /**
135
- * Whitelist of allowed hostnames. If provided, only these hosts will be
136
- * accepted from forwarding headers. This prevents host header injection attacks.
137
- *
138
- * SECURITY: You must provide this OR set trustForwardingHeaders=true to use
139
- * X-Forwarded-Host headers. Without either, forwarding headers are ignored.
140
- */
141
- allowedHosts?: string[];
142
- /**
143
- * Explicitly trust X-Forwarded-* headers without a whitelist.
144
- * WARNING: Only set this to true if you trust your proxy configuration
145
- * and understand the security implications.
146
- *
147
- * @default false
148
- */
149
- trustForwardingHeaders?: boolean;
150
- /**
151
- * List of trusted proxy IP addresses. X-Forwarded-* headers will only be
152
- * trusted if the request comes from one of these IPs.
153
- * Requires remoteIP to be set for validation.
154
- */
155
- trustedProxyIPs?: string[];
156
- /**
157
- * The IP address of the incoming request (for proxy validation).
158
- */
159
- remoteIP?: string;
160
- }
161
-
162
- /**
163
- * Validate that a hostname matches RFC 1123 format.
164
- * Prevents injection of malformed hostnames.
165
- */
166
- function isValidHostname(hostname: string): boolean {
167
- if (!hostname || hostname.length > 253) {
168
- return false;
169
- }
170
- // RFC 1123 hostname: alphanumeric, hyphens, dots
171
- // Also allow ngrok/tunnel subdomains
172
- const hostnameRegex =
173
- /^([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])?$/;
174
- return hostnameRegex.test(hostname);
175
- }
176
-
177
- /**
178
- * Safely extract hostname from a host header value.
179
- * Handles IPv6 addresses and prevents injection via malformed values.
180
- */
181
- function extractHostname(hostHeader: string): string | null {
182
- if (!hostHeader) {
183
- return null;
184
- }
185
-
186
- let hostname: string;
187
-
188
- // Handle IPv6 addresses: [::1]:8080
189
- if (hostHeader.startsWith("[")) {
190
- const endBracket = hostHeader.indexOf("]");
191
- if (endBracket === -1) {
192
- return null; // Malformed IPv6
193
- }
194
- hostname = hostHeader.slice(1, endBracket);
195
- return normalizeLowercaseStringOrEmpty(hostname);
196
- }
197
-
198
- // Handle IPv4/domain with optional port
199
- // Check for @ which could indicate user info injection attempt
200
- if (hostHeader.includes("@")) {
201
- return null; // Reject potential injection: attacker.com:80@legitimate.com
202
- }
203
-
204
- hostname = hostHeader.split(":")[0];
205
-
206
- // Validate the extracted hostname
207
- if (!isValidHostname(hostname)) {
208
- return null;
209
- }
210
-
211
- return normalizeLowercaseStringOrEmpty(hostname);
212
- }
213
-
214
- function extractHostnameFromHeader(headerValue: string): string | null {
215
- const first = headerValue.split(",")[0]?.trim();
216
- if (!first) {
217
- return null;
218
- }
219
- return extractHostname(first);
220
- }
221
-
222
- function normalizeAllowedHosts(allowedHosts?: string[]): Set<string> | null {
223
- if (!allowedHosts || allowedHosts.length === 0) {
224
- return null;
225
- }
226
- const normalized = new Set<string>();
227
- for (const host of allowedHosts) {
228
- const extracted = extractHostname(host.trim());
229
- if (extracted) {
230
- normalized.add(extracted);
231
- }
232
- }
233
- return normalized.size > 0 ? normalized : null;
234
- }
235
-
236
- /**
237
- * Reconstruct the public webhook URL from request headers.
238
- *
239
- * SECURITY: This function validates host headers to prevent host header
240
- * injection attacks. When using forwarding headers (X-Forwarded-Host, etc.),
241
- * always provide allowedHosts to whitelist valid hostnames.
242
- *
243
- * When behind a reverse proxy (Tailscale, nginx, ngrok), the original URL
244
- * used by Twilio differs from the local request URL. We use standard
245
- * forwarding headers to reconstruct it.
246
- *
247
- * Priority order:
248
- * 1. X-Forwarded-Proto + X-Forwarded-Host (standard proxy headers)
249
- * 2. X-Original-Host (nginx)
250
- * 3. Ngrok-Forwarded-Host (ngrok specific)
251
- * 4. Host header (direct connection)
252
- */
253
- export function reconstructWebhookUrl(ctx: WebhookContext, options?: WebhookUrlOptions): string {
254
- const { headers } = ctx;
255
-
256
- // SECURITY: Only trust forwarding headers if explicitly configured.
257
- // Either allowedHosts must be set (for whitelist validation) or
258
- // trustForwardingHeaders must be true (explicit opt-in to trust).
259
- const allowedHosts = normalizeAllowedHosts(options?.allowedHosts);
260
- const hasAllowedHosts = allowedHosts !== null;
261
- const explicitlyTrusted = options?.trustForwardingHeaders === true;
262
-
263
- // Also check trusted proxy IPs if configured
264
- const trustedProxyIPs = options?.trustedProxyIPs?.filter(Boolean) ?? [];
265
- const hasTrustedProxyIPs = trustedProxyIPs.length > 0;
266
- const remoteIP = options?.remoteIP ?? ctx.remoteAddress;
267
- const fromTrustedProxy =
268
- !hasTrustedProxyIPs || (remoteIP ? trustedProxyIPs.includes(remoteIP) : false);
269
-
270
- // Only trust forwarding headers if: (has whitelist OR explicitly trusted) AND from trusted proxy
271
- const shouldTrustForwardingHeaders = (hasAllowedHosts || explicitlyTrusted) && fromTrustedProxy;
272
-
273
- const isAllowedForwardedHost = (host: string): boolean => !allowedHosts || allowedHosts.has(host);
274
-
275
- // Determine protocol - only trust X-Forwarded-Proto from trusted proxies
276
- let proto = "https";
277
- if (shouldTrustForwardingHeaders) {
278
- const forwardedProto = getHeader(headers, "x-forwarded-proto");
279
- if (forwardedProto === "http" || forwardedProto === "https") {
280
- proto = forwardedProto;
281
- }
282
- }
283
-
284
- // Determine host - with security validation
285
- let host: string | null = null;
286
-
287
- if (shouldTrustForwardingHeaders) {
288
- // Try forwarding headers in priority order
289
- const forwardingHeaders = ["x-forwarded-host", "x-original-host", "ngrok-forwarded-host"];
290
-
291
- for (const headerName of forwardingHeaders) {
292
- const headerValue = getHeader(headers, headerName);
293
- if (headerValue) {
294
- const extracted = extractHostnameFromHeader(headerValue);
295
- if (extracted && isAllowedForwardedHost(extracted)) {
296
- host = extracted;
297
- break;
298
- }
299
- }
300
- }
301
- }
302
-
303
- // Fallback to Host header if no valid forwarding header found
304
- if (!host) {
305
- const hostHeader = getHeader(headers, "host");
306
- if (hostHeader) {
307
- const extracted = extractHostnameFromHeader(hostHeader);
308
- if (extracted) {
309
- host = extracted;
310
- }
311
- }
312
- }
313
-
314
- // Last resort: try to extract from ctx.url
315
- if (!host) {
316
- try {
317
- const parsed = new URL(ctx.url);
318
- const extracted = extractHostname(parsed.host);
319
- if (extracted) {
320
- host = extracted;
321
- }
322
- } catch {
323
- // URL parsing failed - use empty string (will result in invalid URL)
324
- host = "";
325
- }
326
- }
327
-
328
- if (!host) {
329
- host = "";
330
- }
331
-
332
- // Extract path from the context URL (fallback to "/" on parse failure)
333
- let path = "/";
334
- try {
335
- const parsed = new URL(ctx.url);
336
- path = parsed.pathname + parsed.search;
337
- } catch {
338
- // URL parsing failed
339
- }
340
-
341
- return `${proto}://${host}${path}`;
342
- }
343
-
344
- function buildTwilioVerificationUrl(
345
- ctx: WebhookContext,
346
- publicUrl?: string,
347
- urlOptions?: WebhookUrlOptions,
348
- ): string {
349
- if (!publicUrl) {
350
- return reconstructWebhookUrl(ctx, urlOptions);
351
- }
352
-
353
- try {
354
- const base = new URL(publicUrl);
355
- const requestUrl = new URL(ctx.url);
356
- base.pathname = requestUrl.pathname;
357
- base.search = requestUrl.search;
358
- return base.toString();
359
- } catch {
360
- return publicUrl;
361
- }
362
- }
363
-
364
- function stripPortFromUrl(url: string): string {
365
- try {
366
- const parsed = new URL(url);
367
- if (!parsed.port) {
368
- return url;
369
- }
370
- parsed.port = "";
371
- return parsed.toString();
372
- } catch {
373
- return url;
374
- }
375
- }
376
-
377
- function setPortOnUrl(url: string, port: string): string {
378
- try {
379
- const parsed = new URL(url);
380
- parsed.port = port;
381
- return parsed.toString();
382
- } catch {
383
- return url;
384
- }
385
- }
386
-
387
- function extractPortFromHostHeader(hostHeader?: string): string | undefined {
388
- if (!hostHeader) {
389
- return undefined;
390
- }
391
- try {
392
- const parsed = new URL(`https://${hostHeader}`);
393
- return parsed.port || undefined;
394
- } catch {
395
- return undefined;
396
- }
397
- }
398
-
399
- /**
400
- * Result of Twilio webhook verification with detailed info.
401
- */
402
- interface TwilioVerificationResult {
403
- ok: boolean;
404
- reason?: string;
405
- /** The URL that was used for verification (for debugging) */
406
- verificationUrl?: string;
407
- /** Whether we're running behind ngrok free tier */
408
- isNgrokFreeTier?: boolean;
409
- /** Request is cryptographically valid but was already processed recently. */
410
- isReplay?: boolean;
411
- /** Stable request identity derived from signed Twilio material. */
412
- verifiedRequestKey?: string;
413
- }
414
-
415
- interface TelnyxVerificationResult {
416
- ok: boolean;
417
- reason?: string;
418
- /** Request is cryptographically valid but was already processed recently. */
419
- isReplay?: boolean;
420
- /** Stable request identity derived from signed Telnyx material. */
421
- verifiedRequestKey?: string;
422
- }
423
-
424
- function createTwilioReplayKey(params: {
425
- verificationUrl: string;
426
- signature: string;
427
- requestParams: URLSearchParams;
428
- }): string {
429
- const canonicalParams = buildCanonicalTwilioParamString(params.requestParams);
430
- return `twilio:req:${sha256Hex(
431
- `${params.verificationUrl}\n${canonicalParams}\n${params.signature}`,
432
- )}`;
433
- }
434
-
435
- function decodeBase64OrBase64Url(input: string): Buffer {
436
- // Telnyx docs say Base64; some tooling emits Base64URL. Accept both.
437
- const normalized = input.replace(/-/g, "+").replace(/_/g, "/");
438
- const padLen = (4 - (normalized.length % 4)) % 4;
439
- const padded = normalized + "=".repeat(padLen);
440
- return Buffer.from(padded, "base64");
441
- }
442
-
443
- function base64UrlEncode(buf: Buffer): string {
444
- return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
445
- }
446
-
447
- function importEd25519PublicKey(publicKey: string): crypto.KeyObject | string {
448
- const trimmed = publicKey.trim();
449
-
450
- // PEM (spki) support.
451
- if (trimmed.startsWith("-----BEGIN")) {
452
- return trimmed;
453
- }
454
-
455
- // Base64-encoded raw Ed25519 key (32 bytes) or Base64-encoded DER SPKI key.
456
- const decoded = decodeBase64OrBase64Url(trimmed);
457
- if (decoded.length === 32) {
458
- // JWK is the easiest portable way to import raw Ed25519 keys in Node crypto.
459
- return crypto.createPublicKey({
460
- key: { kty: "OKP", crv: "Ed25519", x: base64UrlEncode(decoded) },
461
- format: "jwk",
462
- });
463
- }
464
-
465
- return crypto.createPublicKey({
466
- key: decoded,
467
- format: "der",
468
- type: "spki",
469
- });
470
- }
471
-
472
- /**
473
- * Verify Telnyx webhook signature using Ed25519.
474
- *
475
- * Telnyx signs `timestamp|payload` and provides:
476
- * - `telnyx-signature-ed25519` (Base64 signature)
477
- * - `telnyx-timestamp` (Unix seconds)
478
- */
479
- export function verifyTelnyxWebhook(
480
- ctx: WebhookContext,
481
- publicKey: string | undefined,
482
- options?: {
483
- /** Skip verification entirely (only for development) */
484
- skipVerification?: boolean;
485
- /** Maximum allowed clock skew (ms). Defaults to 5 minutes. */
486
- maxSkewMs?: number;
487
- },
488
- ): TelnyxVerificationResult {
489
- if (options?.skipVerification) {
490
- const replayKey = createSkippedVerificationReplayKey("telnyx", ctx);
491
- const isReplay = markReplay(telnyxReplayCache, replayKey);
492
- return {
493
- ok: true,
494
- reason: "verification skipped (dev mode)",
495
- isReplay,
496
- verifiedRequestKey: replayKey,
497
- };
498
- }
499
-
500
- if (!publicKey) {
501
- return { ok: false, reason: "Missing telnyx.publicKey (configure to verify webhooks)" };
502
- }
503
-
504
- const signature = getHeader(ctx.headers, "telnyx-signature-ed25519");
505
- const timestamp = getHeader(ctx.headers, "telnyx-timestamp");
506
-
507
- if (!signature || !timestamp) {
508
- return { ok: false, reason: "Missing signature or timestamp header" };
509
- }
510
-
511
- const eventTimeSec = Number.parseInt(timestamp, 10);
512
- if (!Number.isFinite(eventTimeSec)) {
513
- return { ok: false, reason: "Invalid timestamp header" };
514
- }
515
-
516
- try {
517
- const signedPayload = `${timestamp}|${ctx.rawBody}`;
518
- const signatureBuffer = decodeBase64OrBase64Url(signature);
519
- // Canonicalize equivalent Base64/Base64URL encodings before replay hashing.
520
- const canonicalSignature = signatureBuffer.toString("base64");
521
- const key = importEd25519PublicKey(publicKey);
522
-
523
- const isValid = crypto.verify(null, Buffer.from(signedPayload), key, signatureBuffer);
524
- if (!isValid) {
525
- return { ok: false, reason: "Invalid signature" };
526
- }
527
-
528
- const maxSkewMs = options?.maxSkewMs ?? 5 * 60 * 1000;
529
- const eventTimeMs = eventTimeSec * 1000;
530
- const now = Date.now();
531
- if (Math.abs(now - eventTimeMs) > maxSkewMs) {
532
- return { ok: false, reason: "Timestamp too old" };
533
- }
534
-
535
- const replayKey = `telnyx:${sha256Hex(`${timestamp}\n${canonicalSignature}\n${ctx.rawBody}`)}`;
536
- const isReplay = markReplay(telnyxReplayCache, replayKey);
537
- return { ok: true, isReplay, verifiedRequestKey: replayKey };
538
- } catch (err) {
539
- return {
540
- ok: false,
541
- reason: `Verification error: ${formatErrorMessage(err)}`,
542
- };
543
- }
544
- }
545
-
546
- /**
547
- * Verify Twilio webhook with full context and detailed result.
548
- */
549
- export function verifyTwilioWebhook(
550
- ctx: WebhookContext,
551
- authToken: string,
552
- options?: {
553
- /** Override the public URL (e.g., from config) */
554
- publicUrl?: string;
555
- /**
556
- * Allow ngrok free tier compatibility mode (loopback only).
557
- *
558
- * IMPORTANT: This does NOT bypass signature verification.
559
- * It only enables trusting forwarded headers on loopback so we can
560
- * reconstruct the public ngrok URL that Twilio used for signing.
561
- */
562
- allowNgrokFreeTierLoopbackBypass?: boolean;
563
- /** Skip verification entirely (only for development) */
564
- skipVerification?: boolean;
565
- /**
566
- * Whitelist of allowed hostnames for host header validation.
567
- * Prevents host header injection attacks.
568
- */
569
- allowedHosts?: string[];
570
- /**
571
- * Explicitly trust X-Forwarded-* headers without a whitelist.
572
- * WARNING: Only enable if you trust your proxy configuration.
573
- * @default false
574
- */
575
- trustForwardingHeaders?: boolean;
576
- /**
577
- * List of trusted proxy IP addresses. X-Forwarded-* headers will only
578
- * be trusted from these IPs.
579
- */
580
- trustedProxyIPs?: string[];
581
- /**
582
- * The remote IP address of the request (for proxy validation).
583
- */
584
- remoteIP?: string;
585
- },
586
- ): TwilioVerificationResult {
587
- // Allow skipping verification for development/testing
588
- if (options?.skipVerification) {
589
- const replayKey = createSkippedVerificationReplayKey("twilio", ctx);
590
- const isReplay = markReplay(twilioReplayCache, replayKey);
591
- return {
592
- ok: true,
593
- reason: "verification skipped (dev mode)",
594
- isReplay,
595
- verifiedRequestKey: replayKey,
596
- };
597
- }
598
-
599
- const signature = getHeader(ctx.headers, "x-twilio-signature");
600
-
601
- if (!signature) {
602
- return { ok: false, reason: "Missing X-Twilio-Signature header" };
603
- }
604
-
605
- const isLoopback = isLoopbackHost(options?.remoteIP ?? ctx.remoteAddress ?? "");
606
- const allowLoopbackForwarding = options?.allowNgrokFreeTierLoopbackBypass && isLoopback;
607
-
608
- // Reconstruct the URL Twilio used
609
- const verificationUrl = buildTwilioVerificationUrl(ctx, options?.publicUrl, {
610
- allowedHosts: options?.allowedHosts,
611
- trustForwardingHeaders: options?.trustForwardingHeaders || allowLoopbackForwarding,
612
- trustedProxyIPs: options?.trustedProxyIPs,
613
- remoteIP: options?.remoteIP,
614
- });
615
-
616
- // Parse the body as URL-encoded params
617
- const params = new URLSearchParams(ctx.rawBody);
618
-
619
- const isValid = validateTwilioSignature(authToken, signature, verificationUrl, params);
620
-
621
- if (isValid) {
622
- const replayKey = createTwilioReplayKey({
623
- verificationUrl,
624
- signature,
625
- requestParams: params,
626
- });
627
- const isReplay = markReplay(twilioReplayCache, replayKey);
628
- return { ok: true, verificationUrl, isReplay, verifiedRequestKey: replayKey };
629
- }
630
-
631
- // Twilio webhook signatures can differ in whether port is included.
632
- // Retry a small, deterministic set of URL variants before failing closed.
633
- const variants = new Set<string>();
634
- variants.add(verificationUrl);
635
- variants.add(stripPortFromUrl(verificationUrl));
636
-
637
- if (options?.publicUrl) {
638
- try {
639
- const publicPort = new URL(options.publicUrl).port;
640
- if (publicPort) {
641
- variants.add(setPortOnUrl(verificationUrl, publicPort));
642
- }
643
- } catch {
644
- // ignore invalid publicUrl; primary verification already used best effort
645
- }
646
- }
647
-
648
- const hostHeaderPort = extractPortFromHostHeader(getHeader(ctx.headers, "host"));
649
- if (hostHeaderPort) {
650
- variants.add(setPortOnUrl(verificationUrl, hostHeaderPort));
651
- }
652
-
653
- for (const candidateUrl of variants) {
654
- if (candidateUrl === verificationUrl) {
655
- continue;
656
- }
657
- const isValidCandidate = validateTwilioSignature(authToken, signature, candidateUrl, params);
658
- if (!isValidCandidate) {
659
- continue;
660
- }
661
- const replayKey = createTwilioReplayKey({
662
- verificationUrl: candidateUrl,
663
- signature,
664
- requestParams: params,
665
- });
666
- const isReplay = markReplay(twilioReplayCache, replayKey);
667
- return { ok: true, verificationUrl: candidateUrl, isReplay, verifiedRequestKey: replayKey };
668
- }
669
-
670
- // Check if this is ngrok free tier - the URL might have different format
671
- const isNgrokFreeTier =
672
- verificationUrl.includes(".ngrok-free.app") || verificationUrl.includes(".ngrok.io");
673
-
674
- return {
675
- ok: false,
676
- reason: `Invalid signature for URL: ${verificationUrl}`,
677
- verificationUrl,
678
- isNgrokFreeTier,
679
- };
680
- }
681
-
682
- // -----------------------------------------------------------------------------
683
- // Plivo webhook verification
684
- // -----------------------------------------------------------------------------
685
-
686
- /**
687
- * Result of Plivo webhook verification with detailed info.
688
- */
689
- interface PlivoVerificationResult {
690
- ok: boolean;
691
- reason?: string;
692
- verificationUrl?: string;
693
- /** Signature version used for verification */
694
- version?: "v3" | "v2";
695
- /** Request is cryptographically valid but was already processed recently. */
696
- isReplay?: boolean;
697
- /** Stable request identity derived from signed Plivo material. */
698
- verifiedRequestKey?: string;
699
- }
700
-
701
- function normalizeSignatureBase64(input: string): string {
702
- // Canonicalize base64 to match Plivo SDK behavior (decode then re-encode).
703
- return Buffer.from(input, "base64").toString("base64");
704
- }
705
-
706
- function getBaseUrlNoQuery(url: string): string {
707
- const u = new URL(url);
708
- return `${u.protocol}//${u.host}${u.pathname}`;
709
- }
710
-
711
- function createPlivoV2ReplayKey(url: string, nonce: string): string {
712
- return `plivo:v2:${sha256Hex(`${getBaseUrlNoQuery(url)}\n${nonce}`)}`;
713
- }
714
-
715
- function createPlivoV3ReplayKey(params: {
716
- method: "GET" | "POST";
717
- url: string;
718
- postParams: PlivoParamMap;
719
- nonce: string;
720
- }): string {
721
- const baseUrl = constructPlivoV3BaseUrl({
722
- method: params.method,
723
- url: params.url,
724
- postParams: params.postParams,
725
- });
726
- return `plivo:v3:${sha256Hex(`${baseUrl}\n${params.nonce}`)}`;
727
- }
728
-
729
- function timingSafeEqualString(a: string, b: string): boolean {
730
- return safeEqualSecret(a, b);
731
- }
732
-
733
- function validatePlivoV2Signature(params: {
734
- authToken: string;
735
- signature: string;
736
- nonce: string;
737
- url: string;
738
- }): boolean {
739
- const baseUrl = getBaseUrlNoQuery(params.url);
740
- const digest = crypto
741
- .createHmac("sha256", params.authToken)
742
- .update(baseUrl + params.nonce)
743
- .digest("base64");
744
- const expected = normalizeSignatureBase64(digest);
745
- const provided = normalizeSignatureBase64(params.signature);
746
- return timingSafeEqualString(expected, provided);
747
- }
748
-
749
- type PlivoParamMap = Record<string, string[]>;
750
-
751
- function toParamMapFromSearchParams(sp: URLSearchParams): PlivoParamMap {
752
- const map: PlivoParamMap = {};
753
- for (const [key, value] of sp.entries()) {
754
- if (!map[key]) {
755
- map[key] = [];
756
- }
757
- map[key].push(value);
758
- }
759
- return map;
760
- }
761
-
762
- function sortedQueryString(params: PlivoParamMap): string {
763
- const parts: string[] = [];
764
- for (const key of Object.keys(params).toSorted()) {
765
- const values = [...params[key]].toSorted();
766
- for (const value of values) {
767
- parts.push(`${key}=${value}`);
768
- }
769
- }
770
- return parts.join("&");
771
- }
772
-
773
- function sortedParamsString(params: PlivoParamMap): string {
774
- const parts: string[] = [];
775
- for (const key of Object.keys(params).toSorted()) {
776
- const values = [...params[key]].toSorted();
777
- for (const value of values) {
778
- parts.push(`${key}${value}`);
779
- }
780
- }
781
- return parts.join("");
782
- }
783
-
784
- function constructPlivoV3BaseUrl(params: {
785
- method: "GET" | "POST";
786
- url: string;
787
- postParams: PlivoParamMap;
788
- }): string {
789
- const hasPostParams = Object.keys(params.postParams).length > 0;
790
- const u = new URL(params.url);
791
- const baseNoQuery = `${u.protocol}//${u.host}${u.pathname}`;
792
-
793
- const queryMap = toParamMapFromSearchParams(u.searchParams);
794
- const queryString = sortedQueryString(queryMap);
795
-
796
- // In the Plivo V3 algorithm, the query portion is always sorted, and if we
797
- // have POST params we add a '.' separator after the query string.
798
- let baseUrl = baseNoQuery;
799
- if (queryString.length > 0 || hasPostParams) {
800
- baseUrl = `${baseNoQuery}?${queryString}`;
801
- }
802
- if (queryString.length > 0 && hasPostParams) {
803
- baseUrl = `${baseUrl}.`;
804
- }
805
-
806
- if (params.method === "GET") {
807
- return baseUrl;
808
- }
809
-
810
- return baseUrl + sortedParamsString(params.postParams);
811
- }
812
-
813
- function validatePlivoV3Signature(params: {
814
- authToken: string;
815
- signatureHeader: string;
816
- nonce: string;
817
- method: "GET" | "POST";
818
- url: string;
819
- postParams: PlivoParamMap;
820
- }): boolean {
821
- const baseUrl = constructPlivoV3BaseUrl({
822
- method: params.method,
823
- url: params.url,
824
- postParams: params.postParams,
825
- });
826
-
827
- const hmacBase = `${baseUrl}.${params.nonce}`;
828
- const digest = crypto.createHmac("sha256", params.authToken).update(hmacBase).digest("base64");
829
- const expected = normalizeSignatureBase64(digest);
830
-
831
- // Header can contain multiple signatures separated by commas.
832
- const provided = params.signatureHeader
833
- .split(",")
834
- .map((s) => s.trim())
835
- .filter(Boolean)
836
- .map((s) => normalizeSignatureBase64(s));
837
-
838
- for (const sig of provided) {
839
- if (timingSafeEqualString(expected, sig)) {
840
- return true;
841
- }
842
- }
843
- return false;
844
- }
845
-
846
- /**
847
- * Verify Plivo webhooks using V3 signature if present; fall back to V2.
848
- *
849
- * Header names (case-insensitive; Node provides lower-case keys):
850
- * - V3: X-Plivo-Signature-V3 / X-Plivo-Signature-V3-Nonce
851
- * - V2: X-Plivo-Signature-V2 / X-Plivo-Signature-V2-Nonce
852
- */
853
- export function verifyPlivoWebhook(
854
- ctx: WebhookContext,
855
- authToken: string,
856
- options?: {
857
- /** Override the public URL origin (host) used for verification */
858
- publicUrl?: string;
859
- /** Skip verification entirely (only for development) */
860
- skipVerification?: boolean;
861
- /**
862
- * Whitelist of allowed hostnames for host header validation.
863
- * Prevents host header injection attacks.
864
- */
865
- allowedHosts?: string[];
866
- /**
867
- * Explicitly trust X-Forwarded-* headers without a whitelist.
868
- * WARNING: Only enable if you trust your proxy configuration.
869
- * @default false
870
- */
871
- trustForwardingHeaders?: boolean;
872
- /**
873
- * List of trusted proxy IP addresses. X-Forwarded-* headers will only
874
- * be trusted from these IPs.
875
- */
876
- trustedProxyIPs?: string[];
877
- /**
878
- * The remote IP address of the request (for proxy validation).
879
- */
880
- remoteIP?: string;
881
- },
882
- ): PlivoVerificationResult {
883
- if (options?.skipVerification) {
884
- const replayKey = createSkippedVerificationReplayKey("plivo", ctx);
885
- const isReplay = markReplay(plivoReplayCache, replayKey);
886
- return {
887
- ok: true,
888
- reason: "verification skipped (dev mode)",
889
- isReplay,
890
- verifiedRequestKey: replayKey,
891
- };
892
- }
893
-
894
- const signatureV3 = getHeader(ctx.headers, "x-plivo-signature-v3");
895
- const nonceV3 = getHeader(ctx.headers, "x-plivo-signature-v3-nonce");
896
- const signatureV2 = getHeader(ctx.headers, "x-plivo-signature-v2");
897
- const nonceV2 = getHeader(ctx.headers, "x-plivo-signature-v2-nonce");
898
-
899
- const reconstructed = reconstructWebhookUrl(ctx, {
900
- allowedHosts: options?.allowedHosts,
901
- trustForwardingHeaders: options?.trustForwardingHeaders,
902
- trustedProxyIPs: options?.trustedProxyIPs,
903
- remoteIP: options?.remoteIP,
904
- });
905
- let verificationUrl = reconstructed;
906
- if (options?.publicUrl) {
907
- try {
908
- const req = new URL(reconstructed);
909
- const base = new URL(options.publicUrl);
910
- base.pathname = req.pathname;
911
- base.search = req.search;
912
- verificationUrl = base.toString();
913
- } catch {
914
- verificationUrl = reconstructed;
915
- }
916
- }
917
-
918
- if (signatureV3 && nonceV3) {
919
- const method = ctx.method === "GET" || ctx.method === "POST" ? ctx.method : null;
920
-
921
- if (!method) {
922
- return {
923
- ok: false,
924
- version: "v3",
925
- verificationUrl,
926
- reason: `Unsupported HTTP method for Plivo V3 signature: ${ctx.method}`,
927
- };
928
- }
929
-
930
- const postParams = toParamMapFromSearchParams(new URLSearchParams(ctx.rawBody));
931
- const ok = validatePlivoV3Signature({
932
- authToken,
933
- signatureHeader: signatureV3,
934
- nonce: nonceV3,
935
- method,
936
- url: verificationUrl,
937
- postParams,
938
- });
939
- if (!ok) {
940
- return {
941
- ok: false,
942
- version: "v3",
943
- verificationUrl,
944
- reason: "Invalid Plivo V3 signature",
945
- };
946
- }
947
- const replayKey = createPlivoV3ReplayKey({
948
- method,
949
- url: verificationUrl,
950
- postParams,
951
- nonce: nonceV3,
952
- });
953
- const isReplay = markReplay(plivoReplayCache, replayKey);
954
- return { ok: true, version: "v3", verificationUrl, isReplay, verifiedRequestKey: replayKey };
955
- }
956
-
957
- if (signatureV2 && nonceV2) {
958
- const ok = validatePlivoV2Signature({
959
- authToken,
960
- signature: signatureV2,
961
- nonce: nonceV2,
962
- url: verificationUrl,
963
- });
964
- if (!ok) {
965
- return {
966
- ok: false,
967
- version: "v2",
968
- verificationUrl,
969
- reason: "Invalid Plivo V2 signature",
970
- };
971
- }
972
- const replayKey = createPlivoV2ReplayKey(verificationUrl, nonceV2);
973
- const isReplay = markReplay(plivoReplayCache, replayKey);
974
- return { ok: true, version: "v2", verificationUrl, isReplay, verifiedRequestKey: replayKey };
975
- }
976
-
977
- return {
978
- ok: false,
979
- reason: "Missing Plivo signature headers (V3 or V2)",
980
- verificationUrl,
981
- };
982
- }