@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,107 @@
1
+ /**
2
+ * Guardian binding helpers for gateway-owned verification.
3
+ *
4
+ * Provides lookup, conflict detection, and revocation of existing bindings.
5
+ * Binding creation uses the existing createGuardianBinding from
6
+ * gateway/src/auth/guardian-bootstrap.ts which already dual-writes.
7
+ *
8
+ * All assistant DB access is via raw SQL (assistantDbQuery/assistantDbRun).
9
+ */
10
+
11
+ import { eq } from "drizzle-orm";
12
+
13
+ import {
14
+ assistantDbQuery,
15
+ assistantDbRun,
16
+ } from "../db/assistant-db-proxy.js";
17
+ import { getGatewayDb } from "../db/connection.js";
18
+ import { contactChannels as gwContactChannels } from "../db/schema.js";
19
+ import { getLogger } from "../logger.js";
20
+
21
+ const log = getLogger("verification-bindings");
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // Lookup
25
+ // ---------------------------------------------------------------------------
26
+
27
+ /**
28
+ * Find the existing active guardian binding for a channel.
29
+ */
30
+ export async function getExistingGuardianBinding(
31
+ channel: string,
32
+ ): Promise<{ externalUserId: string | null } | null> {
33
+ const rows = await assistantDbQuery<{ externalUserId: string | null }>(
34
+ `SELECT cc.external_user_id AS externalUserId
35
+ FROM contacts c
36
+ JOIN contact_channels cc ON cc.contact_id = c.id
37
+ WHERE c.role = 'guardian' AND cc.type = ? AND cc.status = 'active'
38
+ LIMIT 1`,
39
+ [channel],
40
+ );
41
+ return rows[0] ?? null;
42
+ }
43
+
44
+ /**
45
+ * Resolve the canonical principal ID for the guardian.
46
+ * Looks up the vellum channel binding's principal; falls back to the provided ID.
47
+ */
48
+ export async function resolveCanonicalPrincipal(
49
+ fallback: string,
50
+ ): Promise<string> {
51
+ const rows = await assistantDbQuery<{ principalId: string | null }>(
52
+ `SELECT c.principal_id AS principalId
53
+ FROM contacts c
54
+ JOIN contact_channels cc ON cc.contact_id = c.id
55
+ WHERE c.role = 'guardian' AND cc.type = 'vellum' AND cc.status = 'active'
56
+ LIMIT 1`,
57
+ [],
58
+ );
59
+ return rows[0]?.principalId ?? fallback;
60
+ }
61
+
62
+ // ---------------------------------------------------------------------------
63
+ // Revocation (dual-write)
64
+ // ---------------------------------------------------------------------------
65
+
66
+ /**
67
+ * Revoke all existing active guardian bindings for a channel.
68
+ * Uses fetched IDs for the UPDATE to avoid TOCTOU races.
69
+ */
70
+ export async function revokeExistingChannelGuardian(
71
+ channel: string,
72
+ ): Promise<void> {
73
+ const now = Date.now();
74
+
75
+ const revokedRows = await assistantDbQuery<{ id: string }>(
76
+ `SELECT cc.id
77
+ FROM contacts c
78
+ JOIN contact_channels cc ON cc.contact_id = c.id
79
+ WHERE c.role = 'guardian' AND cc.type = ? AND cc.status = 'active'`,
80
+ [channel],
81
+ );
82
+
83
+ if (revokedRows.length === 0) return;
84
+
85
+ const ids = revokedRows.map((r) => r.id);
86
+ const placeholders = ids.map(() => "?").join(", ");
87
+
88
+ await assistantDbRun(
89
+ `UPDATE contact_channels
90
+ SET status = 'revoked', policy = 'deny', updated_at = ?
91
+ WHERE id IN (${placeholders})`,
92
+ [now, ...ids],
93
+ );
94
+
95
+ // Gateway DB dual-write
96
+ try {
97
+ const gwDb = getGatewayDb();
98
+ for (const id of ids) {
99
+ gwDb.update(gwContactChannels)
100
+ .set({ status: "revoked", policy: "deny", updatedAt: now })
101
+ .where(eq(gwContactChannels.id, id))
102
+ .run();
103
+ }
104
+ } catch (gwErr) {
105
+ log.warn({ err: gwErr }, "Gateway DB revoke dual-write failed (best-effort)");
106
+ }
107
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Verification code parsing for gateway-owned text-channel verification.
3
+ *
4
+ * Mirrors the assistant's parseGuardianVerifyCode from acl-enforcement.ts.
5
+ * Accepts a bare code as the entire message: 6-digit numeric OR 64-char hex.
6
+ * Strips surrounding mrkdwn formatting characters first so that codes
7
+ * pasted with bold/italic/code formatting are still recognized.
8
+ */
9
+
10
+ import { createHash } from "node:crypto";
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // Parsing
14
+ // ---------------------------------------------------------------------------
15
+
16
+ /**
17
+ * Strip Slack/Telegram mrkdwn formatting wrappers from raw message text.
18
+ */
19
+ function stripMrkdwnFormatting(text: string): string {
20
+ return text.replace(/^[*_~`]+/, "").replace(/[*_~`]+$/, "");
21
+ }
22
+
23
+ /**
24
+ * Parse a verification code from message content.
25
+ *
26
+ * Returns the code string if the message is a bare 6-digit numeric or
27
+ * 64-char hex code, or undefined if the message is not a verification code.
28
+ */
29
+ export function parseVerificationCode(content: string): string | undefined {
30
+ const stripped = stripMrkdwnFormatting(content.trim());
31
+ const match = stripped.match(/^([0-9a-fA-F]{64}|\d{6})$/);
32
+ return match?.[1];
33
+ }
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // Hashing
37
+ // ---------------------------------------------------------------------------
38
+
39
+ /**
40
+ * Hash a verification secret using SHA-256 (matches the assistant's scheme).
41
+ */
42
+ export function hashVerificationSecret(secret: string): string {
43
+ return createHash("sha256").update(secret).digest("hex");
44
+ }
@@ -0,0 +1,342 @@
1
+ /**
2
+ * Contact upsert/lookup helpers for gateway-owned verification.
3
+ *
4
+ * All operations go through assistantDbQuery/assistantDbRun (raw SQL via
5
+ * IPC proxy). No IPC routes are used — only the direct SQL executor.
6
+ *
7
+ * These helpers cover the subset of contact operations needed by the
8
+ * verification intercept flow. They are intentionally simpler than the
9
+ * assistant's full upsertContact/syncChannels — we only need to upsert
10
+ * a single contact+channel for the verifying user.
11
+ */
12
+
13
+ import { existsSync } from "node:fs";
14
+
15
+ import { eq } from "drizzle-orm";
16
+
17
+ import {
18
+ assistantDbQuery,
19
+ assistantDbRun,
20
+ } from "../db/assistant-db-proxy.js";
21
+ import { getGatewayDb } from "../db/connection.js";
22
+ import { contactChannels as gwContactChannels, contacts as gwContacts } from "../db/schema.js";
23
+ import { getLogger } from "../logger.js";
24
+ import { resolveIpcSocketPath } from "../ipc/socket-path.js";
25
+ import { canonicalizeInboundIdentity } from "./identity.js";
26
+
27
+ const log = getLogger("verification-contacts");
28
+
29
+ // ---------------------------------------------------------------------------
30
+ // Types
31
+ // ---------------------------------------------------------------------------
32
+
33
+ export interface ContactChannelRow {
34
+ channelId: string;
35
+ contactId: string;
36
+ externalUserId: string | null;
37
+ externalChatId: string | null;
38
+ displayName: string | null;
39
+ status: string;
40
+ }
41
+
42
+ // ---------------------------------------------------------------------------
43
+ // Lookup
44
+ // ---------------------------------------------------------------------------
45
+
46
+ /**
47
+ * Find an existing contact channel for a given channel type + external user ID.
48
+ */
49
+ export async function findContactChannelByExternalUserId(
50
+ channelType: string,
51
+ externalUserId: string,
52
+ ): Promise<ContactChannelRow | null> {
53
+ const rows = await assistantDbQuery<ContactChannelRow>(
54
+ `SELECT cc.id AS channelId, cc.contact_id AS contactId,
55
+ cc.external_user_id AS externalUserId,
56
+ cc.external_chat_id AS externalChatId,
57
+ c.display_name AS displayName,
58
+ cc.status
59
+ FROM contact_channels cc
60
+ JOIN contacts c ON c.id = cc.contact_id
61
+ WHERE cc.type = ? AND cc.external_user_id = ?
62
+ LIMIT 1`,
63
+ [channelType, externalUserId],
64
+ );
65
+ return rows[0] ?? null;
66
+ }
67
+
68
+ // ---------------------------------------------------------------------------
69
+ // Upsert
70
+ // ---------------------------------------------------------------------------
71
+
72
+ /**
73
+ * Upsert a contact + channel for a verified user.
74
+ *
75
+ * If a contact channel with the same (type, address) exists, updates it.
76
+ * Otherwise creates a new contact + channel.
77
+ *
78
+ * This is intentionally simpler than the assistant's full upsertContact —
79
+ * it handles the verification-specific case only (single channel, no
80
+ * reassignment, no invite binding).
81
+ */
82
+ export async function upsertVerifiedContactChannel(params: {
83
+ sourceChannel: string;
84
+ externalUserId: string;
85
+ externalChatId: string;
86
+ displayName?: string;
87
+ username?: string;
88
+ }): Promise<void> {
89
+ const now = Date.now();
90
+ const { sourceChannel, externalChatId, displayName, username } = params;
91
+
92
+ const canonicalUserId =
93
+ canonicalizeInboundIdentity(sourceChannel, params.externalUserId) ??
94
+ params.externalUserId;
95
+ const address = canonicalUserId.toLowerCase();
96
+ const contactDisplayName = displayName ?? username ?? canonicalUserId;
97
+
98
+ // Check if a channel with this (type, address) already exists
99
+ const existing = await assistantDbQuery<{
100
+ channelId: string;
101
+ contactId: string;
102
+ channelStatus: string;
103
+ }>(
104
+ `SELECT cc.id AS channelId, cc.contact_id AS contactId, cc.status AS channelStatus
105
+ FROM contact_channels cc
106
+ WHERE cc.type = ? AND cc.address = ?
107
+ LIMIT 1`,
108
+ [sourceChannel, address],
109
+ );
110
+
111
+ if (existing.length > 0) {
112
+ const row = existing[0];
113
+
114
+ // Don't overwrite blocked channels
115
+ if (row.channelStatus === "blocked") {
116
+ log.warn({ sourceChannel, address }, "Skipping upsert: channel is blocked");
117
+ return;
118
+ }
119
+
120
+ // Update existing channel
121
+ await assistantDbRun(
122
+ `UPDATE contact_channels
123
+ SET status = 'active', policy = 'allow',
124
+ external_user_id = ?, external_chat_id = ?,
125
+ revoked_reason = NULL, blocked_reason = NULL,
126
+ updated_at = ?
127
+ WHERE id = ?`,
128
+ [canonicalUserId, externalChatId, now, row.channelId],
129
+ );
130
+
131
+ // Dual-write to gateway DB
132
+ try {
133
+ const gwDb = getGatewayDb();
134
+ gwDb.update(gwContactChannels)
135
+ .set({
136
+ status: "active",
137
+ policy: "allow",
138
+ externalUserId: canonicalUserId,
139
+ externalChatId,
140
+ revokedReason: null,
141
+ blockedReason: null,
142
+ updatedAt: now,
143
+ })
144
+ .where(eq(gwContactChannels.id, row.channelId))
145
+ .run();
146
+ } catch (gwErr) {
147
+ log.warn({ err: gwErr }, "Gateway DB contact channel update dual-write failed");
148
+ }
149
+
150
+ return;
151
+ }
152
+
153
+ // Create new contact + channel. Both use OR IGNORE for idempotency under
154
+ // retries. If the channel insert fails mid-flight, the orphan contact row
155
+ // is harmless (no channels → invisible in UI, cleaned up by next upsert
156
+ // for the same identity which will find-by-address and reuse it).
157
+ const contactId = crypto.randomUUID();
158
+ const channelId = crypto.randomUUID();
159
+
160
+ await assistantDbRun(
161
+ `INSERT OR IGNORE INTO contacts (id, display_name, role, created_at, updated_at)
162
+ VALUES (?, ?, 'contact', ?, ?)`,
163
+ [contactId, contactDisplayName, now, now],
164
+ );
165
+
166
+ await assistantDbRun(
167
+ `INSERT OR IGNORE INTO contact_channels
168
+ (id, contact_id, type, address, is_primary, external_user_id, external_chat_id,
169
+ status, policy, interaction_count, created_at, updated_at)
170
+ VALUES (?, ?, ?, ?, 0, ?, ?, 'active', 'allow', 0, ?, ?)`,
171
+ [channelId, contactId, sourceChannel, address, canonicalUserId, externalChatId, now, now],
172
+ );
173
+
174
+ // Dual-write to gateway DB
175
+ try {
176
+ const gwDb = getGatewayDb();
177
+ gwDb.insert(gwContacts)
178
+ .values({
179
+ id: contactId,
180
+ displayName: contactDisplayName,
181
+ role: "contact",
182
+ createdAt: now,
183
+ updatedAt: now,
184
+ })
185
+ .onConflictDoNothing()
186
+ .run();
187
+
188
+ gwDb.insert(gwContactChannels)
189
+ .values({
190
+ id: channelId,
191
+ contactId,
192
+ type: sourceChannel,
193
+ address,
194
+ isPrimary: false,
195
+ externalUserId: canonicalUserId,
196
+ externalChatId,
197
+ status: "active",
198
+ policy: "allow",
199
+ interactionCount: 0,
200
+ createdAt: now,
201
+ updatedAt: now,
202
+ })
203
+ .onConflictDoNothing()
204
+ .run();
205
+ } catch (gwErr) {
206
+ log.warn({ err: gwErr }, "Gateway DB contact create dual-write failed");
207
+ }
208
+ }
209
+
210
+ // ---------------------------------------------------------------------------
211
+ // Inbound contact seeding (dual-write)
212
+ // ---------------------------------------------------------------------------
213
+
214
+ /**
215
+ * Create or update a contact channel for an inbound actor, preserving any
216
+ * existing status/policy. Used to seed contact records when new users are
217
+ * first seen on a channel.
218
+ *
219
+ * - Existing channel: updates display name, external_user_id, external_chat_id.
220
+ * Status and policy are left unchanged so blocked/revoked channels stay that way.
221
+ * - New channel: inserts contact + channel with status='unverified', policy='allow'.
222
+ *
223
+ * Dual-writes to both the assistant DB (source of truth) and the gateway DB.
224
+ * Skips silently when the assistant IPC socket is unavailable (test environments).
225
+ */
226
+ export async function upsertContactChannel(params: {
227
+ sourceChannel: string;
228
+ externalUserId: string;
229
+ externalChatId?: string;
230
+ displayName?: string;
231
+ username?: string;
232
+ }): Promise<void> {
233
+ const { path: socketPath } = resolveIpcSocketPath("assistant");
234
+ if (!existsSync(socketPath)) return;
235
+
236
+ const { sourceChannel, externalChatId, displayName, username } = params;
237
+ const now = Date.now();
238
+ const canonicalUserId =
239
+ canonicalizeInboundIdentity(sourceChannel, params.externalUserId) ??
240
+ params.externalUserId;
241
+ const address = canonicalUserId.toLowerCase();
242
+ const contactDisplayName = displayName ?? username ?? canonicalUserId;
243
+
244
+ const existing = await assistantDbQuery<{
245
+ channelId: string;
246
+ contactId: string;
247
+ channelStatus: string;
248
+ }>(
249
+ `SELECT cc.id AS channelId, cc.contact_id AS contactId, cc.status AS channelStatus
250
+ FROM contact_channels cc
251
+ WHERE cc.type = ? AND cc.address = ?
252
+ LIMIT 1`,
253
+ [sourceChannel, address],
254
+ );
255
+
256
+ if (existing.length > 0) {
257
+ const row = existing[0];
258
+ if (row.channelStatus === "blocked") return;
259
+
260
+ // Update identity/display fields; preserve status and policy.
261
+ await assistantDbRun(
262
+ `UPDATE contacts SET display_name = ?, updated_at = ? WHERE id = ?`,
263
+ [contactDisplayName, now, row.contactId],
264
+ );
265
+ await assistantDbRun(
266
+ `UPDATE contact_channels
267
+ SET external_user_id = ?,
268
+ external_chat_id = COALESCE(?, external_chat_id),
269
+ updated_at = ?
270
+ WHERE id = ?`,
271
+ [canonicalUserId, externalChatId ?? null, now, row.channelId],
272
+ );
273
+
274
+ try {
275
+ const gwDb = getGatewayDb();
276
+ gwDb
277
+ .update(gwContactChannels)
278
+ .set({
279
+ externalUserId: canonicalUserId,
280
+ ...(externalChatId ? { externalChatId } : {}),
281
+ updatedAt: now,
282
+ })
283
+ .where(eq(gwContactChannels.id, row.channelId))
284
+ .run();
285
+ } catch (gwErr) {
286
+ log.warn({ err: gwErr }, "Gateway DB contact channel update dual-write failed");
287
+ }
288
+ return;
289
+ }
290
+
291
+ // New contact + channel.
292
+ const contactId = crypto.randomUUID();
293
+ const channelId = crypto.randomUUID();
294
+
295
+ await assistantDbRun(
296
+ `INSERT OR IGNORE INTO contacts (id, display_name, role, created_at, updated_at)
297
+ VALUES (?, ?, 'contact', ?, ?)`,
298
+ [contactId, contactDisplayName, now, now],
299
+ );
300
+ await assistantDbRun(
301
+ `INSERT OR IGNORE INTO contact_channels
302
+ (id, contact_id, type, address, is_primary, external_user_id, external_chat_id,
303
+ status, policy, interaction_count, created_at, updated_at)
304
+ VALUES (?, ?, ?, ?, 0, ?, ?, 'unverified', 'allow', 0, ?, ?)`,
305
+ [channelId, contactId, sourceChannel, address, canonicalUserId, externalChatId ?? null, now, now],
306
+ );
307
+
308
+ try {
309
+ const gwDb = getGatewayDb();
310
+ gwDb
311
+ .insert(gwContacts)
312
+ .values({
313
+ id: contactId,
314
+ displayName: contactDisplayName,
315
+ role: "contact",
316
+ createdAt: now,
317
+ updatedAt: now,
318
+ })
319
+ .onConflictDoNothing()
320
+ .run();
321
+ gwDb
322
+ .insert(gwContactChannels)
323
+ .values({
324
+ id: channelId,
325
+ contactId,
326
+ type: sourceChannel,
327
+ address,
328
+ isPrimary: false,
329
+ externalUserId: canonicalUserId,
330
+ externalChatId: externalChatId ?? null,
331
+ status: "unverified",
332
+ policy: "allow",
333
+ interactionCount: 0,
334
+ createdAt: now,
335
+ updatedAt: now,
336
+ })
337
+ .onConflictDoNothing()
338
+ .run();
339
+ } catch (gwErr) {
340
+ log.warn({ err: gwErr }, "Gateway DB contact channel create dual-write failed");
341
+ }
342
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Identity matching for verification sessions.
3
+ *
4
+ * Mirrors the assistant's channel-verification-service.ts identity check.
5
+ * Determines whether the actor submitting a verification code matches the
6
+ * expected identity on an outbound session.
7
+ */
8
+
9
+ import type { VerificationSession } from "./session-helpers.js";
10
+
11
+ /**
12
+ * Check whether the actor matches the session's expected identity.
13
+ *
14
+ * Returns true if:
15
+ * - The session has no expected identity (inbound sessions)
16
+ * - The session's identity binding status is not 'bound' (pending_bootstrap)
17
+ * - The actor matches the expected identity
18
+ */
19
+ export function checkIdentityMatch(
20
+ session: VerificationSession,
21
+ actorExternalUserId: string,
22
+ actorChatId: string,
23
+ ): boolean {
24
+ const hasExpectedIdentity =
25
+ session.expectedExternalUserId != null ||
26
+ session.expectedChatId != null ||
27
+ session.expectedPhoneE164 != null;
28
+
29
+ if (!hasExpectedIdentity || session.identityBindingStatus !== "bound") {
30
+ return true;
31
+ }
32
+
33
+ // Phone match
34
+ if (session.expectedPhoneE164 != null) {
35
+ if (
36
+ actorExternalUserId === session.expectedPhoneE164 ||
37
+ actorExternalUserId === session.expectedExternalUserId
38
+ ) {
39
+ return true;
40
+ }
41
+ }
42
+
43
+ // Chat ID match (Telegram, Slack, etc.)
44
+ if (session.expectedChatId != null) {
45
+ if (session.expectedExternalUserId != null) {
46
+ // When both are set, require the externalUserId match — chatId alone
47
+ // is insufficient (shared group chats).
48
+ if (actorExternalUserId === session.expectedExternalUserId) {
49
+ return true;
50
+ }
51
+ } else if (actorChatId === session.expectedChatId) {
52
+ return true;
53
+ }
54
+ }
55
+
56
+ // Fallback: only expectedExternalUserId set (no phone, no chat)
57
+ if (
58
+ session.expectedPhoneE164 == null &&
59
+ session.expectedChatId == null &&
60
+ session.expectedExternalUserId != null
61
+ ) {
62
+ if (actorExternalUserId === session.expectedExternalUserId) {
63
+ return true;
64
+ }
65
+ }
66
+
67
+ return false;
68
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Channel-agnostic inbound identity canonicalization for the gateway.
3
+ *
4
+ * Mirrors assistant/src/util/canonicalize-identity.ts so the gateway
5
+ * can canonicalize sender IDs independently.
6
+ */
7
+
8
+ /** Channels whose raw sender IDs are phone numbers. */
9
+ const PHONE_CHANNELS = new Set(["phone", "whatsapp"]);
10
+
11
+ /**
12
+ * Normalize a phone number string to E.164 format.
13
+ */
14
+ function normalizePhoneNumber(input: string): string | null {
15
+ const withoutTrunkZero = input.replace(/\(0\)/g, "");
16
+ const stripped = withoutTrunkZero.replace(/[\s\-().]/g, "");
17
+
18
+ if (stripped.length === 0) return null;
19
+
20
+ if (stripped.startsWith("+")) {
21
+ const digits = stripped.slice(1);
22
+ if (/^\d{10,15}$/.test(digits)) {
23
+ return stripped;
24
+ }
25
+ return null;
26
+ }
27
+
28
+ if (!/^\d+$/.test(stripped)) return null;
29
+
30
+ if (stripped.length === 10) {
31
+ return `+1${stripped}`;
32
+ }
33
+
34
+ if (stripped.length === 11 && stripped.startsWith("1")) {
35
+ return `+${stripped}`;
36
+ }
37
+
38
+ return null;
39
+ }
40
+
41
+ /**
42
+ * Canonicalize a raw inbound sender identity for the given channel.
43
+ *
44
+ * For phone-like channels: attempts E.164 normalization.
45
+ * For non-phone channels: returns trimmed raw ID unchanged.
46
+ * Returns null only when rawId is empty/whitespace-only.
47
+ */
48
+ export function canonicalizeInboundIdentity(
49
+ channel: string,
50
+ rawId: string,
51
+ ): string | null {
52
+ const trimmed = rawId.trim();
53
+ if (trimmed.length === 0) return null;
54
+
55
+ if (PHONE_CHANNELS.has(channel)) {
56
+ const e164 = normalizePhoneNumber(trimmed);
57
+ return e164 ?? trimmed;
58
+ }
59
+
60
+ return trimmed;
61
+ }