@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.
- package/AGENTS.md +4 -0
- package/ARCHITECTURE.md +67 -25
- package/Dockerfile +2 -0
- package/README.md +50 -13
- package/bun.lock +16 -2
- package/knip.json +3 -1
- package/package.json +3 -1
- package/src/__tests__/auto-approve-thresholds.test.ts +49 -22
- package/src/__tests__/channel-verification-session-proxy.test.ts +0 -1
- package/src/__tests__/config-file-watcher.test.ts +181 -0
- package/src/__tests__/config.test.ts +0 -1
- package/src/__tests__/contacts-control-plane-proxy.test.ts +0 -1
- package/src/__tests__/credential-watcher-managed-bootstrap.test.ts +10 -2
- package/src/__tests__/credential-watcher.test.ts +30 -2
- package/src/__tests__/db-connection-isolation.test.ts +157 -0
- package/src/__tests__/fake-assistant-ipc.ts +39 -0
- package/src/__tests__/feature-flags-route.test.ts +8 -8
- package/src/__tests__/guardian-init-lockfile.test.ts +30 -4
- package/src/__tests__/ipc-feature-flag-routes.test.ts +1 -1
- package/src/__tests__/live-voice-websocket.test.ts +0 -1
- package/src/__tests__/load-guards.test.ts +0 -1
- package/src/__tests__/migration-teleport-gcs-proxy.test.ts +0 -1
- package/src/__tests__/oauth-callback.test.ts +0 -1
- package/src/__tests__/pair-origin-allowlist.test.ts +155 -0
- package/src/__tests__/rate-limit-loopback.test.ts +1 -1
- package/src/__tests__/remote-feature-flag-sync.test.ts +47 -7
- package/src/__tests__/resolve-assistant.test.ts +0 -1
- package/src/__tests__/route-schema-guard.test.ts +42 -6
- package/src/__tests__/runtime-client.test.ts +0 -1
- package/src/__tests__/runtime-health-proxy.test.ts +0 -1
- package/src/__tests__/runtime-proxy-auth.test.ts +0 -1
- package/src/__tests__/runtime-proxy.test.ts +0 -1
- package/src/__tests__/slack-control-plane-proxy.test.ts +0 -1
- package/src/__tests__/slack-display-name.test.ts +66 -1
- package/src/__tests__/slack-normalize.test.ts +158 -4
- package/src/__tests__/slack-reaction-normalize.test.ts +0 -1
- package/src/__tests__/slack-socket-mode-catchup.test.ts +857 -0
- package/src/__tests__/slack-socket-mode-scopes.test.ts +52 -0
- package/src/__tests__/slack-socket-mode-thread-tracking.test.ts +654 -0
- package/src/__tests__/stt-stream-websocket.test.ts +0 -1
- package/src/__tests__/telegram-control-plane-proxy.test.ts +0 -1
- package/src/__tests__/telegram-send-attachments.test.ts +0 -1
- package/src/__tests__/telegram-webhook-handler.test.ts +0 -1
- package/src/__tests__/text-verification-helpers.test.ts +136 -0
- package/src/__tests__/twilio-media-websocket.test.ts +0 -1
- package/src/__tests__/twilio-relay-websocket.test.ts +0 -1
- package/src/__tests__/twilio-webhooks.test.ts +220 -3
- package/src/__tests__/upstream-transport.test.ts +0 -36
- package/src/__tests__/whatsapp-download.test.ts +0 -1
- package/src/__tests__/whatsapp-webhook.test.ts +0 -1
- package/src/auth/guardian-refresh.ts +4 -18
- package/src/auth/ipc-route-policy.ts +217 -0
- package/src/backup/backup-key.ts +138 -0
- package/src/backup/backup-routes.ts +159 -0
- package/src/backup/backup-worker.ts +374 -0
- package/src/backup/list-snapshots.ts +97 -0
- package/src/backup/local-writer.ts +87 -0
- package/src/backup/offsite-writer.ts +182 -0
- package/src/backup/paths.ts +123 -0
- package/src/backup/stream-crypt.ts +258 -0
- package/src/chrome-extension-origins.ts +28 -0
- package/src/cli/enable-proxy.ts +0 -1
- package/src/config-file-cache.ts +3 -19
- package/src/config-file-utils.ts +124 -0
- package/src/config-file-watcher.ts +57 -25
- package/src/config.ts +4 -7
- package/src/db/connection.ts +65 -3
- package/src/db/contact-store.ts +30 -1
- package/src/db/data-migrations/index.ts +2 -0
- package/src/db/data-migrations/m0003-recover-backup-key.ts +71 -0
- package/src/db/schema.ts +92 -0
- package/src/db/slack-store.ts +144 -11
- package/src/feature-flag-registry.json +40 -152
- package/src/handlers/handle-inbound.ts +123 -0
- package/src/http/middleware/auth.ts +44 -1
- package/src/http/middleware/cors.ts +84 -0
- package/src/http/middleware/rate-limit.ts +6 -8
- package/src/http/routes/auto-approve-thresholds.ts +17 -1
- package/src/http/routes/brain-graph-proxy.ts +1 -1
- package/src/http/routes/channel-readiness-proxy.ts +2 -2
- package/src/http/routes/channel-verification-session-proxy.ts +19 -37
- package/src/http/routes/contact-prompt.ts +149 -0
- package/src/http/routes/contacts-control-plane-proxy.ts +2 -2
- package/src/http/routes/email-webhook.test.ts +0 -1
- package/src/http/routes/ipc-runtime-proxy.test.ts +197 -1
- package/src/http/routes/ipc-runtime-proxy.ts +95 -0
- package/src/http/routes/log-export.test.ts +0 -1
- package/src/http/routes/log-tail.test.ts +336 -0
- package/src/http/routes/log-tail.ts +87 -0
- package/src/http/routes/migration-proxy.ts +1 -2
- package/src/http/routes/oauth-apps-proxy.ts +2 -2
- package/src/http/routes/oauth-providers-proxy.ts +2 -2
- package/src/http/routes/pair.ts +322 -0
- package/src/http/routes/privacy-config.ts +65 -79
- package/src/http/routes/runtime-health-proxy.ts +2 -2
- package/src/http/routes/runtime-proxy.ts +3 -1
- package/src/http/routes/slack-control-plane-proxy.ts +3 -20
- package/src/http/routes/stt-stream-websocket.ts +2 -3
- package/src/http/routes/telegram-control-plane-proxy.ts +2 -2
- package/src/http/routes/telegram-webhook.test.ts +0 -1
- package/src/http/routes/telegram-webhook.ts +6 -0
- package/src/http/routes/trust-rules.suggest.test.ts +25 -0
- package/src/http/routes/trust-rules.ts +7 -0
- package/src/http/routes/twilio-control-plane-proxy.ts +2 -2
- package/src/http/routes/twilio-media-websocket.ts +5 -5
- package/src/http/routes/twilio-voice-verify-callback.ts +310 -0
- package/src/http/routes/twilio-voice-webhook.test.ts +65 -1
- package/src/http/routes/twilio-voice-webhook.ts +45 -1
- package/src/http/routes/whatsapp-webhook.test.ts +0 -1
- package/src/index.ts +357 -278
- package/src/ipc/assistant-client.ts +8 -4
- package/src/ipc/contact-handlers.ts +88 -3
- package/src/ipc/threshold-handlers.ts +2 -0
- package/src/post-assistant-ready.ts +5 -3
- package/src/risk/bash-risk-classifier.test.ts +35 -27
- package/src/risk/bash-risk-classifier.ts +44 -14
- package/src/risk/command-registry/commands/assistant.ts +8 -19
- package/src/risk/command-registry.test.ts +0 -15
- package/src/risk/risk-classifier-parity.test.ts +1 -3
- package/src/runtime/client.ts +58 -3
- package/src/schema.ts +277 -104
- package/src/slack/normalize.test.ts +98 -0
- package/src/slack/normalize.ts +107 -32
- package/src/slack/slack-web.ts +213 -0
- package/src/slack/socket-mode.ts +701 -39
- package/src/telegram/send.test.ts +0 -1
- package/src/twilio/validate-webhook.ts +53 -14
- package/src/twilio/webhook-sync-trigger.ts +58 -0
- package/src/twilio/webhook-sync.test.ts +286 -0
- package/src/twilio/webhook-sync.ts +84 -0
- package/src/util/is-loopback-address.ts +27 -0
- package/src/velay/bridge-utils.ts +228 -0
- package/src/velay/client.test.ts +939 -0
- package/src/velay/client.ts +555 -0
- package/src/velay/http-bridge.test.ts +217 -0
- package/src/velay/http-bridge.ts +83 -0
- package/src/velay/protocol.ts +178 -0
- package/src/velay/test-fake-websocket.ts +69 -0
- package/src/velay/websocket-bridge.test.ts +367 -0
- package/src/velay/websocket-bridge.ts +324 -0
- package/src/verification/binding-helpers.ts +107 -0
- package/src/verification/code-parsing.ts +44 -0
- package/src/verification/contact-helpers.ts +342 -0
- package/src/verification/identity-match.ts +68 -0
- package/src/verification/identity.ts +61 -0
- package/src/verification/rate-limit-helpers.ts +205 -0
- package/src/verification/reply-delivery.ts +109 -0
- package/src/verification/session-helpers.ts +164 -0
- package/src/verification/text-verification.ts +372 -0
- package/src/version.ts +35 -0
- package/src/voice/verification.ts +456 -0
- package/src/webhook-pipeline.ts +4 -0
- package/src/__tests__/browser-relay-websocket.test.ts +0 -698
- package/src/__tests__/telegram-only-default.test.ts +0 -133
- package/src/auth/capability-tokens.ts +0 -248
- package/src/http/routes/browser-extension-pair.ts +0 -455
- package/src/http/routes/browser-relay-websocket.ts +0 -381
- package/src/http/routes/config-file-utils.ts +0 -73
- package/src/ipc/capability-token-handlers.ts +0 -30
- package/src/pairing/approved-devices-store.ts +0 -110
- package/src/pairing/pairing-routes.ts +0 -379
- package/src/pairing/pairing-store.ts +0 -218
|
@@ -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 ===
|
|
39
|
+
if (pathname === TWILIO_VOICE_WEBHOOK_PATH) {
|
|
32
40
|
return "voice";
|
|
33
41
|
}
|
|
34
42
|
|
|
35
|
-
if (pathname ===
|
|
43
|
+
if (pathname === TWILIO_STATUS_WEBHOOK_PATH) {
|
|
36
44
|
return "status";
|
|
37
45
|
}
|
|
38
46
|
|
|
39
|
-
if (pathname ===
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|