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