@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,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
|
+
}
|