@vellumai/vellum-gateway 0.8.1 → 0.8.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 (48) hide show
  1. package/ARCHITECTURE.md +6 -5
  2. package/README.md +4 -3
  3. package/package.json +1 -1
  4. package/src/__tests__/contact-store-mark-channel-verified.test.ts +219 -6
  5. package/src/__tests__/contacts-control-plane-proxy.test.ts +37 -0
  6. package/src/__tests__/guardian-binding-channel-reuse.test.ts +275 -0
  7. package/src/__tests__/ipc-route-policy.test.ts +93 -0
  8. package/src/__tests__/logger-retention.test.ts +115 -0
  9. package/src/__tests__/nonbash-trust-rule-overrides.test.ts +1 -0
  10. package/src/__tests__/slack-display-name.test.ts +70 -0
  11. package/src/__tests__/slack-socket-mode-scopes.test.ts +27 -4
  12. package/src/__tests__/slack-socket-mode-thread-tracking.test.ts +105 -1
  13. package/src/__tests__/trust-rule-store.test.ts +49 -0
  14. package/src/__tests__/upsert-verified-contact-channel.test.ts +49 -6
  15. package/src/auth/guardian-bootstrap.ts +61 -11
  16. package/src/auth/ipc-route-policy.ts +109 -1
  17. package/src/db/contact-store.ts +164 -38
  18. package/src/db/trust-rule-store.ts +22 -17
  19. package/src/feature-flag-registry.json +41 -1
  20. package/src/http/routes/contacts-control-plane-proxy.ts +13 -2
  21. package/src/http/routes/log-export.test.ts +25 -0
  22. package/src/http/routes/log-export.ts +23 -9
  23. package/src/http/routes/log-tail.test.ts +63 -0
  24. package/src/http/routes/log-tail.ts +11 -5
  25. package/src/http/routes/trust-rules.ts +2 -2
  26. package/src/index.ts +81 -6
  27. package/src/ipc/risk-classification-handlers.ts +4 -1
  28. package/src/logger.ts +31 -11
  29. package/src/risk/bash-risk-classifier.test.ts +25 -1
  30. package/src/risk/command-registry/commands/assistant.ts +32 -2
  31. package/src/risk/command-registry.test.ts +5 -0
  32. package/src/risk/file-risk-classifier.test.ts +117 -0
  33. package/src/risk/file-risk-classifier.ts +46 -3
  34. package/src/runtime/client.ts +6 -1
  35. package/src/slack/normalize.test.ts +46 -0
  36. package/src/slack/normalize.ts +133 -29
  37. package/src/slack/socket-mode.ts +62 -8
  38. package/src/twilio/setup-state.test.ts +49 -0
  39. package/src/twilio/setup-state.ts +35 -0
  40. package/src/velay/allowed-paths.test.ts +69 -0
  41. package/src/velay/allowed-paths.ts +53 -0
  42. package/src/velay/client.test.ts +26 -13
  43. package/src/velay/client.ts +34 -12
  44. package/src/verification/binding-helpers.ts +31 -0
  45. package/src/verification/contact-helpers.ts +83 -24
  46. package/src/verification/outbound-voice-verification-sync.test.ts +453 -0
  47. package/src/verification/outbound-voice-verification-sync.ts +54 -11
  48. package/src/webhook-pipeline.ts +7 -1
