@vellumai/vellum-gateway 0.7.0 → 0.7.2

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 (162) hide show
  1. package/AGENTS.md +4 -0
  2. package/ARCHITECTURE.md +67 -25
  3. package/Dockerfile +2 -0
  4. package/README.md +50 -13
  5. package/bun.lock +16 -2
  6. package/knip.json +3 -1
  7. package/package.json +3 -1
  8. package/src/__tests__/auto-approve-thresholds.test.ts +49 -22
  9. package/src/__tests__/channel-verification-session-proxy.test.ts +0 -1
  10. package/src/__tests__/config-file-watcher.test.ts +181 -0
  11. package/src/__tests__/config.test.ts +0 -1
  12. package/src/__tests__/contacts-control-plane-proxy.test.ts +0 -1
  13. package/src/__tests__/credential-watcher-managed-bootstrap.test.ts +10 -2
  14. package/src/__tests__/credential-watcher.test.ts +30 -2
  15. package/src/__tests__/db-connection-isolation.test.ts +157 -0
  16. package/src/__tests__/fake-assistant-ipc.ts +39 -0
  17. package/src/__tests__/feature-flags-route.test.ts +8 -8
  18. package/src/__tests__/guardian-init-lockfile.test.ts +30 -4
  19. package/src/__tests__/ipc-feature-flag-routes.test.ts +1 -1
  20. package/src/__tests__/live-voice-websocket.test.ts +0 -1
  21. package/src/__tests__/load-guards.test.ts +0 -1
  22. package/src/__tests__/migration-teleport-gcs-proxy.test.ts +0 -1
  23. package/src/__tests__/oauth-callback.test.ts +0 -1
  24. package/src/__tests__/pair-origin-allowlist.test.ts +155 -0
  25. package/src/__tests__/rate-limit-loopback.test.ts +1 -1
  26. package/src/__tests__/remote-feature-flag-sync.test.ts +47 -7
  27. package/src/__tests__/resolve-assistant.test.ts +0 -1
  28. package/src/__tests__/route-schema-guard.test.ts +42 -6
  29. package/src/__tests__/runtime-client.test.ts +0 -1
  30. package/src/__tests__/runtime-health-proxy.test.ts +0 -1
  31. package/src/__tests__/runtime-proxy-auth.test.ts +0 -1
  32. package/src/__tests__/runtime-proxy.test.ts +0 -1
  33. package/src/__tests__/slack-control-plane-proxy.test.ts +0 -1
  34. package/src/__tests__/slack-display-name.test.ts +66 -1
  35. package/src/__tests__/slack-normalize.test.ts +158 -4
  36. package/src/__tests__/slack-reaction-normalize.test.ts +0 -1
  37. package/src/__tests__/slack-socket-mode-catchup.test.ts +857 -0
  38. package/src/__tests__/slack-socket-mode-scopes.test.ts +52 -0
  39. package/src/__tests__/slack-socket-mode-thread-tracking.test.ts +654 -0
  40. package/src/__tests__/stt-stream-websocket.test.ts +0 -1
  41. package/src/__tests__/telegram-control-plane-proxy.test.ts +0 -1
  42. package/src/__tests__/telegram-send-attachments.test.ts +0 -1
  43. package/src/__tests__/telegram-webhook-handler.test.ts +0 -1
  44. package/src/__tests__/text-verification-helpers.test.ts +136 -0
  45. package/src/__tests__/twilio-media-websocket.test.ts +0 -1
  46. package/src/__tests__/twilio-relay-websocket.test.ts +0 -1
  47. package/src/__tests__/twilio-webhooks.test.ts +220 -3
  48. package/src/__tests__/upstream-transport.test.ts +0 -36
  49. package/src/__tests__/whatsapp-download.test.ts +0 -1
  50. package/src/__tests__/whatsapp-webhook.test.ts +0 -1
  51. package/src/auth/guardian-refresh.ts +4 -18
  52. package/src/auth/ipc-route-policy.ts +217 -0
  53. package/src/backup/backup-key.ts +138 -0
  54. package/src/backup/backup-routes.ts +159 -0
  55. package/src/backup/backup-worker.ts +374 -0
  56. package/src/backup/list-snapshots.ts +97 -0
  57. package/src/backup/local-writer.ts +87 -0
  58. package/src/backup/offsite-writer.ts +182 -0
  59. package/src/backup/paths.ts +123 -0
  60. package/src/backup/stream-crypt.ts +258 -0
  61. package/src/chrome-extension-origins.ts +28 -0
  62. package/src/cli/enable-proxy.ts +0 -1
  63. package/src/config-file-cache.ts +3 -19
  64. package/src/config-file-utils.ts +124 -0
  65. package/src/config-file-watcher.ts +57 -25
  66. package/src/config.ts +4 -7
  67. package/src/db/connection.ts +65 -3
  68. package/src/db/contact-store.ts +30 -1
  69. package/src/db/data-migrations/index.ts +2 -0
  70. package/src/db/data-migrations/m0003-recover-backup-key.ts +71 -0
  71. package/src/db/schema.ts +92 -0
  72. package/src/db/slack-store.ts +144 -11
  73. package/src/feature-flag-registry.json +40 -152
  74. package/src/handlers/handle-inbound.ts +123 -0
  75. package/src/http/middleware/auth.ts +44 -1
  76. package/src/http/middleware/cors.ts +84 -0
  77. package/src/http/middleware/rate-limit.ts +6 -8
  78. package/src/http/routes/auto-approve-thresholds.ts +17 -1
  79. package/src/http/routes/brain-graph-proxy.ts +1 -1
  80. package/src/http/routes/channel-readiness-proxy.ts +2 -2
  81. package/src/http/routes/channel-verification-session-proxy.ts +19 -37
  82. package/src/http/routes/contact-prompt.ts +149 -0
  83. package/src/http/routes/contacts-control-plane-proxy.ts +2 -2
  84. package/src/http/routes/email-webhook.test.ts +0 -1
  85. package/src/http/routes/ipc-runtime-proxy.test.ts +197 -1
  86. package/src/http/routes/ipc-runtime-proxy.ts +95 -0
  87. package/src/http/routes/log-export.test.ts +0 -1
  88. package/src/http/routes/log-tail.test.ts +336 -0
  89. package/src/http/routes/log-tail.ts +87 -0
  90. package/src/http/routes/migration-proxy.ts +1 -2
  91. package/src/http/routes/oauth-apps-proxy.ts +2 -2
  92. package/src/http/routes/oauth-providers-proxy.ts +2 -2
  93. package/src/http/routes/pair.ts +322 -0
  94. package/src/http/routes/privacy-config.ts +65 -79
  95. package/src/http/routes/runtime-health-proxy.ts +2 -2
  96. package/src/http/routes/runtime-proxy.ts +3 -1
  97. package/src/http/routes/slack-control-plane-proxy.ts +3 -20
  98. package/src/http/routes/stt-stream-websocket.ts +2 -3
  99. package/src/http/routes/telegram-control-plane-proxy.ts +2 -2
  100. package/src/http/routes/telegram-webhook.test.ts +0 -1
  101. package/src/http/routes/telegram-webhook.ts +6 -0
  102. package/src/http/routes/trust-rules.suggest.test.ts +25 -0
  103. package/src/http/routes/trust-rules.ts +7 -0
  104. package/src/http/routes/twilio-control-plane-proxy.ts +2 -2
  105. package/src/http/routes/twilio-media-websocket.ts +5 -5
  106. package/src/http/routes/twilio-voice-verify-callback.ts +310 -0
  107. package/src/http/routes/twilio-voice-webhook.test.ts +65 -1
  108. package/src/http/routes/twilio-voice-webhook.ts +45 -1
  109. package/src/http/routes/whatsapp-webhook.test.ts +0 -1
  110. package/src/index.ts +357 -278
  111. package/src/ipc/assistant-client.ts +8 -4
  112. package/src/ipc/contact-handlers.ts +88 -3
  113. package/src/ipc/threshold-handlers.ts +2 -0
  114. package/src/post-assistant-ready.ts +5 -3
  115. package/src/risk/bash-risk-classifier.test.ts +35 -27
  116. package/src/risk/bash-risk-classifier.ts +44 -14
  117. package/src/risk/command-registry/commands/assistant.ts +8 -19
  118. package/src/risk/command-registry.test.ts +0 -15
  119. package/src/risk/risk-classifier-parity.test.ts +1 -3
  120. package/src/runtime/client.ts +58 -3
  121. package/src/schema.ts +277 -104
  122. package/src/slack/normalize.test.ts +98 -0
  123. package/src/slack/normalize.ts +107 -32
  124. package/src/slack/slack-web.ts +213 -0
  125. package/src/slack/socket-mode.ts +701 -39
  126. package/src/telegram/send.test.ts +0 -1
  127. package/src/twilio/validate-webhook.ts +53 -14
  128. package/src/twilio/webhook-sync-trigger.ts +58 -0
  129. package/src/twilio/webhook-sync.test.ts +286 -0
  130. package/src/twilio/webhook-sync.ts +84 -0
  131. package/src/util/is-loopback-address.ts +27 -0
  132. package/src/velay/bridge-utils.ts +228 -0
  133. package/src/velay/client.test.ts +939 -0
  134. package/src/velay/client.ts +555 -0
  135. package/src/velay/http-bridge.test.ts +217 -0
  136. package/src/velay/http-bridge.ts +83 -0
  137. package/src/velay/protocol.ts +178 -0
  138. package/src/velay/test-fake-websocket.ts +69 -0
  139. package/src/velay/websocket-bridge.test.ts +367 -0
  140. package/src/velay/websocket-bridge.ts +324 -0
  141. package/src/verification/binding-helpers.ts +107 -0
  142. package/src/verification/code-parsing.ts +44 -0
  143. package/src/verification/contact-helpers.ts +342 -0
  144. package/src/verification/identity-match.ts +68 -0
  145. package/src/verification/identity.ts +61 -0
  146. package/src/verification/rate-limit-helpers.ts +205 -0
  147. package/src/verification/reply-delivery.ts +109 -0
  148. package/src/verification/session-helpers.ts +164 -0
  149. package/src/verification/text-verification.ts +372 -0
  150. package/src/version.ts +35 -0
  151. package/src/voice/verification.ts +456 -0
  152. package/src/webhook-pipeline.ts +4 -0
  153. package/src/__tests__/browser-relay-websocket.test.ts +0 -698
  154. package/src/__tests__/telegram-only-default.test.ts +0 -133
  155. package/src/auth/capability-tokens.ts +0 -248
  156. package/src/http/routes/browser-extension-pair.ts +0 -455
  157. package/src/http/routes/browser-relay-websocket.ts +0 -381
  158. package/src/http/routes/config-file-utils.ts +0 -73
  159. package/src/ipc/capability-token-handlers.ts +0 -30
  160. package/src/pairing/approved-devices-store.ts +0 -110
  161. package/src/pairing/pairing-routes.ts +0 -379
  162. package/src/pairing/pairing-store.ts +0 -218
