@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,205 @@
1
+ /**
2
+ * Rate limiting helpers for gateway-owned verification.
3
+ *
4
+ * Gateway DB is the primary store; assistant DB gets best-effort dual-writes.
5
+ * Uses atomic upserts (ON CONFLICT) to handle concurrent webhook deliveries.
6
+ */
7
+
8
+ import { and, eq, sql } from "drizzle-orm";
9
+
10
+ import { assistantDbRun } from "../db/assistant-db-proxy.js";
11
+ import { getGatewayDb } from "../db/connection.js";
12
+ import { channelGuardianRateLimits as gwRateLimits } from "../db/schema.js";
13
+ import { getLogger } from "../logger.js";
14
+
15
+ const log = getLogger("verification-rate-limits");
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // Constants
19
+ // ---------------------------------------------------------------------------
20
+
21
+ const RATE_LIMIT_MAX_ATTEMPTS = 5;
22
+ const RATE_LIMIT_WINDOW_MS = 15 * 60 * 1000;
23
+ const RATE_LIMIT_LOCKOUT_MS = 30 * 60 * 1000;
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // Types
27
+ // ---------------------------------------------------------------------------
28
+
29
+ interface RateLimitRecord {
30
+ attemptTimestampsJson: string;
31
+ lockedUntil: number | null;
32
+ }
33
+
34
+ // ---------------------------------------------------------------------------
35
+ // Helpers
36
+ // ---------------------------------------------------------------------------
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // Read
40
+ // ---------------------------------------------------------------------------
41
+
42
+ export function getRateLimit(
43
+ channel: string,
44
+ actorExternalUserId: string,
45
+ actorChatId: string,
46
+ ): RateLimitRecord | null {
47
+ const gwDb = getGatewayDb();
48
+ const row = gwDb
49
+ .select()
50
+ .from(gwRateLimits)
51
+ .where(
52
+ and(
53
+ eq(gwRateLimits.channel, channel),
54
+ eq(gwRateLimits.actorExternalUserId, actorExternalUserId),
55
+ eq(gwRateLimits.actorChatId, actorChatId),
56
+ ),
57
+ )
58
+ .get();
59
+
60
+ return row
61
+ ? { attemptTimestampsJson: row.attemptTimestampsJson, lockedUntil: row.lockedUntil }
62
+ : null;
63
+ }
64
+
65
+ /**
66
+ * Returns true if the actor is currently locked out.
67
+ */
68
+ export function isRateLimited(
69
+ channel: string,
70
+ actorExternalUserId: string,
71
+ actorChatId: string,
72
+ ): boolean {
73
+ const record = getRateLimit(channel, actorExternalUserId, actorChatId);
74
+ return record?.lockedUntil != null && Date.now() < record.lockedUntil;
75
+ }
76
+
77
+ // ---------------------------------------------------------------------------
78
+ // Write
79
+ // ---------------------------------------------------------------------------
80
+
81
+ /**
82
+ * Record an invalid verification attempt. Uses a single atomic SQL UPDATE
83
+ * with json_array + json_each to prune old timestamps and append the new
84
+ * one in one statement, avoiding the read-modify-write race where
85
+ * concurrent calls could overwrite each other's timestamps.
86
+ *
87
+ * For new records (no existing row), falls back to INSERT with a single
88
+ * timestamp.
89
+ */
90
+ export async function recordInvalidAttempt(
91
+ channel: string,
92
+ actorExternalUserId: string,
93
+ actorChatId: string,
94
+ ): Promise<void> {
95
+ const now = Date.now();
96
+ const cutoff = now - RATE_LIMIT_WINDOW_MS;
97
+
98
+ // Gateway DB — atomic upsert with in-SQL JSON manipulation.
99
+ // The ON CONFLICT UPDATE prunes expired timestamps and appends the new
100
+ // one in a single statement, so concurrent upserts serialize at the
101
+ // row level (SQLite's write lock) without stale reads.
102
+ const gwDb = getGatewayDb();
103
+ const newId = crypto.randomUUID();
104
+ const singleTimestampJson = JSON.stringify([now]);
105
+
106
+ gwDb.run(sql`
107
+ INSERT INTO ${gwRateLimits} (
108
+ id, channel, actor_external_user_id, actor_chat_id,
109
+ attempt_timestamps_json, locked_until, created_at, updated_at
110
+ ) VALUES (
111
+ ${newId}, ${channel}, ${actorExternalUserId}, ${actorChatId},
112
+ ${singleTimestampJson}, NULL, ${now}, ${now}
113
+ )
114
+ ON CONFLICT (channel, actor_external_user_id, actor_chat_id) DO UPDATE SET
115
+ attempt_timestamps_json = (
116
+ SELECT json_group_array(value) FROM (
117
+ SELECT value FROM json_each(${gwRateLimits.attemptTimestampsJson})
118
+ WHERE CAST(value AS INTEGER) > ${cutoff}
119
+ UNION ALL
120
+ SELECT ${now}
121
+ )
122
+ ),
123
+ locked_until = CASE
124
+ WHEN (
125
+ SELECT COUNT(*) FROM (
126
+ SELECT value FROM json_each(${gwRateLimits.attemptTimestampsJson})
127
+ WHERE CAST(value AS INTEGER) > ${cutoff}
128
+ UNION ALL
129
+ SELECT ${now}
130
+ )
131
+ ) >= ${RATE_LIMIT_MAX_ATTEMPTS}
132
+ THEN ${now + RATE_LIMIT_LOCKOUT_MS}
133
+ ELSE ${gwRateLimits.lockedUntil}
134
+ END,
135
+ updated_at = ${now}
136
+ `);
137
+
138
+ // Read back for assistant DB dual-write
139
+ const updated = getRateLimit(channel, actorExternalUserId, actorChatId);
140
+ const timestampsJson = updated?.attemptTimestampsJson ?? singleTimestampJson;
141
+ const lockedUntil = updated?.lockedUntil ?? null;
142
+
143
+ // Assistant DB dual-write
144
+ try {
145
+ await assistantDbRun(
146
+ `INSERT INTO channel_guardian_rate_limits
147
+ (id, channel, actor_external_user_id, actor_chat_id,
148
+ attempt_timestamps_json, locked_until, created_at, updated_at)
149
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
150
+ ON CONFLICT (channel, actor_external_user_id, actor_chat_id) DO UPDATE SET
151
+ attempt_timestamps_json = excluded.attempt_timestamps_json,
152
+ locked_until = excluded.locked_until,
153
+ updated_at = excluded.updated_at`,
154
+ [
155
+ crypto.randomUUID(),
156
+ channel,
157
+ actorExternalUserId,
158
+ actorChatId,
159
+ timestampsJson,
160
+ lockedUntil,
161
+ now,
162
+ now,
163
+ ],
164
+ );
165
+ } catch (err) {
166
+ log.warn({ err }, "Assistant DB rate limit dual-write failed (best-effort)");
167
+ }
168
+ }
169
+
170
+ export async function resetRateLimit(
171
+ channel: string,
172
+ actorExternalUserId: string,
173
+ actorChatId: string,
174
+ ): Promise<void> {
175
+ const now = Date.now();
176
+
177
+ const gwDb = getGatewayDb();
178
+ gwDb.update(gwRateLimits)
179
+ .set({
180
+ attemptTimestampsJson: "[]",
181
+ lockedUntil: null,
182
+ updatedAt: now,
183
+ })
184
+ .where(
185
+ and(
186
+ eq(gwRateLimits.channel, channel),
187
+ eq(gwRateLimits.actorExternalUserId, actorExternalUserId),
188
+ eq(gwRateLimits.actorChatId, actorChatId),
189
+ ),
190
+ )
191
+ .run();
192
+
193
+ try {
194
+ await assistantDbRun(
195
+ `UPDATE channel_guardian_rate_limits
196
+ SET attempt_timestamps_json = '[]', locked_until = NULL, updated_at = ?
197
+ WHERE channel = ?
198
+ AND actor_external_user_id = ?
199
+ AND actor_chat_id = ?`,
200
+ [now, channel, actorExternalUserId, actorChatId],
201
+ );
202
+ } catch (err) {
203
+ log.warn({ err }, "Assistant DB rate limit reset dual-write failed (best-effort)");
204
+ }
205
+ }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Verification reply delivery for gateway-owned text-channel verification.
3
+ *
4
+ * Delivers deterministic template-driven replies to the originating channel
5
+ * via the replyCallbackUrl (the gateway's own /deliver/* endpoint). This
6
+ * keeps verification replies entirely within the gateway — the assistant
7
+ * never sees verification code messages.
8
+ */
9
+
10
+ import { fetchImpl } from "../fetch.js";
11
+ import { getLogger } from "../logger.js";
12
+
13
+ const log = getLogger("verification-reply");
14
+
15
+ const DELIVERY_TIMEOUT_MS = 10_000;
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // Reply templates (mirrors assistant's verification-templates.ts)
19
+ // ---------------------------------------------------------------------------
20
+
21
+ export function composeVerificationSuccessReply(
22
+ verificationType?: "guardian" | "trusted_contact",
23
+ ): string {
24
+ if (verificationType === "trusted_contact") {
25
+ return "Verification successful! You can now message the assistant.";
26
+ }
27
+ return "Verification successful. You are now set as the guardian for this channel.";
28
+ }
29
+
30
+ export function composeVerificationFailureReply(
31
+ reason?: string,
32
+ ): string {
33
+ return reason ?? "The verification code is invalid or has expired.";
34
+ }
35
+
36
+ // ---------------------------------------------------------------------------
37
+ // Delivery
38
+ // ---------------------------------------------------------------------------
39
+
40
+ export interface VerificationReplyParams {
41
+ replyCallbackUrl: string;
42
+ chatId: string;
43
+ text: string;
44
+ assistantId?: string;
45
+ }
46
+
47
+ /**
48
+ * Deliver a verification reply via the channel's callback URL.
49
+ *
50
+ * Uses fetchImpl (the gateway's fetch wrapper) to call the gateway's own
51
+ * /deliver/* endpoint. Retries once after a short delay on failure.
52
+ */
53
+ export async function deliverVerificationReply(
54
+ params: VerificationReplyParams,
55
+ ): Promise<void> {
56
+ const { replyCallbackUrl, chatId, text, assistantId } = params;
57
+
58
+ const body = JSON.stringify({
59
+ chatId,
60
+ text,
61
+ ...(assistantId ? { assistantId } : {}),
62
+ });
63
+
64
+ const headers = { "Content-Type": "application/json" };
65
+
66
+ try {
67
+ const res = await fetchImpl(replyCallbackUrl, {
68
+ method: "POST",
69
+ headers,
70
+ body,
71
+ signal: AbortSignal.timeout(DELIVERY_TIMEOUT_MS),
72
+ });
73
+
74
+ if (!res.ok) {
75
+ const resBody = await res.text().catch(() => "<unreadable>");
76
+ log.error(
77
+ { status: res.status, body: resBody, chatId },
78
+ "Verification reply delivery returned non-OK status",
79
+ );
80
+ }
81
+ } catch (err) {
82
+ log.error(
83
+ { err, chatId },
84
+ "Verification reply delivery failed — retrying once",
85
+ );
86
+
87
+ // Single retry after 2s
88
+ await new Promise((r) => setTimeout(r, 2000));
89
+ try {
90
+ const retryRes = await fetchImpl(replyCallbackUrl, {
91
+ method: "POST",
92
+ headers,
93
+ body,
94
+ signal: AbortSignal.timeout(DELIVERY_TIMEOUT_MS),
95
+ });
96
+ if (!retryRes.ok) {
97
+ log.error(
98
+ { status: retryRes.status, chatId },
99
+ "Verification reply retry also failed",
100
+ );
101
+ }
102
+ } catch (retryErr) {
103
+ log.error(
104
+ { err: retryErr, chatId },
105
+ "Verification reply retry threw",
106
+ );
107
+ }
108
+ }
109
+ }
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Verification session helpers for gateway-owned verification.
3
+ *
4
+ * Session lookup, consumption, and status checks — all via raw SQL
5
+ * through the assistant DB IPC proxy. Dual-writes to gateway DB on
6
+ * session consumption.
7
+ */
8
+
9
+ import { eq } from "drizzle-orm";
10
+
11
+ import {
12
+ assistantDbQuery,
13
+ assistantDbRun,
14
+ } from "../db/assistant-db-proxy.js";
15
+ import { getGatewayDb } from "../db/connection.js";
16
+ import { channelVerificationSessions as gwSessions } from "../db/schema.js";
17
+ import { getLogger } from "../logger.js";
18
+
19
+ const log = getLogger("verification-sessions");
20
+
21
+ // ---------------------------------------------------------------------------
22
+ // Types
23
+ // ---------------------------------------------------------------------------
24
+
25
+ export interface VerificationSession {
26
+ id: string;
27
+ challengeHash: string;
28
+ expiresAt: number;
29
+ status: string;
30
+ verificationPurpose: string;
31
+ expectedExternalUserId: string | null;
32
+ expectedChatId: string | null;
33
+ expectedPhoneE164: string | null;
34
+ identityBindingStatus: string | null;
35
+ codeDigits: number | null;
36
+ maxAttempts: number | null;
37
+ }
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // Constants
41
+ // ---------------------------------------------------------------------------
42
+
43
+ const SESSION_COLUMNS = `
44
+ id, challenge_hash AS challengeHash, expires_at AS expiresAt,
45
+ status, verification_purpose AS verificationPurpose,
46
+ expected_external_user_id AS expectedExternalUserId,
47
+ expected_chat_id AS expectedChatId,
48
+ expected_phone_e164 AS expectedPhoneE164,
49
+ identity_binding_status AS identityBindingStatus,
50
+ code_digits AS codeDigits, max_attempts AS maxAttempts
51
+ `;
52
+
53
+ /**
54
+ * All session statuses that represent an interceptable verification session.
55
+ *
56
+ * Includes 'awaiting_response' — outbound text verification sessions are
57
+ * created with this status (see assistant createOutboundSession).
58
+ */
59
+ const INTERCEPTABLE_STATUSES = `('pending', 'pending_bootstrap', 'awaiting_response')`;
60
+
61
+ // ---------------------------------------------------------------------------
62
+ // Session lookup
63
+ // ---------------------------------------------------------------------------
64
+
65
+ /**
66
+ * Check whether there is any pending/active verification session for this
67
+ * channel. Used as a fast guard before attempting code parsing + validation.
68
+ */
69
+ export async function hasPendingOrActiveSession(
70
+ channel: string,
71
+ ): Promise<boolean> {
72
+ const now = Date.now();
73
+ const rows = await assistantDbQuery<{ id: string }>(
74
+ `SELECT id FROM channel_verification_sessions
75
+ WHERE channel = ?
76
+ AND status IN ${INTERCEPTABLE_STATUSES}
77
+ AND expires_at > ?
78
+ LIMIT 1`,
79
+ [channel, now],
80
+ );
81
+ return rows.length > 0;
82
+ }
83
+
84
+ /**
85
+ * Find a session matching a specific challenge hash.
86
+ */
87
+ export async function findSessionByHash(
88
+ channel: string,
89
+ challengeHash: string,
90
+ ): Promise<VerificationSession | null> {
91
+ const now = Date.now();
92
+ const rows = await assistantDbQuery<VerificationSession>(
93
+ `SELECT ${SESSION_COLUMNS}
94
+ FROM channel_verification_sessions
95
+ WHERE channel = ?
96
+ AND challenge_hash = ?
97
+ AND status IN ${INTERCEPTABLE_STATUSES}
98
+ AND expires_at > ?
99
+ LIMIT 1`,
100
+ [channel, challengeHash, now],
101
+ );
102
+ return rows[0] ?? null;
103
+ }
104
+
105
+ // ---------------------------------------------------------------------------
106
+ // Session consumption (dual-write)
107
+ // ---------------------------------------------------------------------------
108
+
109
+ /**
110
+ * Mark a verification session as consumed. Dual-writes to both assistant
111
+ * and gateway DBs.
112
+ *
113
+ * The UPDATE includes a status predicate so only the first concurrent
114
+ * consumer wins — subsequent attempts see zero changes and return false,
115
+ * preserving one-time-code semantics under race conditions.
116
+ */
117
+ export async function consumeSession(
118
+ sessionId: string,
119
+ actorExternalUserId: string,
120
+ actorChatId: string,
121
+ ): Promise<boolean> {
122
+ const now = Date.now();
123
+
124
+ // Assistant DB (source of truth) — status guard ensures atomicity
125
+ const result = await assistantDbRun(
126
+ `UPDATE channel_verification_sessions
127
+ SET status = 'consumed',
128
+ consumed_by_external_user_id = ?,
129
+ consumed_by_chat_id = ?,
130
+ updated_at = ?
131
+ WHERE id = ?
132
+ AND status IN ${INTERCEPTABLE_STATUSES}`,
133
+ [actorExternalUserId, actorChatId, now, sessionId],
134
+ );
135
+
136
+ if (result.changes === 0) {
137
+ log.warn(
138
+ { sessionId },
139
+ "Session consume returned 0 changes — already consumed or status changed",
140
+ );
141
+ return false;
142
+ }
143
+
144
+ // Gateway DB dual-write
145
+ try {
146
+ const gwDb = getGatewayDb();
147
+ gwDb.update(gwSessions)
148
+ .set({
149
+ status: "consumed",
150
+ consumedByExternalUserId: actorExternalUserId,
151
+ consumedByChatId: actorChatId,
152
+ updatedAt: now,
153
+ })
154
+ .where(eq(gwSessions.id, sessionId))
155
+ .run();
156
+ } catch (gwErr) {
157
+ log.warn(
158
+ { err: gwErr, sessionId },
159
+ "Gateway DB session consume dual-write failed (best-effort)",
160
+ );
161
+ }
162
+
163
+ return true;
164
+ }