package/ARCHITECTURE.md CHANGED
@@ -298,7 +298,7 @@ The assistant runtime reads this URL via the centralized `public-ingress-urls.ts
298
298
 
299
299
  Velay is a platform-managed tunnel for assistant-hosted HTTP and WebSocket traffic. When it is active, Velay publishes the registered public assistant URL to `ingress.publicBaseUrl` and marks it with `ingress.publicBaseUrlManagedBy: "velay"`.
300
300
 
301
- When `VELAY_BASE_URL` is present in the gateway environment, the gateway starts `VelayTunnelClient`. The client registers with Velay over `GET /v1/register` using the assistant API key, then receives a `registered` frame containing a public assistant URL such as `https://velay.vellum.ai/<assistant-id>`. The gateway writes that URL to `ingress.publicBaseUrl`. When the tunnel disconnects, it clears that value only if the Velay ownership marker is still present and the URL still matches what the tunnel published, leaving manual URLs intact.
301
+ When `VELAY_BASE_URL` is present in the gateway environment, the gateway creates `VelayTunnelClient` but starts it only after Twilio setup has been started in the workspace. On boot, existing Twilio credentials or existing `twilio.accountSid` / `twilio.phoneNumber` config count as prior setup, and successful credential setup persists `twilio.setupStarted: true` for future boots. Before credential-backed startup side effects run, the gateway clears any stale Velay-managed `ingress.publicBaseUrl`; if setup has not started, it does this without opening a tunnel. The client registers with Velay over `GET /v1/register` using the assistant API key, then receives a `registered` frame containing a public assistant URL such as `https://velay.vellum.ai/<assistant-id>`. The gateway writes that URL to `ingress.publicBaseUrl`. When the tunnel disconnects, it clears that value only if the Velay ownership marker is still present and the URL still matches what the tunnel published, leaving manual URLs intact.
302
302
 
303
303
  Velay forwards both HTTP request frames and WebSocket frames into the local gateway loopback listener:
304
304
 
@@ -316,10 +316,11 @@ Local platform smoke-test flow:
316
316
 
317
317
  1. In `vellum-assistant-platform`, run `vel up velay`.
318
318
  2. Ensure vembda passes the environment-appropriate `VELAY_BASE_URL` into assistant gateway containers.
319
- 3. Re-hatch or restart the assistant so the gateway receives the new environment.
320
- 4. Confirm gateway logs show `Velay tunnel connected` and `Velay tunnel registered`.
321
- 5. Verify HTTP forwarding by requesting `${VELAY_PUBLIC_BASE_URL}/<assistant-id>/healthz` and `${VELAY_PUBLIC_BASE_URL}/<assistant-id>/schema`. When validating a JSON webhook route under active development, POST a small JSON body through the same Velay public URL and confirm it reaches the loopback gateway.
322
- 6. Verify Twilio WebSocket forwarding with a synthetic local WebSocket client against `${VELAY_PUBLIC_BASE_URL}/<assistant-id>/webhooks/twilio/relay?callSessionId=...&token=...`, then with a real Twilio call after the gateway has registered with Velay.
319
+ 3. Start or complete Twilio setup in the workspace so the gateway is allowed to connect the tunnel.
320
+ 4. Re-hatch or restart the assistant so the gateway receives the new environment.
321
+ 5. Confirm gateway logs show `Velay tunnel connected` and `Velay tunnel registered`.
322
+ 6. Verify HTTP forwarding by requesting `${VELAY_PUBLIC_BASE_URL}/<assistant-id>/healthz` and `${VELAY_PUBLIC_BASE_URL}/<assistant-id>/schema`. When validating a JSON webhook route under active development, POST a small JSON body through the same Velay public URL and confirm it reaches the loopback gateway.
323
+ 7. Verify Twilio WebSocket forwarding with a synthetic local WebSocket client against `${VELAY_PUBLIC_BASE_URL}/<assistant-id>/webhooks/twilio/relay?callSessionId=...&token=...`, then with a real Twilio call after the gateway has registered with Velay.
323
324
 
324
325
  ### URL Builders
325
326
 
package/README.md CHANGED
@@ -212,7 +212,7 @@ The assistant runtime uses this URL to construct all webhook and OAuth callback
212
212
 
213
213
  ### Velay for Twilio Testing
214
214
 
215
- Velay is a managed ingress transport for assistant-hosted HTTP and WebSocket traffic. When Velay registration succeeds, the gateway writes the registered public assistant URL to `ingress.publicBaseUrl` and marks it with `ingress.publicBaseUrlManagedBy: "velay"`. Twilio URL builders use that public base URL for voice, status, relay, and media-stream endpoints.
215
+ Velay is a managed ingress transport for assistant-hosted HTTP and WebSocket traffic. The gateway starts the Velay tunnel only after Twilio setup has been started in the workspace, or when existing Twilio config shows it was set up before. When Velay registration succeeds, the gateway writes the registered public assistant URL to `ingress.publicBaseUrl` and marks it with `ingress.publicBaseUrlManagedBy: "velay"`. Twilio URL builders use that public base URL for voice, status, relay, and media-stream endpoints.
216
216
 