@@ -39,7 +39,6 @@ const baseConfig: GatewayConfig = {
39
39
  routingEntries: [],
40
40
  runtimeInitialBackoffMs: 500,
41
41
  runtimeMaxRetries: 2,
42
- runtimeProxyEnabled: false,
43
42
  runtimeProxyRequireAuth: true,
44
43
  runtimeTimeoutMs: 30000,
45
44
  shutdownDrainMs: 5000,
@@ -1,3 +1,10 @@
1
+ import {
2
+ normalizePublicBaseUrl,
3
+ TWILIO_CONNECT_ACTION_WEBHOOK_PATH,
4
+ TWILIO_STATUS_WEBHOOK_PATH,
5
+ TWILIO_VOICE_WEBHOOK_PATH,
6
+ } from "@vellumai/service-contracts/twilio-ingress";
7
+
1
8
  import type { CredentialCache } from "../credential-cache.js";
2
9
  import type { ConfigFileCache } from "../config-file-cache.js";
3
10
  import type { GatewayConfig } from "../config.js";
@@ -10,6 +17,7 @@ const log = getLogger("twilio-validate");
10
17
  type TwilioWebhookKind = "voice" | "status" | "connect-action" | "unknown";
11
18
 
12
19
  type SignatureUrlCandidateSource =
20
+ | "platform_proxy"
13
21
  | "configured_ingress"
14
22
  | "forwarded_headers"
15
23
  | "raw_request";
@@ -28,15 +36,15 @@ function firstHeaderValue(value: string | null): string | undefined {
28
36
  function inferWebhookKind(reqUrl: string): TwilioWebhookKind {
29
37
  const pathname = new URL(reqUrl).pathname;
30
38
 
31
- if (pathname === "/webhooks/twilio/voice") {
39
+ if (pathname === TWILIO_VOICE_WEBHOOK_PATH) {
32
40
  return "voice";
33
41
  }
34
42
 
35
- if (pathname === "/webhooks/twilio/status") {
43
+ if (pathname === TWILIO_STATUS_WEBHOOK_PATH) {
36
44
  return "status";
37
45
  }
38
46
 
39
- if (pathname === "/webhooks/twilio/connect-action") {
47
+ if (pathname === TWILIO_CONNECT_ACTION_WEBHOOK_PATH) {
40
48
  return "connect-action";
41
49
  }
42
50
 
@@ -80,11 +88,20 @@ function buildSignatureUrlCandidateDetails(
80
88
  source: SignatureUrlCandidateSource,
81
89
  ): void => {
82
90
  if (!base) return;
83
- const normalized = base.trim().replace(/\/+$/, "");
91
+ const normalized = normalizePublicBaseUrl(base);
84
92
  if (!normalized) return;
85
93
  addCandidate(`${normalized}${pathAndQuery}`, source);
86
94
  };
87
95
 
96
+ // Platform callback proxy injects the original public URL that the
97
+ // provider signed against. Use it as-is (not base + path) since the
98
+ // platform path includes the /v1/gateway/callbacks/{id}/ prefix that
99
+ // the gateway never sees.
100
+ addCandidate(
101
+ req.headers.get("x-vellum-ingress-url") ?? undefined,
102
+ "platform_proxy",
103
+ );
104
+
88
105
  addBase(resolved.ingressUrl, "configured_ingress");
89
106
 
90
107
  const forwardedProto =
@@ -173,6 +190,22 @@ export type TwilioValidationCaches = {
173
190
  configFile?: ConfigFileCache;
174
191
  };
175
192
 
193
+ function readConfiguredIngressUrl(
194
+ configFile: ConfigFileCache | undefined,
195
+ ): string | undefined {
196
+ if (!configFile) return undefined;
197
+ return configFile.getString("ingress", "publicBaseUrl");
198
+ }
199
+
200
+ function isPublicIngressDisabled(
201
+ configFile: ConfigFileCache | undefined,
202
+ ): boolean {
203
+ if (!configFile || typeof configFile.getBoolean !== "function") {
204
+ return false;
205
+ }
206
+ return configFile.getBoolean("ingress", "enabled", { force: true }) === false;
207
+ }
208
+
176
209
  /**
177
210
  * Validate an incoming Twilio webhook request:
178
211
  * - Enforces POST method
@@ -201,15 +234,21 @@ export async function validateTwilioWebhookRequest(
201
234
  return Response.json({ error: "Payload too large" }, { status: 413 });
202
235
  }
203
236
 
237
+ if (isPublicIngressDisabled(caches?.configFile)) {
238
+ log.warn(
239
+ { webhookKind: inferWebhookKind(req.url) },
240
+ "Twilio webhook rejected because public ingress is disabled",
241
+ );
242
+ return Response.json({ error: "Forbidden" }, { status: 403 });
243
+ }
244
+
204
245
  // Resolve the auth token from cache
205
246
  let authToken = caches?.credentials
206
247
  ? await caches.credentials.get(credentialKey("twilio", "auth_token"))
207
248
  : undefined;
208
249
 
209
250
  // Resolve ingress URL from cache
210
- let ingressUrl = caches?.configFile
211
- ? caches.configFile.getString("ingress", "publicBaseUrl")
212
- : undefined;
251
+ let ingressUrl = readConfiguredIngressUrl(caches?.configFile);
213
252
 
214
253
  let resolved: ResolvedValidationContext = { authToken, ingressUrl };
215
254
 
@@ -228,10 +267,7 @@ export async function validateTwilioWebhookRequest(
228
267
  let freshIngressUrl = ingressUrl;
229
268
  if (caches.configFile) {
230
269
  caches.configFile.refreshNow();
231
- freshIngressUrl = caches.configFile.getString(
232
- "ingress",
233
- "publicBaseUrl",
234
- );
270
+ freshIngressUrl = readConfiguredIngressUrl(caches.configFile);
235
271
  }
236
272
  authToken = freshAuthToken;
237
273
  ingressUrl = freshIngressUrl;
@@ -300,7 +336,7 @@ export async function validateTwilioWebhookRequest(
300
336
  let freshIngressUrl: string | undefined;
301
337
  if (caches.configFile) {
302
338
  caches.configFile.refreshNow();
303
- freshIngressUrl = caches.configFile.getString("ingress", "publicBaseUrl");
339
+ freshIngressUrl = readConfiguredIngressUrl(caches.configFile);
304
340
  }
305
341
 
306
342
  const retryAuthToken = freshAuthToken;
@@ -325,9 +361,12 @@ export async function validateTwilioWebhookRequest(
325
361
 
326
362
  if (validatingIndex !== -1) {
327
363
  log.info(
328
- "Twilio webhook signature validated after forced credential refresh",
364
+ "Twilio webhook signature validated after forced cache refresh",
329
365
  );
330
366
  // Update references for the success log below
367
+ ingressUrl = retryIngressUrl;
368
+ validationLogContext = retryDiagnostics.logContext;
369
+ signatureUrlCandidates = retryDiagnostics.signatureUrlCandidates;
331
370
  signatureCandidateUrls = retryCandidateUrls;
332
371
  }
333
372
  }
@@ -351,7 +390,7 @@ export async function validateTwilioWebhookRequest(
351
390
  validatedCandidateUrl: normalizeUrlForLog(validatingCandidate.url),
352
391
  };
353
392
 
354
- // When ingressPublicBaseUrl is configured and the signature only validated
393
+ // When a configured ingress URL is present and the signature only validated
355
394
  // against the raw local URL (last candidate), log a warning. This indicates
356
395
  // a likely drift between the configured ingress URL and the actual webhook
357
396
  // registration — the ingress URL should match what Twilio is signing against.
@@ -0,0 +1,58 @@
1
+ import type { ConfigChangeEvent } from "../config-file-watcher.js";
2
+
3
+ const PUBLIC_BASE_URL_FIELD = "publicBaseUrl";
4
+ const PUBLIC_BASE_URL_MANAGED_BY_FIELD = "publicBaseUrlManagedBy";
5
+ const TWILIO_PHONE_NUMBER_FIELD = "phoneNumber";
6
+ const TWILIO_ACCOUNT_SID_FIELD = "accountSid";
7
+
8
+ /**
9
+ * Returns true when the only config change is a Velay-managed publicBaseUrl
10
+ * update. Callers use this to skip side effects that shouldn't fire for
11
+ * Velay-only ingress updates (e.g. Telegram webhook re-registration).
12
+ */
13
+ export function isOnlyVelayPublicBaseUrlChange(
14
+ event: ConfigChangeEvent,
15
+ ): boolean {
16
+ if (event.changedKeys.size !== 1 || !event.changedKeys.has("ingress")) {
17
+ return false;
18
+ }
19
+
20
+ const ingressFields = event.changedFields.get("ingress");
21
+ if (!ingressFields || ingressFields.size === 0) {
22
+ return false;
23
+ }
24
+
25
+ // A Velay-managed update always touches publicBaseUrlManagedBy. If only
26
+ // publicBaseUrl changed (without the manager marker), treat it as a
27
+ // user-initiated change that should trigger downstream side effects.
28
+ if (!ingressFields.has(PUBLIC_BASE_URL_MANAGED_BY_FIELD)) {
29
+ return false;
30
+ }
31
+
32
+ return [...ingressFields].every(
33
+ (field) =>
34
+ field === PUBLIC_BASE_URL_FIELD ||
35
+ field === PUBLIC_BASE_URL_MANAGED_BY_FIELD,
36
+ );
37
+ }
38
+
39
+ export function shouldSyncTwilioPhoneWebhooksAfterConfigChange(
40
+ event: ConfigChangeEvent,
41
+ ): boolean {
42
+ if (event.changedKeys.has("ingress")) {
43
+ const ingressFields = event.changedFields.get("ingress");
44
+ if (ingressFields?.has(PUBLIC_BASE_URL_FIELD) === true) {
45
+ return true;
46
+ }
47
+ }
48
+
49
+ if (!event.changedKeys.has("twilio")) {
50
+ return false;
51
+ }
52
+
53
+ const twilioFields = event.changedFields.get("twilio");
54
+ return (
55
+ twilioFields?.has(TWILIO_PHONE_NUMBER_FIELD) === true ||
56
+ twilioFields?.has(TWILIO_ACCOUNT_SID_FIELD) === true
57
+ );
58
+ }
@@ -0,0 +1,286 @@
1
+ import { afterEach, describe, expect, mock, test } from "bun:test";
2
+
3
+ import type { ConfigFileCache } from "../config-file-cache.js";
4
+ import type { CredentialCache } from "../credential-cache.js";
5
+ import { credentialKey } from "../credential-key.js";
6
+ const ACCOUNT_SID = "AC123";
7
+ const AUTH_TOKEN = "auth-token";
8
+ const PHONE_NUMBER = "+15550100";
9
+ const PHONE_NUMBER_SID = "PN123";
10
+
11
+ type FetchFn = (
12
+ input: string | URL | Request,
13
+ init?: RequestInit,
14
+ ) => Promise<Response>;
15
+
16
+ interface MockedResponse {
17
+ body?: unknown;
18
+ status: number;
19
+ }
20
+
21
+ interface MockFetchEntry {
22
+ init: Partial<RequestInit>;
23
+ path: string;
24
+ response: MockedResponse | Response;
25
+ }
26
+
27
+ const mockFetchEntries: MockFetchEntry[] = [];
28
+ const mockFetchCalls: { init: RequestInit; path: string }[] = [];
29
+ let fetchImpl: ReturnType<typeof mock<FetchFn>> = mockFetchImpl();
30
+
31
+ mock.module("../fetch.js", () => ({
32
+ fetchImpl: (...args: Parameters<FetchFn>) => fetchImpl(...args),
33
+ }));
34
+
35
+ const { syncConfiguredTwilioPhoneNumberWebhooks } =
36
+ await import("./webhook-sync.js");
37
+
38
+ afterEach(() => {
39
+ resetMockFetch();
40
+ });
41
+
42
+ function makeCaches(opts: {
43
+ phoneNumber?: string;
44
+ accountSid?: string;
45
+ accountSidCredential?: string;
46
+ authToken?: string;
47
+ ingressEnabled?: boolean;
48
+ publicBaseUrl?: string;
49
+ }): { credentials: CredentialCache; configFile: ConfigFileCache } {
50
+ const credentialValues = new Map<string, string | undefined>([
51
+ [credentialKey("twilio", "account_sid"), opts.accountSidCredential],
52
+ [credentialKey("twilio", "auth_token"), opts.authToken],
53
+ ]);
54
+ const configValues: Record<string, Record<string, string | undefined>> = {
55
+ twilio: {
56
+ phoneNumber: opts.phoneNumber,
57
+ accountSid: opts.accountSid,
58
+ },
59
+ ingress: {
60
+ publicBaseUrl: opts.publicBaseUrl,
61
+ },
62
+ };
63
+
64
+ return {
65
+ credentials: {
66
+ get: async (key: string) => credentialValues.get(key),
67
+ invalidate: () => {},
68
+ } as unknown as CredentialCache,
69
+ configFile: {
70
+ getString: (section: string, key: string) =>
71
+ configValues[section]?.[key] ?? undefined,
72
+ getBoolean: (section: string, key: string) => {
73
+ if (section === "ingress" && key === "enabled") {
74
+ return opts.ingressEnabled;
75
+ }
76
+ return undefined;
77
+ },
78
+ invalidate: () => {},
79
+ } as unknown as ConfigFileCache,
80
+ };
81
+ }
82
+
83
+ function mockFetchImpl(): ReturnType<typeof mock<FetchFn>> {
84
+ return mock(
85
+ async (input: string | URL | Request, actualInit?: RequestInit) => {
86
+ const url = String(input);
87
+ mockFetchCalls.push({ path: url, init: actualInit ?? {} });
88
+
89
+ const idx = mockFetchEntries.findIndex((entry) => {
90
+ if (!url.includes(entry.path)) return false;
91
+ for (const [key, value] of Object.entries(entry.init)) {
92
+ const actualValue = (
93
+ actualInit as Record<string, unknown> | undefined
94
+ )?.[key];
95
+ if (actualValue !== value) {
96
+ return false;
97
+ }
98
+ }
99
+ return true;
100
+ });
101
+
102
+ if (idx === -1) {
103
+ return new Response(JSON.stringify({ detail: "No mock matched" }), {
104
+ status: 500,
105
+ });
106
+ }
107
+
108
+ const entry = mockFetchEntries[idx];
109
+ mockFetchEntries.splice(idx, 1);
110
+
111
+ if (entry.response instanceof Response) {
112
+ return entry.response;
113
+ }
114
+
115
+ return new Response(JSON.stringify(entry.response.body ?? null), {
116
+ status: entry.response.status,
117
+ headers: { "Content-Type": "application/json" },
118
+ });
119
+ },
120
+ );
121
+ }
122
+
123
+ function mockFetch(
124
+ path: string,
125
+ init: Partial<RequestInit>,
126
+ response: MockedResponse | Response,
127
+ ): void {
128
+ mockFetchEntries.push({ path, init, response });
129
+ }
130
+
131
+ function getMockFetchCalls(): { init: RequestInit; path: string }[] {
132
+ return mockFetchCalls;
133
+ }
134
+
135
+ function resetMockFetch(): void {
136
+ mockFetchEntries.length = 0;
137
+ mockFetchCalls.length = 0;
138
+ fetchImpl = mockFetchImpl();
139
+ }
140
+
141
+ function mockTwilioLookupAndUpdate(): void {
142
+ mockFetch(
143
+ `/Accounts/${ACCOUNT_SID}/IncomingPhoneNumbers.json?PhoneNumber=${encodeURIComponent(
144
+ PHONE_NUMBER,
145
+ )}`,
146
+ { method: "GET" },
147
+ {
148
+ status: 200,
149
+ body: {
150
+ incoming_phone_numbers: [
151
+ { sid: PHONE_NUMBER_SID, phone_number: PHONE_NUMBER },
152
+ ],
153
+ },
154
+ },
155
+ );
156
+ mockFetch(
157
+ `/Accounts/${ACCOUNT_SID}/IncomingPhoneNumbers/${PHONE_NUMBER_SID}.json`,
158
+ { method: "POST" },
159
+ { status: 200, body: {} },
160
+ );
161
+ }
162
+
163
+ describe("syncConfiguredTwilioPhoneNumberWebhooks", () => {
164
+ test("syncs phone webhooks to publicBaseUrl when configured", async () => {
165
+ mockTwilioLookupAndUpdate();
166
+
167
+ await syncConfiguredTwilioPhoneNumberWebhooks(
168
+ makeCaches({
169
+ phoneNumber: PHONE_NUMBER,
170
+ accountSid: ACCOUNT_SID,
171
+ authToken: AUTH_TOKEN,
172
+ publicBaseUrl: " https://velay.example.test/twilio/ ",
173
+ }),
174
+ );
175
+
176
+ const calls = getMockFetchCalls();
177
+ expect(calls).toHaveLength(2);
178
+ const body = new URLSearchParams(String(calls[1].init.body));
179
+ expect(body.get("VoiceUrl")).toBe(
180
+ "https://velay.example.test/twilio/webhooks/twilio/voice",
181
+ );
182
+ expect(body.get("VoiceMethod")).toBe("POST");
183
+ expect(body.get("StatusCallback")).toBe(
184
+ "https://velay.example.test/twilio/webhooks/twilio/status",
185
+ );
186
+ expect(body.get("StatusCallbackMethod")).toBe("POST");
187
+ expect(calls[1].init.headers).toEqual({
188
+ Authorization:
189
+ "Basic " +
190
+ Buffer.from(`${ACCOUNT_SID}:${AUTH_TOKEN}`).toString("base64"),
191
+ "Content-Type": "application/x-www-form-urlencoded",
192
+ });
193
+ });
194
+
195
+ test("syncs phone webhooks using publicBaseUrl", async () => {
196
+ mockTwilioLookupAndUpdate();
197
+
198
+ await syncConfiguredTwilioPhoneNumberWebhooks(
199
+ makeCaches({
200
+ phoneNumber: PHONE_NUMBER,
201
+ accountSid: ACCOUNT_SID,
202
+ authToken: AUTH_TOKEN,
203
+ publicBaseUrl: "https://generic.example.test/",
204
+ }),
205
+ );
206
+
207
+ const calls = getMockFetchCalls();
208
+ expect(calls).toHaveLength(2);
209
+ const body = new URLSearchParams(String(calls[1].init.body));
210
+ expect(body.get("VoiceUrl")).toBe(
211
+ "https://generic.example.test/webhooks/twilio/voice",
212
+ );
213
+ expect(body.get("StatusCallback")).toBe(
214
+ "https://generic.example.test/webhooks/twilio/status",
215
+ );
216
+ });
217
+
218
+ test("uses credential-store account SID before legacy config fallback", async () => {
219
+ mockTwilioLookupAndUpdate();
220
+
221
+ await syncConfiguredTwilioPhoneNumberWebhooks(
222
+ makeCaches({
223
+ phoneNumber: PHONE_NUMBER,
224
+ accountSid: "AC_CONFIG_STALE",
225
+ accountSidCredential: ACCOUNT_SID,
226
+ authToken: AUTH_TOKEN,
227
+ publicBaseUrl: "https://generic.example.test/",
228
+ }),
229
+ );
230
+
231
+ const calls = getMockFetchCalls();
232
+ expect(calls).toHaveLength(2);
233
+ expect(calls[0].path).toContain(`/Accounts/${ACCOUNT_SID}/`);
234
+ expect(calls[1].path).toContain(`/Accounts/${ACCOUNT_SID}/`);
235
+ });
236
+
237
+ test("skips without Twilio REST calls when required inputs are missing", async () => {
238
+ await syncConfiguredTwilioPhoneNumberWebhooks(
239
+ makeCaches({
240
+ phoneNumber: PHONE_NUMBER,
241
+ accountSid: ACCOUNT_SID,
242
+ authToken: undefined,
243
+ publicBaseUrl: "https://generic.example.test",
244
+ }),
245
+ );
246
+
247
+ expect(getMockFetchCalls()).toEqual([]);
248
+ });
249
+
250
+ test("skips without Twilio REST calls when public ingress is disabled", async () => {
251
+ await syncConfiguredTwilioPhoneNumberWebhooks(
252
+ makeCaches({
253
+ phoneNumber: PHONE_NUMBER,
254
+ accountSid: ACCOUNT_SID,
255
+ authToken: AUTH_TOKEN,
256
+ ingressEnabled: false,
257
+ publicBaseUrl: "https://generic.example.test",
258
+ }),
259
+ );
260
+
261
+ expect(getMockFetchCalls()).toEqual([]);
262
+ });
263
+
264
+ test("does not throw when Twilio lookup fails", async () => {
265
+ mockFetch(
266
+ `/Accounts/${ACCOUNT_SID}/IncomingPhoneNumbers.json?PhoneNumber=${encodeURIComponent(
267
+ PHONE_NUMBER,
268
+ )}`,
269
+ { method: "GET" },
270
+ { status: 500, body: { error: "unavailable" } },
271
+ );
272
+
273
+ await expect(
274
+ syncConfiguredTwilioPhoneNumberWebhooks(
275
+ makeCaches({
276
+ phoneNumber: PHONE_NUMBER,
277
+ accountSid: ACCOUNT_SID,
278
+ authToken: AUTH_TOKEN,
279
+ publicBaseUrl: "https://generic.example.test",
280
+ }),
281
+ ),
282
+ ).resolves.toBeUndefined();
283
+
284
+ expect(getMockFetchCalls()).toHaveLength(1);
285
+ });
286
+ });
@@ -0,0 +1,84 @@
1
+ import {
2
+ buildTwilioPhoneNumberWebhookUrls,
3
+ resolveTwilioPublicBaseUrl,
4
+ } from "@vellumai/service-contracts/twilio-ingress";
5
+ import { updatePhoneNumberWebhooks } from "@vellumai/twilio-client";
6
+
7
+ import type { ConfigFileCache } from "../config-file-cache.js";
8
+ import type { CredentialCache } from "../credential-cache.js";
9
+ import { credentialKey } from "../credential-key.js";
10
+ import { fetchImpl } from "../fetch.js";
11
+ import { getLogger } from "../logger.js";
12
+
13
+ const log = getLogger("twilio-webhook-sync");
14
+
15
+ export type TwilioWebhookSyncCaches = {
16
+ credentials: CredentialCache;
17
+ configFile: ConfigFileCache;
18
+ };
19
+
20
+ function resolveEffectiveTwilioBaseUrl(
21
+ configFile: ConfigFileCache,
22
+ ): string | undefined {
23
+ if (configFile.getBoolean("ingress", "enabled", { force: true }) === false) {
24
+ return undefined;
25
+ }
26
+
27
+ return resolveTwilioPublicBaseUrl({
28
+ publicBaseUrl: configFile.getString("ingress", "publicBaseUrl"),
29
+ });
30
+ }
31
+
32
+ export async function syncConfiguredTwilioPhoneNumberWebhooks(
33
+ caches: TwilioWebhookSyncCaches,
34
+ ): Promise<void> {
35
+ try {
36
+ const phoneNumber = caches.configFile
37
+ .getString("twilio", "phoneNumber")
38
+ ?.trim();
39
+ const accountSidFromCredentials = (
40
+ await caches.credentials.get(credentialKey("twilio", "account_sid"))
41
+ )?.trim();
42
+ const accountSid =
43
+ accountSidFromCredentials ||
44
+ caches.configFile.getString("twilio", "accountSid")?.trim();
45
+ const authToken = (
46
+ await caches.credentials.get(credentialKey("twilio", "auth_token"))
47
+ )?.trim();
48
+ const baseUrl = resolveEffectiveTwilioBaseUrl(caches.configFile);
49
+
50
+ if (!phoneNumber || !accountSid || !authToken || !baseUrl) {
51
+ log.debug(
52
+ {
53
+ hasPhoneNumber: !!phoneNumber,
54
+ hasAccountSid: !!accountSid,
55
+ hasAuthToken: !!authToken,
56
+ hasBaseUrl: !!baseUrl,
57
+ },
58
+ "Skipping Twilio webhook sync because configuration is incomplete",
59
+ );
60
+ return;
61
+ }
62
+
63
+ const urls = buildTwilioPhoneNumberWebhookUrls(baseUrl);
64
+ await updatePhoneNumberWebhooks({
65
+ accountSid,
66
+ authToken,
67
+ fetchImpl,
68
+ phoneNumber,
69
+ timeoutMs: 10_000,
70
+ webhooks: urls,
71
+ });
72
+
73
+ log.info(
74
+ {
75
+ phoneNumber,
76
+ voiceUrl: urls.voiceUrl,
77
+ statusCallbackUrl: urls.statusCallbackUrl,
78
+ },
79
+ "Synced Twilio phone number webhooks",
80
+ );
81
+ } catch (err) {
82
+ log.warn({ err }, "Twilio webhook sync skipped after non-fatal error");
83
+ }
84
+ }
@@ -1,3 +1,30 @@
1
+ import type { Server } from "bun";
2
+
3
+ /**
4
+ * Check whether the TCP peer of a Bun HTTP request is a loopback address.
5
+ *
6
+ * When `trustProxy` is set, the first entry in `X-Forwarded-For` is used
7
+ * instead of the raw socket IP.
8
+ */
9
+ export function isLoopbackPeer(
10
+ server: Server<unknown>,
11
+ req: Request,
12
+ opts?: { trustProxy?: boolean },
13
+ ): boolean {
14
+ if (opts?.trustProxy) {
15
+ const forwarded = req.headers.get("x-forwarded-for");
16
+ if (forwarded) {
17
+ const first = forwarded.split(",")[0]?.trim();
18
+ if (!first) return false;
19
+ return isLoopbackAddress(first);
20
+ }
21
+ }
22
+
23
+ const peer = server.requestIP(req);
24
+ if (!peer) return false;
25
+ return isLoopbackAddress(peer.address);
26
+ }
27
+
1
28
  /**
2
29
  * Stricter loopback-only check: accepts only 127.0.0.0/8 and ::1.
3
30
  * Use this instead of isPrivateNetworkPeer for endpoints that must be