@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
|
@@ -56,9 +56,8 @@ export function createSttStreamWebsocketHandler(config: GatewayConfig) {
|
|
|
56
56
|
|
|
57
57
|
// ── Auth ──
|
|
58
58
|
// STT streaming is an authenticated, assistant-scoped path. The
|
|
59
|
-
// client must present a valid edge JWT (actor principal).
|
|
60
|
-
//
|
|
61
|
-
// fail closed.
|
|
59
|
+
// client must present a valid edge JWT (actor principal). There is
|
|
60
|
+
// no auth-bypass mode — fail closed.
|
|
62
61
|
if (!config.runtimeProxyRequireAuth) {
|
|
63
62
|
// When runtime proxy auth is globally disabled (dev bypass), we
|
|
64
63
|
// still allow the upgrade but skip token validation.
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Gateway proxy endpoints for Telegram integration control-plane routes.
|
|
3
3
|
*
|
|
4
|
-
* These routes
|
|
5
|
-
*
|
|
4
|
+
* These routes are registered as explicit gateway routes for dedicated
|
|
5
|
+
* auth handling rather than falling through to the catch-all proxy.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { proxyForward } from "@vellumai/assistant-client";
|
|
@@ -419,6 +419,8 @@ export function createTelegramWebhookHandler(
|
|
|
419
419
|
);
|
|
420
420
|
});
|
|
421
421
|
}
|
|
422
|
+
} else if (result.verificationIntercepted) {
|
|
423
|
+
// Verification handled — no error, no forward needed
|
|
422
424
|
} else if (!result.forwarded) {
|
|
423
425
|
tlog.error(
|
|
424
426
|
{ updateId: payload.update_id },
|
|
@@ -739,6 +741,10 @@ export function createTelegramWebhookHandler(
|
|
|
739
741
|
return respond({ ok: true });
|
|
740
742
|
}
|
|
741
743
|
|
|
744
|
+
if (result.verificationIntercepted) {
|
|
745
|
+
return respond({ ok: true });
|
|
746
|
+
}
|
|
747
|
+
|
|
742
748
|
if (!result.forwarded) {
|
|
743
749
|
tlog.error(
|
|
744
750
|
{ updateId: payload.update_id },
|
|
@@ -212,6 +212,31 @@ describe("POST /v1/trust-rules/suggest", () => {
|
|
|
212
212
|
expect(callArgs.currentThreshold).toBe("medium");
|
|
213
213
|
});
|
|
214
214
|
|
|
215
|
+
test("passes existingRule when provided", async () => {
|
|
216
|
+
const bodyWithExistingRule = {
|
|
217
|
+
...VALID_BODY,
|
|
218
|
+
existingRule: {
|
|
219
|
+
id: "rule-123",
|
|
220
|
+
pattern: "bash *",
|
|
221
|
+
risk: "low",
|
|
222
|
+
},
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
const handler = createTrustRulesSuggestHandler();
|
|
226
|
+
const res = await handler(jsonRequest(bodyWithExistingRule));
|
|
227
|
+
|
|
228
|
+
expect(res.status).toBe(200);
|
|
229
|
+
expect(ipcSuggestTrustRuleMock).toHaveBeenCalledTimes(1);
|
|
230
|
+
const callArgs = ipcSuggestTrustRuleMock.mock.calls[0][0] as {
|
|
231
|
+
existingRule: unknown;
|
|
232
|
+
};
|
|
233
|
+
expect(callArgs.existingRule).toEqual({
|
|
234
|
+
id: "rule-123",
|
|
235
|
+
pattern: "bash *",
|
|
236
|
+
risk: "low",
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
|
|
215
240
|
test("passes directoryScopeOptions when provided", async () => {
|
|
216
241
|
const bodyWithDirScope = {
|
|
217
242
|
...VALID_BODY,
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Gateway proxy endpoints for Twilio integration control-plane routes.
|
|
3
3
|
*
|
|
4
|
-
* These routes
|
|
5
|
-
*
|
|
4
|
+
* These routes are registered as explicit gateway routes for dedicated
|
|
5
|
+
* auth handling rather than falling through to the catch-all proxy.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { proxyForward } from "@vellumai/assistant-client";
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { buildWsUpstreamUrl } from "@vellumai/assistant-client";
|
|
2
|
+
import { TWILIO_MEDIA_STREAM_WEBHOOK_PATH } from "@vellumai/service-contracts/twilio-ingress";
|
|
2
3
|
|
|
3
4
|
import {
|
|
4
5
|
validateEdgeToken,
|
|
@@ -13,9 +14,6 @@ const log = getLogger("twilio-media-ws");
|
|
|
13
14
|
// Cap buffered messages to prevent unbounded memory growth if upstream stalls
|
|
14
15
|
const MAX_PENDING_MESSAGES = 100;
|
|
15
16
|
|
|
16
|
-
/** Path prefix for media-stream WS upgrades. */
|
|
17
|
-
const MEDIA_STREAM_PATH_PREFIX = "/webhooks/twilio/media-stream";
|
|
18
|
-
|
|
19
17
|
type MediaStreamSocketData = {
|
|
20
18
|
wsType: "twilio-media-stream";
|
|
21
19
|
callSessionId: string;
|
|
@@ -44,8 +42,10 @@ export function extractMediaStreamMetadata(url: URL): {
|
|
|
44
42
|
} {
|
|
45
43
|
// Try path-based extraction first.
|
|
46
44
|
// Expected pathname: /webhooks/twilio/media-stream/<callSessionId>[/<token>]
|
|
47
|
-
if (url.pathname.startsWith(
|
|
48
|
-
const suffix = url.pathname.slice(
|
|
45
|
+
if (url.pathname.startsWith(TWILIO_MEDIA_STREAM_WEBHOOK_PATH + "/")) {
|
|
46
|
+
const suffix = url.pathname.slice(
|
|
47
|
+
TWILIO_MEDIA_STREAM_WEBHOOK_PATH.length + 1,
|
|
48
|
+
);
|
|
49
49
|
const segments = suffix.split("/").filter(Boolean);
|
|
50
50
|
if (segments.length >= 1) {
|
|
51
51
|
try {
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* POST /webhooks/twilio/voice-verify — Twilio <Gather> action callback
|
|
3
|
+
* for gateway-owned voice verification.
|
|
4
|
+
*
|
|
5
|
+
* When the gateway intercepts an inbound call that requires verification,
|
|
6
|
+
* it returns <Gather> TwiML pointing to this endpoint. Twilio POSTs the
|
|
7
|
+
* collected DTMF digits here. The gateway validates the code, creates
|
|
8
|
+
* the guardian binding on success, and then forwards to the assistant
|
|
9
|
+
* for ConversationRelay setup.
|
|
10
|
+
*
|
|
11
|
+
* The assistant is never involved in verification — it only receives
|
|
12
|
+
* calls from callers whose identity the gateway has already confirmed.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { eq } from "drizzle-orm";
|
|
16
|
+
|
|
17
|
+
import type { GatewayConfig } from "../../config.js";
|
|
18
|
+
import { credentialKey } from "../../credential-key.js";
|
|
19
|
+
import { getLogger } from "../../logger.js";
|
|
20
|
+
import {
|
|
21
|
+
CircuitBreakerOpenError,
|
|
22
|
+
forwardTwilioVoiceWebhook,
|
|
23
|
+
resolvePublicBaseWssUrl,
|
|
24
|
+
} from "../../runtime/client.js";
|
|
25
|
+
import {
|
|
26
|
+
validateTwilioWebhookRequest,
|
|
27
|
+
type TwilioValidationCaches,
|
|
28
|
+
} from "../../twilio/validate-webhook.js";
|
|
29
|
+
import {
|
|
30
|
+
findPendingPhoneSession,
|
|
31
|
+
validateVerificationCode,
|
|
32
|
+
gatherVerificationTwiml,
|
|
33
|
+
failureTwiml,
|
|
34
|
+
} from "../../voice/verification.js";
|
|
35
|
+
import { createGuardianBinding } from "../../auth/guardian-bootstrap.js";
|
|
36
|
+
import { getGatewayDb } from "../../db/connection.js";
|
|
37
|
+
import { contactChannels as gwContactChannels } from "../../db/schema.js";
|
|
38
|
+
import {
|
|
39
|
+
assistantDbQuery,
|
|
40
|
+
assistantDbRun,
|
|
41
|
+
} from "../../db/assistant-db-proxy.js";
|
|
42
|
+
import { upsertVerifiedContactChannel } from "../../verification/contact-helpers.js";
|
|
43
|
+
|
|
44
|
+
const log = getLogger("twilio-voice-verify-callback");
|
|
45
|
+
|
|
46
|
+
const TWIML_HEADERS = { "Content-Type": "text/xml" };
|
|
47
|
+
|
|
48
|
+
function twimlResponse(body: string, status = 200): Response {
|
|
49
|
+
return new Response(body, { status, headers: TWIML_HEADERS });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function createTwilioVoiceVerifyCallbackHandler(
|
|
53
|
+
config: GatewayConfig,
|
|
54
|
+
caches?: TwilioValidationCaches,
|
|
55
|
+
) {
|
|
56
|
+
return async (req: Request): Promise<Response> => {
|
|
57
|
+
const validation = await validateTwilioWebhookRequest(req, config, caches);
|
|
58
|
+
if (validation instanceof Response) return validation;
|
|
59
|
+
|
|
60
|
+
const { params } = validation;
|
|
61
|
+
const fromNumber = params.From || "";
|
|
62
|
+
const callSid = params.CallSid || "";
|
|
63
|
+
|
|
64
|
+
// Extract attempt counter from query string
|
|
65
|
+
const url = new URL(req.url);
|
|
66
|
+
const attempt = parseInt(url.searchParams.get("attempt") || "0", 10);
|
|
67
|
+
const digits = params.Digits || "";
|
|
68
|
+
|
|
69
|
+
log.info(
|
|
70
|
+
{ callSid, fromNumber, attempt, hasDigits: digits.length > 0 },
|
|
71
|
+
"Voice verification callback received",
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
// Re-fetch the pending session (it may have been consumed or expired)
|
|
75
|
+
const session = await findPendingPhoneSession();
|
|
76
|
+
if (!session) {
|
|
77
|
+
log.warn(
|
|
78
|
+
{ callSid, fromNumber },
|
|
79
|
+
"No pending verification session found on callback — forwarding to assistant",
|
|
80
|
+
);
|
|
81
|
+
return forwardToAssistant(config, params, req.url, caches);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (!digits) {
|
|
85
|
+
log.info(
|
|
86
|
+
{ callSid, fromNumber },
|
|
87
|
+
"No digits entered — re-prompting",
|
|
88
|
+
);
|
|
89
|
+
const actionUrl = buildActionUrl(url, attempt);
|
|
90
|
+
return twimlResponse(
|
|
91
|
+
gatherVerificationTwiml(actionUrl, attempt, session.codeDigits ?? 6),
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Validate the entered code
|
|
96
|
+
const result = await validateVerificationCode(
|
|
97
|
+
session,
|
|
98
|
+
digits,
|
|
99
|
+
fromNumber,
|
|
100
|
+
attempt,
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
if (!result.success) {
|
|
104
|
+
if (result.exhausted) {
|
|
105
|
+
log.warn(
|
|
106
|
+
{ callSid, fromNumber, attempt },
|
|
107
|
+
"Voice verification exhausted — hanging up",
|
|
108
|
+
);
|
|
109
|
+
return twimlResponse(failureTwiml(result.failureMessage!));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Re-prompt with incremented attempt
|
|
113
|
+
const nextAttempt = attempt + 1;
|
|
114
|
+
const actionUrl = buildActionUrl(url, nextAttempt);
|
|
115
|
+
return twimlResponse(
|
|
116
|
+
gatherVerificationTwiml(actionUrl, nextAttempt, session.codeDigits ?? 6),
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Verification succeeded — create guardian binding if this is a guardian verification
|
|
121
|
+
if (result.verificationType === "guardian") {
|
|
122
|
+
try {
|
|
123
|
+
// Resolve the canonical principal from the vellum channel guardian binding
|
|
124
|
+
const vellumGuardians = await assistantDbQuery<{
|
|
125
|
+
principalId: string | null;
|
|
126
|
+
}>(
|
|
127
|
+
`SELECT c.principal_id AS principalId
|
|
128
|
+
FROM contacts c
|
|
129
|
+
JOIN contact_channels cc ON cc.contact_id = c.id
|
|
130
|
+
WHERE c.role = 'guardian' AND cc.type = 'vellum' AND cc.status = 'active'
|
|
131
|
+
LIMIT 1`,
|
|
132
|
+
[],
|
|
133
|
+
);
|
|
134
|
+
const canonicalPrincipal =
|
|
135
|
+
vellumGuardians[0]?.principalId ?? fromNumber;
|
|
136
|
+
|
|
137
|
+
// Check for existing phone guardian binding conflict
|
|
138
|
+
const existingPhoneGuardians = await assistantDbQuery<{
|
|
139
|
+
externalUserId: string | null;
|
|
140
|
+
}>(
|
|
141
|
+
`SELECT cc.external_user_id AS externalUserId
|
|
142
|
+
FROM contacts c
|
|
143
|
+
JOIN contact_channels cc ON cc.contact_id = c.id
|
|
144
|
+
WHERE c.role = 'guardian' AND cc.type = 'phone' AND cc.status = 'active'
|
|
145
|
+
LIMIT 1`,
|
|
146
|
+
[],
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
const existingGuardian = existingPhoneGuardians[0];
|
|
150
|
+
if (existingGuardian && existingGuardian.externalUserId !== fromNumber) {
|
|
151
|
+
log.warn(
|
|
152
|
+
{
|
|
153
|
+
callSid,
|
|
154
|
+
fromNumber,
|
|
155
|
+
existingGuardian: existingGuardian.externalUserId,
|
|
156
|
+
},
|
|
157
|
+
"Guardian binding conflict — another user already holds the voice binding",
|
|
158
|
+
);
|
|
159
|
+
} else {
|
|
160
|
+
// Revoke existing phone guardian binding before creating new one
|
|
161
|
+
if (existingGuardian) {
|
|
162
|
+
await revokeExistingPhoneGuardian();
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
await createGuardianBinding({
|
|
166
|
+
channel: "phone",
|
|
167
|
+
externalUserId: fromNumber,
|
|
168
|
+
deliveryChatId: fromNumber,
|
|
169
|
+
guardianPrincipalId: canonicalPrincipal,
|
|
170
|
+
verifiedVia: "challenge",
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
log.info(
|
|
174
|
+
{ callSid, fromNumber, canonicalPrincipal },
|
|
175
|
+
"Guardian phone binding created by gateway",
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
} catch (err) {
|
|
179
|
+
log.error(
|
|
180
|
+
{ err, callSid, fromNumber },
|
|
181
|
+
"Failed to create guardian binding after voice verification",
|
|
182
|
+
);
|
|
183
|
+
// Don't fail the call — the verification succeeded even if binding
|
|
184
|
+
// creation had an issue. The caller should still be connected.
|
|
185
|
+
}
|
|
186
|
+
} else if (result.verificationType === "trusted_contact") {
|
|
187
|
+
try {
|
|
188
|
+
await upsertVerifiedContactChannel({
|
|
189
|
+
sourceChannel: "phone",
|
|
190
|
+
externalUserId: fromNumber,
|
|
191
|
+
externalChatId: fromNumber,
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
log.info(
|
|
195
|
+
{ callSid, fromNumber },
|
|
196
|
+
"Trusted contact phone channel activated by gateway",
|
|
197
|
+
);
|
|
198
|
+
} catch (err) {
|
|
199
|
+
log.error(
|
|
200
|
+
{ err, callSid, fromNumber },
|
|
201
|
+
"Failed to upsert trusted contact channel after voice verification",
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Forward to the assistant for ConversationRelay setup.
|
|
207
|
+
// The assistant will see the caller as already verified.
|
|
208
|
+
log.info(
|
|
209
|
+
{ callSid, fromNumber },
|
|
210
|
+
"Voice verification complete — forwarding to assistant for call setup",
|
|
211
|
+
);
|
|
212
|
+
return forwardToAssistant(config, params, req.url, caches);
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ---------------------------------------------------------------------------
|
|
217
|
+
// Helpers
|
|
218
|
+
// ---------------------------------------------------------------------------
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Revoke the existing phone guardian binding by setting status to 'revoked'.
|
|
222
|
+
* Dual-writes to both assistant DB and gateway DB.
|
|
223
|
+
*/
|
|
224
|
+
async function revokeExistingPhoneGuardian(): Promise<void> {
|
|
225
|
+
const now = Date.now();
|
|
226
|
+
|
|
227
|
+
const revokedRows = await assistantDbQuery<{ id: string }>(
|
|
228
|
+
`SELECT cc.id
|
|
229
|
+
FROM contacts c
|
|
230
|
+
JOIN contact_channels cc ON cc.contact_id = c.id
|
|
231
|
+
WHERE c.role = 'guardian' AND cc.type = 'phone' AND cc.status = 'active'`,
|
|
232
|
+
[],
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
if (revokedRows.length === 0) return;
|
|
236
|
+
|
|
237
|
+
const ids = revokedRows.map((r) => r.id);
|
|
238
|
+
const placeholders = ids.map(() => "?").join(", ");
|
|
239
|
+
|
|
240
|
+
await assistantDbRun(
|
|
241
|
+
`UPDATE contact_channels
|
|
242
|
+
SET status = 'revoked', policy = 'deny', updated_at = ?
|
|
243
|
+
WHERE id IN (${placeholders})`,
|
|
244
|
+
[now, ...ids],
|
|
245
|
+
);
|
|
246
|
+
|
|
247
|
+
// Gateway DB dual-write (best-effort)
|
|
248
|
+
try {
|
|
249
|
+
const gwDb = getGatewayDb();
|
|
250
|
+
for (const id of ids) {
|
|
251
|
+
gwDb.update(gwContactChannels)
|
|
252
|
+
.set({ status: "revoked", policy: "deny", updatedAt: now })
|
|
253
|
+
.where(eq(gwContactChannels.id, id))
|
|
254
|
+
.run();
|
|
255
|
+
}
|
|
256
|
+
} catch (gwErr) {
|
|
257
|
+
log.warn({ err: gwErr }, "Gateway DB revoke dual-write failed (best-effort)");
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Build the action URL for the next Gather attempt, preserving the base
|
|
263
|
+
* path and incrementing the attempt counter.
|
|
264
|
+
*/
|
|
265
|
+
function buildActionUrl(currentUrl: URL, attempt: number): string {
|
|
266
|
+
const actionUrl = new URL(currentUrl.origin + currentUrl.pathname);
|
|
267
|
+
actionUrl.searchParams.set("attempt", String(attempt));
|
|
268
|
+
return actionUrl.pathname + actionUrl.search;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Forward the Twilio voice webhook to the assistant runtime and return
|
|
273
|
+
* the TwiML response (with relay token injection handled by the client).
|
|
274
|
+
*/
|
|
275
|
+
async function forwardToAssistant(
|
|
276
|
+
config: GatewayConfig,
|
|
277
|
+
params: Record<string, string>,
|
|
278
|
+
originalUrl: string,
|
|
279
|
+
caches?: TwilioValidationCaches,
|
|
280
|
+
): Promise<Response> {
|
|
281
|
+
try {
|
|
282
|
+
const platformAssistantId = (
|
|
283
|
+
await caches?.credentials?.get(
|
|
284
|
+
credentialKey("vellum", "platform_assistant_id"),
|
|
285
|
+
)
|
|
286
|
+
)?.trim();
|
|
287
|
+
const runtimeResponse = await forwardTwilioVoiceWebhook(
|
|
288
|
+
config,
|
|
289
|
+
params,
|
|
290
|
+
originalUrl,
|
|
291
|
+
resolvePublicBaseWssUrl(config, caches?.configFile, platformAssistantId),
|
|
292
|
+
);
|
|
293
|
+
return new Response(runtimeResponse.body, {
|
|
294
|
+
status: runtimeResponse.status,
|
|
295
|
+
headers: runtimeResponse.headers,
|
|
296
|
+
});
|
|
297
|
+
} catch (err) {
|
|
298
|
+
if (err instanceof CircuitBreakerOpenError) {
|
|
299
|
+
return Response.json(
|
|
300
|
+
{ error: "Service temporarily unavailable" },
|
|
301
|
+
{
|
|
302
|
+
status: 503,
|
|
303
|
+
headers: { "Retry-After": String(err.retryAfterSecs) },
|
|
304
|
+
},
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
log.error({ err }, "Failed to forward voice webhook to runtime after verification");
|
|
308
|
+
return Response.json({ error: "Internal server error" }, { status: 502 });
|
|
309
|
+
}
|
|
310
|
+
}
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* - Assistant IDs are NOT forwarded to the daemon (daemon uses internal scope).
|
|
12
12
|
*/
|
|
13
13
|
import { describe, test, expect, mock, beforeEach } from "bun:test";
|
|
14
|
+
import { resolvePublicBaseWssUrl } from "../../runtime/client.js";
|
|
14
15
|
|
|
15
16
|
// ── Mocks ──────────────────────────────────────────────────────────────
|
|
16
17
|
|
|
@@ -54,6 +55,11 @@ mock.module("../../logger.js", () => ({
|
|
|
54
55
|
}),
|
|
55
56
|
}));
|
|
56
57
|
|
|
58
|
+
mock.module("../../voice/verification.js", () => ({
|
|
59
|
+
findPendingPhoneSession: async () => null,
|
|
60
|
+
gatherVerificationTwiml: () => "<Response/>",
|
|
61
|
+
}));
|
|
62
|
+
|
|
57
63
|
import { createTwilioVoiceWebhookHandler } from "./twilio-voice-webhook.js";
|
|
58
64
|
import type { GatewayConfig } from "../../config.js";
|
|
59
65
|
import type { ConfigFileCache } from "../../config-file-cache.js";
|
|
@@ -77,7 +83,6 @@ const baseConfig: GatewayConfig = {
|
|
|
77
83
|
routingEntries: [],
|
|
78
84
|
runtimeInitialBackoffMs: 500,
|
|
79
85
|
runtimeMaxRetries: 2,
|
|
80
|
-
runtimeProxyEnabled: false,
|
|
81
86
|
runtimeProxyRequireAuth: true,
|
|
82
87
|
runtimeTimeoutMs: 30000,
|
|
83
88
|
shutdownDrainMs: 5000,
|
|
@@ -282,3 +287,62 @@ describe("twilio voice webhook handler", () => {
|
|
|
282
287
|
expect(forwardCalled).toBe(true);
|
|
283
288
|
});
|
|
284
289
|
});
|
|
290
|
+
|
|
291
|
+
// ── resolvePublicBaseWssUrl unit tests ─────────────────────────────────
|
|
292
|
+
|
|
293
|
+
describe("resolvePublicBaseWssUrl", () => {
|
|
294
|
+
const baseConfig = {
|
|
295
|
+
assistantRuntimeBaseUrl: "http://localhost:3000",
|
|
296
|
+
} as Parameters<typeof resolvePublicBaseWssUrl>[0];
|
|
297
|
+
|
|
298
|
+
test("returns undefined when neither velayBaseUrl nor configFile publicBaseUrl is set", () => {
|
|
299
|
+
expect(resolvePublicBaseWssUrl(baseConfig)).toBeUndefined();
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
test("uses velayBaseUrl + platformAssistantId when both are present", () => {
|
|
303
|
+
const config = {
|
|
304
|
+
...baseConfig,
|
|
305
|
+
velayBaseUrl: "https://velay-dev.vellum.ai",
|
|
306
|
+
};
|
|
307
|
+
const result = resolvePublicBaseWssUrl(
|
|
308
|
+
config,
|
|
309
|
+
undefined,
|
|
310
|
+
"abc12345-0000-0000-0000-000000000000",
|
|
311
|
+
);
|
|
312
|
+
expect(result).toBe(
|
|
313
|
+
"wss://velay-dev.vellum.ai/abc12345-0000-0000-0000-000000000000",
|
|
314
|
+
);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
test("falls back to configFile publicBaseUrl when velayBaseUrl is set but platformAssistantId is missing", () => {
|
|
318
|
+
const config = {
|
|
319
|
+
...baseConfig,
|
|
320
|
+
velayBaseUrl: "https://velay-dev.vellum.ai",
|
|
321
|
+
};
|
|
322
|
+
const mockConfigFile = {
|
|
323
|
+
getString: (section: string, key: string) =>
|
|
324
|
+
section === "ingress" && key === "publicBaseUrl"
|
|
325
|
+
? "https://velay-dev.vellum.ai/abc12345-0000-0000-0000-000000000000"
|
|
326
|
+
: undefined,
|
|
327
|
+
} as Parameters<typeof resolvePublicBaseWssUrl>[1];
|
|
328
|
+
const result = resolvePublicBaseWssUrl(config, mockConfigFile, undefined);
|
|
329
|
+
expect(result).toBe(
|
|
330
|
+
"wss://velay-dev.vellum.ai/abc12345-0000-0000-0000-000000000000",
|
|
331
|
+
);
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
test("strips trailing slash from velayBaseUrl before joining assistant ID", () => {
|
|
335
|
+
const config = {
|
|
336
|
+
...baseConfig,
|
|
337
|
+
velayBaseUrl: "https://velay-dev.vellum.ai/",
|
|
338
|
+
};
|
|
339
|
+
const result = resolvePublicBaseWssUrl(
|
|
340
|
+
config,
|
|
341
|
+
undefined,
|
|
342
|
+
"abc12345-0000-0000-0000-000000000000",
|
|
343
|
+
);
|
|
344
|
+
expect(result).toBe(
|
|
345
|
+
"wss://velay-dev.vellum.ai/abc12345-0000-0000-0000-000000000000",
|
|
346
|
+
);
|
|
347
|
+
});
|
|
348
|
+
});
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import type { ConfigFileCache } from "../../config-file-cache.js";
|
|
2
2
|
import type { GatewayConfig } from "../../config.js";
|
|
3
|
+
import { credentialKey } from "../../credential-key.js";
|
|
3
4
|
import { getLogger } from "../../logger.js";
|
|
4
5
|
import {
|
|
5
6
|
CircuitBreakerOpenError,
|
|
6
7
|
forwardTwilioVoiceWebhook,
|
|
8
|
+
resolvePublicBaseWssUrl,
|
|
7
9
|
} from "../../runtime/client.js";
|
|
8
10
|
import {
|
|
9
11
|
resolveAssistant,
|
|
@@ -14,6 +16,10 @@ import {
|
|
|
14
16
|
validateTwilioWebhookRequest,
|
|
15
17
|
type TwilioValidationCaches,
|
|
16
18
|
} from "../../twilio/validate-webhook.js";
|
|
19
|
+
import {
|
|
20
|
+
findPendingPhoneSession,
|
|
21
|
+
gatherVerificationTwiml,
|
|
22
|
+
} from "../../voice/verification.js";
|
|
17
23
|
|
|
18
24
|
const log = getLogger("twilio-voice-webhook");
|
|
19
25
|
|
|
@@ -21,6 +27,8 @@ const log = getLogger("twilio-voice-webhook");
|
|
|
21
27
|
const REJECT_TWIML =
|
|
22
28
|
'<?xml version="1.0" encoding="UTF-8"?><Response><Reject reason="rejected"/></Response>';
|
|
23
29
|
|
|
30
|
+
const TWIML_HEADERS = { "Content-Type": "text/xml" };
|
|
31
|
+
|
|
24
32
|
export function createTwilioVoiceWebhookHandler(
|
|
25
33
|
config: GatewayConfig,
|
|
26
34
|
caches?: TwilioValidationCaches & { configFile?: ConfigFileCache },
|
|
@@ -71,7 +79,7 @@ export function createTwilioVoiceWebhookHandler(
|
|
|
71
79
|
);
|
|
72
80
|
return new Response(REJECT_TWIML, {
|
|
73
81
|
status: 200,
|
|
74
|
-
headers:
|
|
82
|
+
headers: TWIML_HEADERS,
|
|
75
83
|
});
|
|
76
84
|
}
|
|
77
85
|
|
|
@@ -85,13 +93,49 @@ export function createTwilioVoiceWebhookHandler(
|
|
|
85
93
|
"Resolved assistant via fallback routing for inbound call",
|
|
86
94
|
);
|
|
87
95
|
}
|
|
96
|
+
|
|
97
|
+
// ── Gateway-owned voice verification ────────────────────────────
|
|
98
|
+
// For inbound calls, check if there's a pending phone verification
|
|
99
|
+
// session. If so, intercept the call with a <Gather> TwiML flow
|
|
100
|
+
// instead of forwarding to the assistant. The assistant never
|
|
101
|
+
// touches verification — it only receives verified calls.
|
|
102
|
+
try {
|
|
103
|
+
const pendingSession = await findPendingPhoneSession();
|
|
104
|
+
if (pendingSession) {
|
|
105
|
+
log.info(
|
|
106
|
+
{
|
|
107
|
+
callSid: params.CallSid,
|
|
108
|
+
fromNumber: params.From,
|
|
109
|
+
sessionId: pendingSession.id,
|
|
110
|
+
},
|
|
111
|
+
"Pending phone verification session found — intercepting with gateway verification",
|
|
112
|
+
);
|
|
113
|
+
const verifyCallbackPath = `/webhooks/twilio/voice-verify?attempt=0`;
|
|
114
|
+
const codeDigits = pendingSession.codeDigits ?? 6;
|
|
115
|
+
return new Response(
|
|
116
|
+
gatherVerificationTwiml(verifyCallbackPath, 0, codeDigits),
|
|
117
|
+
{ status: 200, headers: TWIML_HEADERS },
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
} catch (err) {
|
|
121
|
+
log.warn(
|
|
122
|
+
{ err, callSid: params.CallSid },
|
|
123
|
+
"Failed to check pending verification session — falling through to assistant",
|
|
124
|
+
);
|
|
125
|
+
}
|
|
88
126
|
}
|
|
89
127
|
|
|
90
128
|
try {
|
|
129
|
+
const platformAssistantId = (
|
|
130
|
+
await caches?.credentials?.get(
|
|
131
|
+
credentialKey("vellum", "platform_assistant_id"),
|
|
132
|
+
)
|
|
133
|
+
)?.trim();
|
|
91
134
|
const runtimeResponse = await forwardTwilioVoiceWebhook(
|
|
92
135
|
config,
|
|
93
136
|
params,
|
|
94
137
|
req.url,
|
|
138
|
+
resolvePublicBaseWssUrl(config, caches?.configFile, platformAssistantId),
|
|
95
139
|
);
|
|
96
140
|
return new Response(runtimeResponse.body, {
|
|
97
141
|
status: runtimeResponse.status,
|