217
217
  Use Velay when testing Twilio voice webhooks or Twilio WebSocket upgrades through the platform-managed tunnel:
218
218
 
@@ -230,8 +230,9 @@ Use Velay when testing Twilio voice webhooks or Twilio WebSocket upgrades throug
230
230
 
231
231
  Hosted environments should use their environment's deployed Velay URL instead.
232
232
 
233
- 3. Re-hatch or restart the assistant so the gateway process receives `VELAY_BASE_URL`.
234
- 4. Confirm the gateway logs include `Velay tunnel connected` followed by `Velay tunnel registered`. Registration publishes the returned Velay URL to `ingress.publicBaseUrl`.
233
+ 3. Start or complete Twilio setup in the workspace so the gateway is allowed to connect the tunnel.
234
+ 4. Re-hatch or restart the assistant so the gateway process receives `VELAY_BASE_URL`.
235
+ 5. Confirm the gateway logs include `Velay tunnel connected` followed by `Velay tunnel registered`. Registration publishes the returned Velay URL to `ingress.publicBaseUrl`.
235
236
 
236
237
  For an HTTP bridge smoke test, send a request to the registered Velay public URL and confirm it reaches the loopback gateway, for example:
237
238
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/vellum-gateway",
3
- "version": "0.8.1",
3
+ "version": "0.8.2",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -2,8 +2,10 @@
2
2
  * Tests for ContactStore.markChannelVerified — manual channel verification
3
3
  * flow used by the /v1/contact-channels/:id/verify endpoint.
4
4
  *
5
- * assistantDbRun is mocked to a no-op so tests exercise gateway DB logic
6
- * only, without needing an assistant daemon.
5
+ * The assistant DB proxy is mocked behind a per-test fake (`fakeAssistantDb`)
6
+ * so tests can stage either an empty assistant DB (most cases) or a
7
+ * pre-populated one (mirror-from-assistant cases) without spinning up a
8
+ * daemon.
7
9
  */
8
10
 
