@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
|
@@ -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, "&")
|
|
452
|
+
.replace(/</g, "<")
|
|
453
|
+
.replace(/>/g, ">")
|
|
454
|
+
.replace(/"/g, """)
|
|
455
|
+
.replace(/'/g, "'");
|
|
456
|
+
}
|
package/src/webhook-pipeline.ts
CHANGED
|
@@ -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);
|