@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
@@ -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). Unlike
60
- // browser-relay, STT streaming does not have an auth-bypass mode —
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 remain available even when the broad runtime proxy is
5
- * disabled, so skills and clients can use gateway URLs exclusively.
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";
@@ -72,7 +72,6 @@ const baseConfig: GatewayConfig = {
72
72
  routingEntries: [],
73
73
  runtimeInitialBackoffMs: 500,
74
74
  runtimeMaxRetries: 2,
75
- runtimeProxyEnabled: false,
76
75
  runtimeProxyRequireAuth: true,
77
76
  runtimeTimeoutMs: 30000,
78
77
  shutdownDrainMs: 5000,
@@ -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,
@@ -47,6 +47,13 @@ const SuggestRequestSchema = z.object({
47
47
  )
48
48
  .optional(),
49
49
  intent: z.enum(["auto_approve", "escalate"]),
50
+ existingRule: z
51
+ .object({
52
+ id: z.string(),
53
+ pattern: z.string(),
54
+ risk: z.string(),
55
+ })
56
+ .optional(),
50
57
  });
51
58
 
52
59
  /**
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Gateway proxy endpoints for Twilio integration control-plane routes.
3
3
  *
4
- * These routes remain available even when the broad runtime proxy is
5
- * disabled, so skills and clients can use gateway URLs exclusively.
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(MEDIA_STREAM_PATH_PREFIX + "/")) {
48
- const suffix = url.pathname.slice(MEDIA_STREAM_PATH_PREFIX.length + 1);
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: { "Content-Type": "text/xml" },
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,
@@ -93,7 +93,6 @@ const baseConfig: GatewayConfig = {
93
93
  routingEntries: [],
94
94
  runtimeInitialBackoffMs: 500,
95
95
  runtimeMaxRetries: 2,
96
- runtimeProxyEnabled: false,
97
96
  runtimeProxyRequireAuth: true,
98
97
  runtimeTimeoutMs: 30000,
99
98
  shutdownDrainMs: 5000,