9
11
  import {
@@ -18,13 +20,75 @@ import {
18
20
 
19
21
  import "./test-preload.js";
20
22
 
21
- // Mock the assistant DB proxy before importing ContactStore.
23
+ type FakeChannelRow = {
24
+ id: string;
25
+ contact_id: string;
26
+ type: string;
27
+ address: string;
28
+ is_primary: number;
29
+ external_user_id: string | null;
30
+ external_chat_id: string | null;
31
+ status: string;
32
+ policy: string;
33
+ verified_at: number | null;
34
+ verified_via: string | null;
35
+ invite_id: string | null;
36
+ revoked_reason: string | null;
37
+ blocked_reason: string | null;
38
+ last_seen_at: number | null;
39
+ interaction_count: number;
40
+ last_interaction: number | null;
41
+ created_at: number;
42
+ updated_at: number | null;
43
+ };
44
+
45
+ type FakeContactRow = {
46
+ id: string;
47
+ display_name: string;
48
+ role: string | null;
49
+ principal_id: string | null;
50
+ created_at: number;
51
+ updated_at: number | null;
52
+ };
53
+
54
+ const fakeAssistantDb = {
55
+ channels: new Map<string, FakeChannelRow>(),
56
+ contacts: new Map<string, FakeContactRow>(),
57
+ runCalls: [] as { sql: string; bind?: unknown[] }[],
58
+ reset(): void {
59
+ this.channels.clear();
60
+ this.contacts.clear();
61
+ this.runCalls = [];
62
+ },
63
+ };
64
+
65
+ // Mock the assistant DB proxy before importing ContactStore. The fake
66
+ // honors `SELECT ... FROM contact_channels WHERE id = ?` and
67
+ // `SELECT ... FROM contacts WHERE id = ?`; all other SELECTs return [].
22
68
  mock.module("../db/assistant-db-proxy.js", () => ({
23
- assistantDbRun: mock(async () => ({ changes: 0, lastInsertRowid: 0 })),
24
- assistantDbQuery: mock(async () => []),
69
+ assistantDbRun: mock(async (sql: string, bind?: unknown[]) => {
70
+ fakeAssistantDb.runCalls.push({ sql, bind });
71
+ return { changes: 1, lastInsertRowid: 0 };
72
+ }),
73
+ assistantDbQuery: mock(async (sql: string, bind?: unknown[]) => {
74
+ const lower = sql.toLowerCase();
75
+ if (lower.includes("from contact_channels")) {
76
+ const id = String(bind?.[0] ?? "");
77
+ const row = fakeAssistantDb.channels.get(id);
78
+ return row ? [row] : [];
79
+ }
80
+ if (lower.includes("from contacts")) {
81
+ const id = String(bind?.[0] ?? "");
82
+ const row = fakeAssistantDb.contacts.get(id);
83
+ return row ? [row] : [];
84
+ }
85
+ return [];
86
+ }),
25
87
  assistantDbExec: mock(async () => undefined),
26
88
  }));
27
89
 
90
+ import { eq } from "drizzle-orm";
91
+
28
92
  import { ContactStore } from "../db/contact-store.js";
29
93
  import {
30
94
  initGatewayDb,
@@ -41,8 +105,48 @@ beforeEach(() => {
41
105
  const db = getGatewayDb();
42
106
  db.delete(contactChannels).run();
43
107
  db.delete(contacts).run();
108
+ fakeAssistantDb.reset();
44
109
  });
45
110
 
111
+ function seedAssistantContact(id: string, role: string = "guardian"): void {
112
+ fakeAssistantDb.contacts.set(id, {
113
+ id,
114
+ display_name: `name-${id}`,
115
+ role,
116
+ principal_id: `prin-${id}`,
117
+ created_at: 100,
118
+ updated_at: 100,
119
+ });
120
+ }
121
+
122
+ function seedAssistantChannel(opts: {
123
+ id: string;
124
+ contactId: string;
125
+ status?: string;
126
+ }): void {
127
+ fakeAssistantDb.channels.set(opts.id, {
128
+ id: opts.id,
129
+ contact_id: opts.contactId,
130
+ type: "vellum",
131
+ address: `addr-${opts.id}`,
132
+ is_primary: 0,
133
+ external_user_id: null,
134
+ external_chat_id: null,
135
+ status: opts.status ?? "unverified",
136
+ policy: "allow",
137
+ verified_at: null,
138
+ verified_via: null,
139
+ invite_id: null,
140
+ revoked_reason: null,
141
+ blocked_reason: null,
142
+ last_seen_at: null,
143
+ interaction_count: 0,
144
+ last_interaction: null,
145
+ created_at: 100,
146
+ updated_at: 100,
147
+ });
148
+ }
149
+
46
150
  afterAll(() => {
47
151
  resetGatewayDb();
48
152
  });
@@ -90,7 +194,7 @@ function seedChannel(opts: {
90
194
  }
91
195
 
92
196
  describe("ContactStore.markChannelVerified", () => {
93
- test("returns null when the channel does not exist", async () => {
197
+ test("returns null when neither side has the channel", async () => {
94
198
  const store = new ContactStore();
95
199
  expect(await store.markChannelVerified("missing-id")).toBeNull();
96
200
  });
@@ -174,4 +278,113 @@ describe("ContactStore.markChannelVerified", () => {
174
278
  // Same verifiedAt — predicate prevented re-stamping
175
279
  expect(b!.channel.verifiedAt).toBe(a!.channel.verifiedAt);
176
280
  });
281
+
282
+ test("mirrors channel + contact from assistant DB when gateway is empty, then verifies", async () => {
283
+ seedAssistantContact("c1");
284
+ seedAssistantChannel({
285
+ id: "ch1",
286
+ contactId: "c1",
287
+ status: "unverified",
288
+ });
289
+
290
+ const before = Date.now();
291
+ const result = await new ContactStore().markChannelVerified("ch1");
292
+ expect(result).not.toBeNull();
293
+ expect(result!.didWrite).toBe(true);
294
+ expect(result!.channel.status).toBe("active");
295
+ expect(result!.channel.verifiedVia).toBe("manual");
296
+ expect(result!.channel.verifiedAt!).toBeGreaterThanOrEqual(before);
297
+
298
+ // Channel + contact were materialized in the gateway DB.
299
+ const channelInGateway = getGatewayDb()
300
+ .select()
301
+ .from(contactChannels)
302
+ .where(eq(contactChannels.id, "ch1"))
303
+ .get();
304
+ expect(channelInGateway).toBeTruthy();
305
+ expect(channelInGateway!.contactId).toBe("c1");
306
+ expect(channelInGateway!.type).toBe("vellum");
307
+ const contactInGateway = getGatewayDb()
308
+ .select()
309
+ .from(contacts)
310
+ .where(eq(contacts.id, "c1"))
311
+ .get();
312
+ expect(contactInGateway).toBeTruthy();
313
+ expect(contactInGateway!.displayName).toBe("name-c1");
314
+ expect(contactInGateway!.role).toBe("guardian");
315
+ });
316
+
317
+ test("refuses to mirror when assistant channel references a missing contact", async () => {
318
+ // Channel present, parent contact absent — broken state, refuse silently.
319
+ seedAssistantChannel({ id: "ch1", contactId: "orphan", status: "unverified" });
320
+
321
+ const result = await new ContactStore().markChannelVerified("ch1");
322
+ expect(result).toBeNull();
323
+
324
+ // Nothing landed in the gateway.
325
+ const channelInGateway = getGatewayDb()
326
+ .select()
327
+ .from(contactChannels)
328
+ .where(eq(contactChannels.id, "ch1"))
329
+ .get();
330
+ expect(channelInGateway).toBeUndefined();
331
+ });
332
+
333
+ test("mirror is idempotent across successive calls", async () => {
334
+ seedAssistantContact("c1");
335
+ seedAssistantChannel({
336
+ id: "ch1",
337
+ contactId: "c1",
338
+ status: "unverified",
339
+ });
340
+
341
+ const store = new ContactStore();
342
+ const first = await store.markChannelVerified("ch1");
343
+ const second = await store.markChannelVerified("ch1");
344
+ expect(first!.didWrite).toBe(true);
345
+ expect(second!.didWrite).toBe(false);
346
+ expect(second!.channel.verifiedAt).toBe(first!.channel.verifiedAt);
347
+ // Mirror INSERT OR IGNORE: still exactly one channel row, one contact row.
348
+ expect(
349
+ getGatewayDb().select().from(contactChannels).all().length,
350
+ ).toBe(1);
351
+ expect(getGatewayDb().select().from(contacts).all().length).toBe(1);
352
+ });
353
+
354
+ test("gateway-present channel takes precedence over assistant copy (no mirror, no overwrite)", async () => {
355
+ // Gateway has the row (with a custom display_name for the contact);
356
+ // assistant has a different display_name. We should verify the gateway
357
+ // row in place — not overwrite gateway state with the assistant copy.
358
+ const now = Date.now();
359
+ getGatewayDb()
360
+ .insert(contacts)
361
+ .values({
362
+ id: "c1",
363
+ displayName: "gateway-name",
364
+ role: "guardian",
365
+ principalId: "prin-c1",
366
+ createdAt: now,
367
+ updatedAt: now,
368
+ })
369
+ .run();
370
+ seedChannel({ id: "ch1", contactId: "c1", status: "unverified" });
371
+ seedAssistantContact("c1");
372
+ seedAssistantChannel({
373
+ id: "ch1",
374
+ contactId: "c1",
375
+ status: "unverified",
376
+ });
377
+
378
+ const result = await new ContactStore().markChannelVerified("ch1");
379
+ expect(result).not.toBeNull();
380
+ expect(result!.didWrite).toBe(true);
381
+ expect(result!.channel.status).toBe("active");
382
+
383
+ const contactRow = getGatewayDb()
384
+ .select()
385
+ .from(contacts)
386
+ .where(eq(contacts.id, "c1"))
387
+ .get();
388
+ expect(contactRow!.displayName).toBe("gateway-name");
389
+ });
177
390
  });
@@ -393,6 +393,43 @@ describe("handleUpsertContact (gateway-native)", () => {
393
393
  expect(params.displayName).toBe("Alice");
394
394
  });
395
395
 
396
+ test("strips role and principalId from request body (privilege escalation guard)", async () => {
397
+ // Regression: a malicious caller MUST NOT be able to rebind the guardian
398
+ // by sending `role: "guardian"` + their own principalId via POST
399
+ // /v1/contacts. The route handler must never pass those fields through
400
+ // to the service layer; ContactStore's params surface must not include
401
+ // them.
402
+ contactStoreUpsertMock = mock(async () => ({
403
+ contact: { ...DEFAULT_MOCK_CONTACT, id: "ct_target", role: "guardian" },
404
+ created: false,
405
+ }));
406
+
407
+ const handler = createContactsControlPlaneProxyHandler(makeConfig());
408
+ const res = await handler.handleUpsertContact(
409
+ new Request("http://localhost:7830/v1/contacts", {
410
+ method: "POST",
411
+ headers: { "content-type": "application/json" },
412
+ body: JSON.stringify({
413
+ id: "ct_target",
414
+ displayName: "Pwn3d",
415
+ role: "guardian",
416
+ principalId: "attacker-principal-id",
417
+ }),
418
+ }),
419
+ );
420
+
421
+ expect(res.status).toBe(200);
422
+ expect(contactStoreUpsertMock).toHaveBeenCalledTimes(1);
423
+ const [params] = contactStoreUpsertMock.mock.calls[0] as [
424
+ Record<string, unknown>,
425
+ ];
426
+ expect(params.role).toBeUndefined();
427
+ expect(params.principalId).toBeUndefined();
428
+ // The other fields still flow through.
429
+ expect(params.id).toBe("ct_target");
430
+ expect(params.displayName).toBe("Pwn3d");
431
+ });
432
+
396
433
  test("returns 400 when body is invalid JSON", async () => {
397
434
  const handler = createContactsControlPlaneProxyHandler(makeConfig());
398
435
  const res = await handler.handleUpsertContact(
@@ -0,0 +1,275 @@
1
+ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
2
+ import { Database } from "bun:sqlite";
3
+
4
+ import type { SqliteValue } from "../db/assistant-db-proxy.js";
5
+
6
+ import "./test-preload.js";
7
+
8
+ let assistantDb: Database | null = null;
9
+
10
+ function db(): Database {
11
+ if (!assistantDb) throw new Error("test DB not initialized");
12
+ return assistantDb;
13
+ }
14
+
15
+ mock.module("../db/assistant-db-proxy.js", () => ({
16
+ async assistantDbQuery(sql: string, bind: SqliteValue[] = []) {
17
+ return db()
18
+ .prepare(sql)
19
+ .all(...bind);
20
+ },
21
+ async assistantDbRun(sql: string, bind: SqliteValue[] = []) {
22
+ const result = db()
23
+ .prepare(sql)
24
+ .run(...bind);
25
+ return {
26
+ changes: result.changes,
27
+ lastInsertRowid: Number(result.lastInsertRowid),
28
+ };
29
+ },
30
+ async assistantDbExec(sql: string) {
31
+ db().exec(sql);
32
+ },
33
+ }));
34
+
35
+ const fakeGatewayTx = {
36
+ insert: () => ({
37
+ values: () => ({
38
+ onConflictDoUpdate: () => ({ run: () => {} }),
39
+ }),
40
+ }),
41
+ };
42
+
43
+ mock.module("../db/connection.js", () => ({
44
+ getGatewayDb: () => ({
45
+ transaction: (fn: (tx: typeof fakeGatewayTx) => void) => fn(fakeGatewayTx),
46
+ }),
47
+ }));
48
+
49
+ mock.module("../db/schema.js", () => ({
50
+ actorRefreshTokenRecords: {},
51
+ actorTokenRecords: {},
52
+ contacts: {},
53
+ contactChannels: {},
54
+ }));
55
+
56
+ const { createGuardianBinding } = await import("../auth/guardian-bootstrap.js");
57
+
58
+ beforeEach(() => {
59
+ assistantDb = new Database(":memory:");
60
+ assistantDb.exec(`
61
+ CREATE TABLE contacts (
62
+ id TEXT PRIMARY KEY,
63
+ display_name TEXT NOT NULL,
64
+ role TEXT NOT NULL DEFAULT 'contact',
65
+ principal_id TEXT,
66
+ notes TEXT,
67
+ created_at INTEGER NOT NULL,
68
+ updated_at INTEGER NOT NULL
69
+ );
70
+
71
+ CREATE TABLE contact_channels (
72
+ id TEXT PRIMARY KEY,
73
+ contact_id TEXT NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
74
+ type TEXT NOT NULL,
75
+ address TEXT NOT NULL,
76
+ external_user_id TEXT,
77
+ external_chat_id TEXT,
78
+ is_primary INTEGER NOT NULL DEFAULT 0,
79
+ status TEXT NOT NULL DEFAULT 'unverified',
80
+ policy TEXT NOT NULL DEFAULT 'allow',
81
+ verified_at INTEGER,
82
+ verified_via TEXT,
83
+ revoked_reason TEXT,
84
+ blocked_reason TEXT,
85
+ created_at INTEGER NOT NULL,
86
+ updated_at INTEGER
87
+ );
88
+
89
+ CREATE UNIQUE INDEX idx_contact_channels_type_address
90
+ ON contact_channels(type, address);
91
+ `);
92
+ });
93
+
94
+ afterEach(() => {
95
+ assistantDb?.close();
96
+ assistantDb = null;
97
+ });
98
+
99
+ function seedGuardianContact(): void {
100
+ db()
101
+ .prepare(
102
+ `INSERT INTO contacts
103
+ (id, display_name, role, principal_id, notes, created_at, updated_at)
104
+ VALUES
105
+ ('guardian-contact', 'Example User', 'guardian', 'guardian-principal', 'guardian', 1, 1)`,
106
+ )
107
+ .run();
108
+ }
109
+
110
+ function seedSlackContactChannel(address: string): void {
111
+ db()
112
+ .prepare(
113
+ `INSERT INTO contacts
114
+ (id, display_name, role, principal_id, notes, created_at, updated_at)
115
+ VALUES
116
+ ('seed-contact', 'Example User', 'contact', NULL, NULL, 1, 1)`,
117
+ )
118
+ .run();
119
+ db()
120
+ .prepare(
121
+ `INSERT INTO contact_channels
122
+ (id, contact_id, type, address, external_user_id, external_chat_id,
123
+ is_primary, status, policy, created_at, updated_at)
124
+ VALUES
125
+ ('seed-channel', 'seed-contact', 'slack', ?, 'U123EXAMPLE',
126
+ 'D123EXAMPLE', 0, 'unverified', 'allow', 1, 1)`,
127
+ )
128
+ .run(address);
129
+ }
130
+
131
+ function seedRevokedGuardianSlackChannel(): void {
132
+ db()
133
+ .prepare(
134
+ `INSERT INTO contact_channels
135
+ (id, contact_id, type, address, external_user_id, external_chat_id,
136
+ is_primary, status, policy, created_at, updated_at)
137
+ VALUES
138
+ ('guardian-channel', 'guardian-contact', 'slack', 'U123EXAMPLE',
139
+ 'U123EXAMPLE', 'D123EXAMPLE', 1, 'revoked', 'deny', 1, 1)`,
140
+ )
141
+ .run();
142
+ }
143
+
144
+ describe("createGuardianBinding", () => {
145
+ test("claims a preseeded Slack channel for the guardian instead of inserting a duplicate", async () => {
146
+ seedGuardianContact();
147
+ seedSlackContactChannel("U123EXAMPLE");
148
+
149
+ const result = await createGuardianBinding({
150
+ channel: "slack",
151
+ externalUserId: "U123EXAMPLE",
152
+ deliveryChatId: "D123EXAMPLE",
153
+ guardianPrincipalId: "guardian-principal",
154
+ displayName: "Example User",
155
+ verifiedVia: "challenge",
156
+ });
157
+
158
+ expect(result.contactId).toBe("guardian-contact");
159
+ expect(result.channelId).toBe("seed-channel");
160
+
161
+ const rows = db()
162
+ .query<
163
+ {
164
+ id: string;
165
+ contact_id: string;
166
+ address: string;
167
+ external_user_id: string;
168
+ status: string;
169
+ policy: string;
170
+ is_primary: number;
171
+ },
172
+ []
173
+ >("SELECT * FROM contact_channels WHERE type = 'slack'")
174
+ .all();
175
+ expect(rows).toHaveLength(1);
176
+ expect(rows[0]).toMatchObject({
177
+ id: "seed-channel",
178
+ contact_id: "guardian-contact",
179
+ address: "U123EXAMPLE",
180
+ external_user_id: "U123EXAMPLE",
181
+ status: "active",
182
+ policy: "allow",
183
+ is_primary: 1,
184
+ });
185
+ });
186
+
187
+ test("repairs an old lowercase Slack address by matching the preserved external user ID", async () => {
188
+ seedGuardianContact();
189
+ seedSlackContactChannel("u123example");
190
+
191
+ await createGuardianBinding({
192
+ channel: "slack",
193
+ externalUserId: "U123EXAMPLE",
194
+ deliveryChatId: "D123EXAMPLE",
195
+ guardianPrincipalId: "guardian-principal",
196
+ displayName: "Example User",
197
+ verifiedVia: "challenge",
198
+ });
199
+
200
+ const rows = db()
201
+ .query<
202
+ {
203
+ id: string;
204
+ contact_id: string;
205
+ address: string;
206
+ external_user_id: string;
207
+ status: string;
208
+ },
209
+ []
210
+ >(
211
+ `SELECT id, contact_id, address, external_user_id, status
212
+ FROM contact_channels
213
+ WHERE type = 'slack'`,
214
+ )
215
+ .all();
216
+ expect(rows).toEqual([
217
+ {
218
+ id: "seed-channel",
219
+ contact_id: "guardian-contact",
220
+ address: "U123EXAMPLE",
221
+ external_user_id: "U123EXAMPLE",
222
+ status: "active",
223
+ },
224
+ ]);
225
+ });
226
+
227
+ test("prefers the cased guardian channel over a lowercase seed duplicate", async () => {
228
+ seedGuardianContact();
229
+ seedRevokedGuardianSlackChannel();
230
+ seedSlackContactChannel("u123example");
231
+
232
+ const result = await createGuardianBinding({
233
+ channel: "slack",
234
+ externalUserId: "U123EXAMPLE",
235
+ deliveryChatId: "D123EXAMPLE",
236
+ guardianPrincipalId: "guardian-principal",
237
+ displayName: "Example User",
238
+ verifiedVia: "challenge",
239
+ });
240
+
241
+ expect(result.contactId).toBe("guardian-contact");
242
+ expect(result.channelId).toBe("guardian-channel");
243
+
244
+ const rows = db()
245
+ .query<
246
+ {
247
+ id: string;
248
+ contact_id: string;
249
+ address: string;
250
+ status: string;
251
+ },
252
+ []
253
+ >(
254
+ `SELECT id, contact_id, address, status
255
+ FROM contact_channels
256
+ WHERE type = 'slack'
257
+ ORDER BY id`,
258
+ )
259
+ .all();
260
+ expect(rows).toEqual([
261
+ {
262
+ id: "guardian-channel",
263
+ contact_id: "guardian-contact",
264
+ address: "U123EXAMPLE",
265
+ status: "active",
266
+ },
267
+ {
268
+ id: "seed-channel",
269
+ contact_id: "seed-contact",
270
+ address: "u123example",
271
+ status: "unverified",
272
+ },
273
+ ]);
274
+ });
275
+ });