@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
@@ -0,0 +1,456 @@
1
+ /**
2
+ * Gateway-owned voice verification.
3
+ *
4
+ * Handles the DTMF challenge-response flow for inbound phone calls
5
+ * entirely within the gateway, BEFORE the ConversationRelay WebSocket
6
+ * is established. The assistant never touches verification — it only
7
+ * receives calls from verified callers.
8
+ *
9
+ * Flow:
10
+ * 1. Twilio voice webhook → gateway detects pending verification session
11
+ * 2. Gateway returns <Gather> TwiML prompting for the verification code
12
+ * 3. Twilio collects DTMF → POSTs digits back to gateway action URL
13
+ * 4. Gateway validates code, creates guardian binding, returns TwiML
14
+ * that forwards to the assistant for ConversationRelay setup
15
+ *
16
+ * Verification sessions are read from the assistant DB (via IPC proxy)
17
+ * because the session creation still happens on the assistant side (the
18
+ * guardian initiates verification through chat channels).
19
+ */
20
+
21
+ import { createHash } from "node:crypto";
22
+
23
+ import { and, eq } from "drizzle-orm";
24
+
25
+ import {
26
+ assistantDbQuery,
27
+ assistantDbRun,
28
+ } from "../db/assistant-db-proxy.js";
29
+ import { getGatewayDb } from "../db/connection.js";
30
+ import { channelGuardianRateLimits as gwRateLimits } from "../db/schema.js";
31
+ import { getLogger } from "../logger.js";
32
+
33
+ const log = getLogger("voice-verification");
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // Constants
37
+ // ---------------------------------------------------------------------------
38
+
39
+ const MAX_ATTEMPTS = 3;
40
+ const RATE_LIMIT_MAX_ATTEMPTS = 5;
41
+ const RATE_LIMIT_WINDOW_MS = 15 * 60 * 1000;
42
+ const RATE_LIMIT_LOCKOUT_MS = 30 * 60 * 1000;
43
+
44
+ // ---------------------------------------------------------------------------
45
+ // Types
46
+ // ---------------------------------------------------------------------------
47
+
48
+ interface PendingSession {
49
+ id: string;
50
+ challengeHash: string;
51
+ expiresAt: number;
52
+ status: string;
53
+ verificationPurpose: string;
54
+ expectedExternalUserId: string | null;
55
+ expectedChatId: string | null;
56
+ expectedPhoneE164: string | null;
57
+ identityBindingStatus: string | null;
58
+ codeDigits: number;
59
+ maxAttempts: number;
60
+ }
61
+
62
+ interface RateLimitRecord {
63
+ attemptTimestampsJson: string;
64
+ lockedUntil: number | null;
65
+ }
66
+
67
+ export interface VoiceVerificationResult {
68
+ /** Whether a pending verification session exists for the phone channel. */
69
+ hasPendingSession: boolean;
70
+ /** The pending session details (only set when hasPendingSession is true). */
71
+ session?: PendingSession;
72
+ }
73
+
74
+ export interface CodeValidationResult {
75
+ success: boolean;
76
+ verificationType?: "guardian" | "trusted_contact";
77
+ /** Error message for TTS playback on failure. */
78
+ failureMessage?: string;
79
+ /** Whether the caller has exhausted all attempts. */
80
+ exhausted?: boolean;
81
+ }
82
+
83
+ // ---------------------------------------------------------------------------
84
+ // Helpers
85
+ // ---------------------------------------------------------------------------
86
+
87
+ function hashSecret(secret: string): string {
88
+ return createHash("sha256").update(secret).digest("hex");
89
+ }
90
+
91
+ // ---------------------------------------------------------------------------
92
+ // Session lookup
93
+ // ---------------------------------------------------------------------------
94
+
95
+ /**
96
+ * Check if there is a pending phone verification session.
97
+ * Reads from the assistant's channel_verification_sessions table.
98
+ */
99
+ export async function findPendingPhoneSession(): Promise<PendingSession | null> {
100
+ const now = Date.now();
101
+ const rows = await assistantDbQuery<PendingSession>(
102
+ `SELECT id, challenge_hash AS challengeHash, expires_at AS expiresAt,
103
+ status, verification_purpose AS verificationPurpose,
104
+ expected_external_user_id AS expectedExternalUserId,
105
+ expected_chat_id AS expectedChatId,
106
+ expected_phone_e164 AS expectedPhoneE164,
107
+ identity_binding_status AS identityBindingStatus,
108
+ code_digits AS codeDigits, max_attempts AS maxAttempts
109
+ FROM channel_verification_sessions
110
+ WHERE channel = 'phone'
111
+ AND status IN ('pending', 'pending_bootstrap')
112
+ AND expires_at > ?
113
+ ORDER BY created_at DESC
114
+ LIMIT 1`,
115
+ [now],
116
+ );
117
+
118
+ return rows[0] ?? null;
119
+ }
120
+
121
+ // ---------------------------------------------------------------------------
122
+ // Rate limiting (gateway DB primary, assistant DB dual-write)
123
+ // ---------------------------------------------------------------------------
124
+
125
+ function parseTimestamps(json: string): number[] {
126
+ try {
127
+ const arr = JSON.parse(json);
128
+ return Array.isArray(arr) ? arr : [];
129
+ } catch {
130
+ return [];
131
+ }
132
+ }
133
+
134
+ function getRateLimit(
135
+ fromNumber: string,
136
+ ): RateLimitRecord | null {
137
+ const gwDb = getGatewayDb();
138
+ const row = gwDb
139
+ .select()
140
+ .from(gwRateLimits)
141
+ .where(
142
+ and(
143
+ eq(gwRateLimits.channel, "phone"),
144
+ eq(gwRateLimits.actorExternalUserId, fromNumber),
145
+ eq(gwRateLimits.actorChatId, fromNumber),
146
+ ),
147
+ )
148
+ .get();
149
+
150
+ return row
151
+ ? { attemptTimestampsJson: row.attemptTimestampsJson, lockedUntil: row.lockedUntil }
152
+ : null;
153
+ }
154
+
155
+ async function recordInvalidAttempt(fromNumber: string): Promise<void> {
156
+ const now = Date.now();
157
+ const cutoff = now - RATE_LIMIT_WINDOW_MS;
158
+
159
+ const existing = getRateLimit(fromNumber);
160
+ const recentTimestamps = existing
161
+ ? parseTimestamps(existing.attemptTimestampsJson).filter((ts) => ts > cutoff)
162
+ : [];
163
+ recentTimestamps.push(now);
164
+
165
+ const timestampsJson = JSON.stringify(recentTimestamps);
166
+ const newLockedUntil =
167
+ recentTimestamps.length >= RATE_LIMIT_MAX_ATTEMPTS
168
+ ? now + RATE_LIMIT_LOCKOUT_MS
169
+ : existing?.lockedUntil ?? null;
170
+
171
+ // Gateway DB — atomic upsert
172
+ const gwDb = getGatewayDb();
173
+ gwDb.insert(gwRateLimits)
174
+ .values({
175
+ id: crypto.randomUUID(),
176
+ channel: "phone",
177
+ actorExternalUserId: fromNumber,
178
+ actorChatId: fromNumber,
179
+ attemptTimestampsJson: timestampsJson,
180
+ lockedUntil: newLockedUntil,
181
+ createdAt: now,
182
+ updatedAt: now,
183
+ })
184
+ .onConflictDoUpdate({
185
+ target: [gwRateLimits.channel, gwRateLimits.actorExternalUserId, gwRateLimits.actorChatId],
186
+ set: {
187
+ attemptTimestampsJson: timestampsJson,
188
+ lockedUntil: newLockedUntil,
189
+ updatedAt: now,
190
+ },
191
+ })
192
+ .run();
193
+
194
+ // Assistant DB dual-write — atomic upsert via ON CONFLICT
195
+ try {
196
+ await assistantDbRun(
197
+ `INSERT INTO channel_guardian_rate_limits
198
+ (id, channel, actor_external_user_id, actor_chat_id,
199
+ attempt_timestamps_json, locked_until, created_at, updated_at)
200
+ VALUES (?, 'phone', ?, ?, ?, ?, ?, ?)
201
+ ON CONFLICT (channel, actor_external_user_id, actor_chat_id) DO UPDATE SET
202
+ attempt_timestamps_json = excluded.attempt_timestamps_json,
203
+ locked_until = excluded.locked_until,
204
+ updated_at = excluded.updated_at`,
205
+ [
206
+ crypto.randomUUID(),
207
+ fromNumber,
208
+ fromNumber,
209
+ timestampsJson,
210
+ newLockedUntil,
211
+ now,
212
+ now,
213
+ ],
214
+ );
215
+ } catch (err) {
216
+ log.warn({ err }, "Assistant DB rate limit dual-write failed (best-effort)");
217
+ }
218
+ }
219
+
220
+ async function resetRateLimit(fromNumber: string): Promise<void> {
221
+ const now = Date.now();
222
+
223
+ // Gateway DB
224
+ const gwDb = getGatewayDb();
225
+ gwDb.update(gwRateLimits)
226
+ .set({
227
+ attemptTimestampsJson: "[]",
228
+ lockedUntil: null,
229
+ updatedAt: now,
230
+ })
231
+ .where(
232
+ and(
233
+ eq(gwRateLimits.channel, "phone"),
234
+ eq(gwRateLimits.actorExternalUserId, fromNumber),
235
+ eq(gwRateLimits.actorChatId, fromNumber),
236
+ ),
237
+ )
238
+ .run();
239
+
240
+ // Assistant DB dual-write
241
+ try {
242
+ await assistantDbRun(
243
+ `UPDATE channel_guardian_rate_limits
244
+ SET attempt_timestamps_json = '[]', locked_until = NULL, updated_at = ?
245
+ WHERE channel = 'phone'
246
+ AND actor_external_user_id = ?
247
+ AND actor_chat_id = ?`,
248
+ [now, fromNumber, fromNumber],
249
+ );
250
+ } catch (err) {
251
+ log.warn({ err }, "Assistant DB rate limit reset dual-write failed (best-effort)");
252
+ }
253
+ }
254
+
255
+ // ---------------------------------------------------------------------------
256
+ // Session consumption
257
+ // ---------------------------------------------------------------------------
258
+
259
+ async function consumeSession(
260
+ sessionId: string,
261
+ fromNumber: string,
262
+ ): Promise<void> {
263
+ const now = Date.now();
264
+ await assistantDbRun(
265
+ `UPDATE channel_verification_sessions
266
+ SET status = 'consumed',
267
+ consumed_by_external_user_id = ?,
268
+ consumed_by_chat_id = ?,
269
+ updated_at = ?
270
+ WHERE id = ?`,
271
+ [fromNumber, fromNumber, now, sessionId],
272
+ );
273
+ }
274
+
275
+ // ---------------------------------------------------------------------------
276
+ // Code validation
277
+ // ---------------------------------------------------------------------------
278
+
279
+ /**
280
+ * Validate a DTMF-entered verification code against the pending session.
281
+ *
282
+ * On success: consumes the session so it cannot be reused, resets rate
283
+ * limits, and returns the verification type (guardian vs trusted_contact).
284
+ *
285
+ * On failure: records an invalid attempt for rate limiting and returns
286
+ * a failure message suitable for TTS playback.
287
+ */
288
+ export async function validateVerificationCode(
289
+ session: PendingSession,
290
+ enteredCode: string,
291
+ fromNumber: string,
292
+ attempt: number,
293
+ ): Promise<CodeValidationResult> {
294
+ // Rate limit check
295
+ const rateLimit = await getRateLimit(fromNumber);
296
+ if (rateLimit?.lockedUntil && Date.now() < rateLimit.lockedUntil) {
297
+ return {
298
+ success: false,
299
+ failureMessage:
300
+ "Too many invalid attempts. Please try again later. Goodbye.",
301
+ exhausted: true,
302
+ };
303
+ }
304
+
305
+ // Expiry check
306
+ if (Date.now() > session.expiresAt) {
307
+ return {
308
+ success: false,
309
+ failureMessage:
310
+ "The verification code has expired. Please request a new code. Goodbye.",
311
+ exhausted: true,
312
+ };
313
+ }
314
+
315
+ // Hash the entered code and compare
316
+ const enteredHash = hashSecret(enteredCode);
317
+ if (enteredHash !== session.challengeHash) {
318
+ await recordInvalidAttempt(fromNumber);
319
+
320
+ if (attempt + 1 >= MAX_ATTEMPTS) {
321
+ return {
322
+ success: false,
323
+ failureMessage: "Verification failed. Goodbye.",
324
+ exhausted: true,
325
+ };
326
+ }
327
+
328
+ const remaining = MAX_ATTEMPTS - attempt - 1;
329
+ return {
330
+ success: false,
331
+ failureMessage: `Incorrect code. You have ${remaining} ${remaining === 1 ? "attempt" : "attempts"} remaining. Please try again.`,
332
+ exhausted: false,
333
+ };
334
+ }
335
+
336
+ // Identity check for bound outbound sessions
337
+ const hasExpectedIdentity =
338
+ session.expectedExternalUserId != null ||
339
+ session.expectedChatId != null ||
340
+ session.expectedPhoneE164 != null;
341
+
342
+ if (hasExpectedIdentity && session.identityBindingStatus === "bound") {
343
+ let identityMatch = false;
344
+
345
+ if (session.expectedPhoneE164 != null) {
346
+ if (
347
+ fromNumber === session.expectedPhoneE164 ||
348
+ fromNumber === session.expectedExternalUserId
349
+ ) {
350
+ identityMatch = true;
351
+ }
352
+ }
353
+
354
+ if (!identityMatch && session.expectedChatId != null) {
355
+ if (session.expectedExternalUserId != null) {
356
+ if (fromNumber === session.expectedExternalUserId) {
357
+ identityMatch = true;
358
+ }
359
+ } else if (fromNumber === session.expectedChatId) {
360
+ identityMatch = true;
361
+ }
362
+ }
363
+
364
+ if (
365
+ !identityMatch &&
366
+ session.expectedPhoneE164 == null &&
367
+ session.expectedChatId == null &&
368
+ session.expectedExternalUserId != null
369
+ ) {
370
+ if (fromNumber === session.expectedExternalUserId) {
371
+ identityMatch = true;
372
+ }
373
+ }
374
+
375
+ if (!identityMatch) {
376
+ await recordInvalidAttempt(fromNumber);
377
+ if (attempt + 1 >= MAX_ATTEMPTS) {
378
+ return {
379
+ success: false,
380
+ failureMessage: "Verification failed. Goodbye.",
381
+ exhausted: true,
382
+ };
383
+ }
384
+ const remaining = MAX_ATTEMPTS - attempt - 1;
385
+ return {
386
+ success: false,
387
+ failureMessage: `Incorrect code. You have ${remaining} ${remaining === 1 ? "attempt" : "attempts"} remaining. Please try again.`,
388
+ exhausted: false,
389
+ };
390
+ }
391
+ }
392
+
393
+ // Success — consume session and reset rate limits
394
+ await consumeSession(session.id, fromNumber);
395
+ await resetRateLimit(fromNumber);
396
+
397
+ const verificationType: "guardian" | "trusted_contact" =
398
+ session.verificationPurpose === "trusted_contact"
399
+ ? "trusted_contact"
400
+ : "guardian";
401
+
402
+ log.info(
403
+ { sessionId: session.id, fromNumber, verificationType },
404
+ "Voice verification succeeded at gateway",
405
+ );
406
+
407
+ return { success: true, verificationType };
408
+ }
409
+
410
+ // ---------------------------------------------------------------------------
411
+ // TwiML generation
412
+ // ---------------------------------------------------------------------------
413
+
414
+ /**
415
+ * Generate <Gather> TwiML that prompts the caller for their verification code.
416
+ *
417
+ * The `action` URL points back to the gateway's verification callback
418
+ * endpoint, which will validate the code and either re-prompt or proceed.
419
+ */
420
+ export function gatherVerificationTwiml(
421
+ actionUrl: string,
422
+ attempt: number,
423
+ codeDigits: number,
424
+ ): string {
425
+ const prompt =
426
+ attempt === 0
427
+ ? "Welcome. Please enter your verification code using your keypad."
428
+ : "Please try again. Enter your verification code using your keypad.";
429
+
430
+ return `<?xml version="1.0" encoding="UTF-8"?>
431
+ <Response>
432
+ <Gather numDigits="${codeDigits}" action="${escapeXml(actionUrl)}" method="POST" timeout="30" finishOnKey="">
433
+ <Say>${escapeXml(prompt)}</Say>
434
+ </Gather>
435
+ <Say>We did not receive any input. Goodbye.</Say>
436
+ </Response>`;
437
+ }
438
+
439
+ /**
440
+ * Generate TwiML that speaks a failure message and hangs up.
441
+ */
442
+ export function failureTwiml(message: string): string {
443
+ return `<?xml version="1.0" encoding="UTF-8"?>
444
+ <Response>
445
+ <Say>${escapeXml(message)}</Say>
446
+ </Response>`;
447
+ }
448
+
449
+ function escapeXml(str: string): string {
450
+ return str
451
+ .replace(/&/g, "&amp;")
452
+ .replace(/</g, "&lt;")
453
+ .replace(/>/g, "&gt;")
454
+ .replace(/"/g, "&quot;")
455
+ .replace(/'/g, "&apos;");
456
+ }
@@ -90,6 +90,10 @@ export function processInboundResult(
90
90
  return { ok: true, rejected: true };
91
91
  }
92
92
 
93
+ if (result.verificationIntercepted) {
94
+ return { ok: true, rejected: false };
95
+ }
96
+
93
97
  if (!result.forwarded) {
94
98
  logger.error({ cacheKey }, "Failed to forward message to runtime");
95
99
  dedupCache.unreserve(cacheKey);