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