@vellumai/vellum-gateway 0.7.2 → 0.7.3

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 (38) hide show
  1. package/ARCHITECTURE.md +20 -21
  2. package/README.md +6 -6
  3. package/package.json +1 -1
  4. package/src/__tests__/config-file-watcher.test.ts +1 -1
  5. package/src/__tests__/contact-prompt-submit.test.ts +349 -0
  6. package/src/__tests__/ipc-route-policy.test.ts +24 -0
  7. package/src/__tests__/nonbash-trust-rule-overrides.test.ts +50 -0
  8. package/src/__tests__/remote-feature-flag-sync.test.ts +16 -14
  9. package/src/__tests__/slack-display-name.test.ts +6 -2
  10. package/src/__tests__/slack-normalize.test.ts +36 -56
  11. package/src/__tests__/slack-socket-mode-thread-tracking.test.ts +4 -2
  12. package/src/__tests__/telegram-webhook-manager.test.ts +8 -25
  13. package/src/__tests__/twilio-webhooks.test.ts +2 -6
  14. package/src/__tests__/upsert-verified-contact-channel.test.ts +173 -0
  15. package/src/auth/guardian-bootstrap.ts +49 -0
  16. package/src/auth/ipc-route-policy.ts +5 -0
  17. package/src/db/contact-store.ts +27 -1
  18. package/src/email/register-callback.test.ts +4 -4
  19. package/src/email/register-callback.ts +12 -16
  20. package/src/feature-flag-registry.json +27 -3
  21. package/src/handlers/handle-inbound.ts +12 -0
  22. package/src/http/routes/contact-prompt.ts +134 -23
  23. package/src/http/routes/contacts-control-plane-proxy.ts +34 -5
  24. package/src/http/routes/ipc-runtime-proxy.ts +18 -0
  25. package/src/http/routes/twilio-voice-webhook.test.ts +22 -1
  26. package/src/http/routes/twilio-voice-webhook.ts +53 -0
  27. package/src/index.ts +4 -2
  28. package/src/ipc/velay-handlers.ts +31 -0
  29. package/src/remote-feature-flag-sync.ts +10 -8
  30. package/src/risk/command-registry/commands/assistant.ts +1 -0
  31. package/src/risk/skill-risk-classifier.ts +12 -3
  32. package/src/runtime/client.ts +25 -12
  33. package/src/slack/normalize.test.ts +3 -3
  34. package/src/slack/normalize.ts +6 -69
  35. package/src/slack/socket-mode.ts +1 -5
  36. package/src/telegram/webhook-manager.ts +9 -13
  37. package/src/velay/client.ts +27 -16
  38. package/src/verification/contact-helpers.ts +6 -3
@@ -116,13 +116,13 @@ function defaultCredentials(): Record<string, string> {
116
116
  // Setup / teardown
117
117
  // ---------------------------------------------------------------------------
118
118
  const savedVellumPlatformUrl = process.env.VELLUM_PLATFORM_URL;
119
- const savedPlatformInternalApiKey = process.env.PLATFORM_INTERNAL_API_KEY;
119
+ const savedAssistantCredential = process.env.ASSISTANT_API_KEY;
120
120
 
121
121
  beforeEach(() => {
122
122
  // Clear env vars that the production code falls back to, so tests remain
123
123
  // deterministic unless they explicitly set them.
124
124
  delete process.env.VELLUM_PLATFORM_URL;
125
- delete process.env.PLATFORM_INTERNAL_API_KEY;
125
+ delete process.env.ASSISTANT_API_KEY;
126
126
  mkdirSync(protectedDir, { recursive: true });
127
127
  // Write the test registry and point resolution at it
128
128
  writeFileSync(testRegistryPath, JSON.stringify(TEST_REGISTRY, null, 2));
@@ -142,7 +142,7 @@ afterEach(() => {
142
142
  }
143
143
  };
144
144
  restoreEnv("VELLUM_PLATFORM_URL", savedVellumPlatformUrl);
145
- restoreEnv("PLATFORM_INTERNAL_API_KEY", savedPlatformInternalApiKey);
145
+ restoreEnv("ASSISTANT_API_KEY", savedAssistantCredential);
146
146
  try {
147
147
  rmSync(protectedDir, { recursive: true, force: true });
148
148
  mkdirSync(protectedDir, { recursive: true });
@@ -195,7 +195,7 @@ describe("RemoteFeatureFlagSync", () => {
195
195
  );
196
196
  });
197
197
 
198
- test("skips sync when assistant_api_key is missing and no PLATFORM_INTERNAL_API_KEY", async () => {
198
+ test("skips sync when assistant_api_key is missing", async () => {
199
199
  const creds = defaultCredentials();
200
200
  delete creds["credential/vellum/assistant_api_key"];
201
201
 
@@ -208,12 +208,13 @@ describe("RemoteFeatureFlagSync", () => {
208
208
  expect(fetchMock).not.toHaveBeenCalled();
209
209
  });
210
210
 
211
- test("does not use PLATFORM_INTERNAL_API_KEY when assistant_api_key is missing", async () => {
212
- fetchMock = mock(async () => Response.json({ flags: {} }));
213
- process.env.PLATFORM_INTERNAL_API_KEY = "internal-key-123";
211
+ test("syncs when only platformUrl and assistantApiKey are present", async () => {
212
+ fetchMock = mock(async () => Response.json({ flags: { ff1: true } }));
214
213
 
215
- const creds = defaultCredentials();
216
- delete creds["credential/vellum/assistant_api_key"];
214
+ const creds = {
215
+ "credential/vellum/platform_base_url": "https://platform.example.com",
216
+ "credential/vellum/assistant_api_key": "test-api-key",
217
+ };
217
218
 
218
219
  const sync = new RemoteFeatureFlagSync({
219
220
  credentials: fakeCredentialCache(creds),
@@ -221,17 +222,15 @@ describe("RemoteFeatureFlagSync", () => {
221
222
  await sync.start();
222
223
  sync.stop();
223
224
 
224
- // PLATFORM_INTERNAL_API_KEY is only for internal gateway endpoints —
225
- // feature flag sync requires assistant_api_key (Api-Key auth).
226
- expect(fetchMock).not.toHaveBeenCalled();
225
+ expect(fetchMock).toHaveBeenCalledTimes(1);
227
226
  });
228
227
 
229
- test("syncs when only platformUrl and assistantApiKey are present", async () => {
228
+ test("falls back to ASSISTANT_API_KEY env var when credential key is missing", async () => {
230
229
  fetchMock = mock(async () => Response.json({ flags: { ff1: true } }));
230
+ process.env.ASSISTANT_API_KEY = "env-key";
231
231
 
232
232
  const creds = {
233
233
  "credential/vellum/platform_base_url": "https://platform.example.com",
234
- "credential/vellum/assistant_api_key": "test-api-key",
235
234
  };
236
235
 
237
236
  const sync = new RemoteFeatureFlagSync({
@@ -241,6 +240,9 @@ describe("RemoteFeatureFlagSync", () => {
241
240
  sync.stop();
242
241
 
243
242
  expect(fetchMock).toHaveBeenCalledTimes(1);
243
+ const [, init] = fetchMock.mock.calls[0];
244
+ const headers = init?.headers as Record<string, string>;
245
+ expect(headers.Authorization).toBe("Api-Key env-key");
244
246
  });
245
247
 
246
248
  test("fetches and caches flags on successful response", async () => {
@@ -259,7 +259,9 @@ describe("normalizeSlackAppMention with display name", () => {
259
259
  );
260
260
 
261
261
  expect(result).not.toBeNull();
262
- expect(result!.event.message.content).toBe("@Leo please look");
262
+ expect(result!.event.message.content).toBe(
263
+ "@unknown-user @Leo please look",
264
+ );
263
265
  expect(result!.event.message.content).not.toContain("<@ULEO>");
264
266
  expect(result!.event.message.content).not.toContain("ULEO");
265
267
  });
@@ -285,7 +287,9 @@ describe("normalizeSlackAppMention with display name", () => {
285
287
  );
286
288
 
287
289
  expect(result).not.toBeNull();
288
- expect(result!.event.message.content).toBe("@unknown-user please look");
290
+ expect(result!.event.message.content).toBe(
291
+ "@unknown-user @unknown-user please look",
292
+ );
289
293
  expect(result!.event.message.content).not.toContain("<@UFAIL>");
290
294
  expect(result!.event.message.content).not.toContain("UFAIL");
291
295
  });
@@ -1,6 +1,5 @@
1
1
  import { describe, test, expect } from "bun:test";
2
2
  import {
3
- stripBotMention,
4
3
  normalizeSlackAppMention,
5
4
  normalizeSlackChannelMessage,
6
5
  normalizeSlackDirectMessage,
@@ -52,42 +51,6 @@ function makeEvent(
52
51
  };
53
52
  }
54
53
 
55
- describe("stripBotMention", () => {
56
- test("strips a single leading bot mention", () => {
57
- expect(stripBotMention("<@U123BOT> hello world")).toBe("hello world");
58
- });
59
-
60
- test("strips repeated leading bot mentions while preserving a following human mention", () => {
61
- expect(
62
- stripBotMention("<@U123BOT> <@U123BOT> <@U456OTHER> hello", "U123BOT"),
63
- ).toBe("<@U456OTHER> hello");
64
- });
65
-
66
- test("fallback strips only the first leading mention", () => {
67
- expect(stripBotMention("<@U123BOT> <@U456OTHER> hello")).toBe(
68
- "<@U456OTHER> hello",
69
- );
70
- });
71
-
72
- test("falls back to original text when stripping produces empty string", () => {
73
- expect(stripBotMention("<@U123BOT>")).toBe("<@U123BOT>");
74
- });
75
-
76
- test("falls back to original trimmed text when stripping produces whitespace only", () => {
77
- expect(stripBotMention("<@U123BOT> ")).toBe("<@U123BOT>");
78
- });
79
-
80
- test("returns text unchanged when no leading mention", () => {
81
- expect(stripBotMention("hello world")).toBe("hello world");
82
- });
83
-
84
- test("does not strip mid-text mentions", () => {
85
- expect(stripBotMention("hello <@U123BOT> world")).toBe(
86
- "hello <@U123BOT> world",
87
- );
88
- });
89
- });
90
-
91
54
  describe("normalizeSlackAppMention", () => {
92
55
  test("normalizes app_mention event with sourceChannel 'slack'", async () => {
93
56
  const config = makeConfig();
@@ -129,16 +92,23 @@ describe("normalizeSlackAppMention", () => {
129
92
  expect(result!.event.message.externalMessageId).toBe("1700000000.000100");
130
93
  });
131
94
 
132
- test("mention stripping: '<@U123BOT> hello world' becomes 'hello world'", async () => {
95
+ test("renders the bot's mention using the resolved label", async () => {
133
96
  const config = makeConfig();
134
97
  const event = makeEvent({ text: "<@U123BOT> hello world" });
135
- const result = await normalizeSlackAppMention(event, "evt-005", config);
98
+ const result = await normalizeSlackAppMention(
99
+ event,
100
+ "evt-005",
101
+ config,
102
+ "U123BOT",
103
+ undefined,
104
+ { userLabels: { U123BOT: "vex" } },
105
+ );
136
106
 
137
107
  expect(result).not.toBeNull();
138
- expect(result!.event.message.content).toBe("hello world");
108
+ expect(result!.event.message.content).toBe("@vex hello world");
139
109
  });
140
110
 
141
- test("mention stripping with empty result falls back to original text", async () => {
111
+ test("renders the bot's mention with the unknown-user fallback when unresolved", async () => {
142
112
  const config = makeConfig();
143
113
  const event = makeEvent({ text: "<@U123BOT>" });
144
114
  const result = await normalizeSlackAppMention(event, "evt-006", config);
@@ -147,7 +117,7 @@ describe("normalizeSlackAppMention", () => {
147
117
  expect(result!.event.message.content).toBe("@unknown-user");
148
118
  });
149
119
 
150
- test("renders a human mention after stripping the app mention", async () => {
120
+ test("renders bot and human mentions side by side", async () => {
151
121
  const config = makeConfig();
152
122
  const event = makeEvent({ text: "<@UBOT> <@ULEO> can you check?" });
153
123
  const result = await normalizeSlackAppMention(
@@ -156,11 +126,11 @@ describe("normalizeSlackAppMention", () => {
156
126
  config,
157
127
  "UBOT",
158
128
  undefined,
159
- { userLabels: { ULEO: "leo" } },
129
+ { userLabels: { UBOT: "vex", ULEO: "leo" } },
160
130
  );
161
131
 
162
132
  expect(result).not.toBeNull();
163
- expect(result!.event.message.content).toBe("@leo can you check?");
133
+ expect(result!.event.message.content).toBe("@vex @leo can you check?");
164
134
  });
165
135
 
166
136
  test("renders unresolved user mentions with the unknown-user fallback", async () => {
@@ -174,7 +144,9 @@ describe("normalizeSlackAppMention", () => {
174
144
  );
175
145
 
176
146
  expect(result).not.toBeNull();
177
- expect(result!.event.message.content).toBe("@unknown-user can you check?");
147
+ expect(result!.event.message.content).toBe(
148
+ "@unknown-user @unknown-user can you check?",
149
+ );
178
150
  });
179
151
 
180
152
  test("thread_ts is preserved in return value", async () => {
@@ -295,7 +267,7 @@ describe("Slack inbound mention rendering", () => {
295
267
  expect(result!.event.message.conversationExternalId).toBe("D_DIRECT1");
296
268
  });
297
269
 
298
- test("channel messages strip only the configured bot mention and render remaining mentions", () => {
270
+ test("channel messages render bot and human mentions inline", () => {
299
271
  const config = makeConfig();
300
272
  const event = makeChannelMessageEvent({
301
273
  text: "<@UBOT> <@ULEO> hello",
@@ -306,16 +278,16 @@ describe("Slack inbound mention rendering", () => {
306
278
  config,
307
279
  "UBOT",
308
280
  undefined,
309
- { userLabels: { ULEO: "leo" } },
281
+ { userLabels: { UBOT: "vex", ULEO: "leo" } },
310
282
  );
311
283
 
312
284
  expect(result).not.toBeNull();
313
- expect(result!.event.message.content).toBe("@leo hello");
285
+ expect(result!.event.message.content).toBe("@vex @leo hello");
314
286
  expect(result!.event.actor.actorExternalId).toBe("U_USER123");
315
287
  expect(result!.event.message.conversationExternalId).toBe("C_CHANNEL1");
316
288
  });
317
289
 
318
- test("channel messages without bot user ID strip only the first leading mention fallback", () => {
290
+ test("channel messages render with unknown-user fallback when bot label is missing", () => {
319
291
  const config = makeConfig();
320
292
  const event = makeChannelMessageEvent({
321
293
  text: "<@UBOT> <@ULEO> hello",
@@ -330,10 +302,10 @@ describe("Slack inbound mention rendering", () => {
330
302
  );
331
303
 
332
304
  expect(result).not.toBeNull();
333
- expect(result!.event.message.content).toBe("@leo hello");
305
+ expect(result!.event.message.content).toBe("@unknown-user @leo hello");
334
306
  });
335
307
 
336
- test("message edits render mentions and preserve edit metadata", () => {
308
+ test("message edits render bot and human mentions and preserve edit metadata", () => {
337
309
  const config = makeConfig();
338
310
  const event = makeMessageChangedEvent({
339
311
  message: {
@@ -347,11 +319,11 @@ describe("Slack inbound mention rendering", () => {
347
319
  "evt-edit-render",
348
320
  config,
349
321
  "UBOT",
350
- { userLabels: { ULEO: "leo" } },
322
+ { userLabels: { UBOT: "vex", ULEO: "leo" } },
351
323
  );
352
324
 
353
325
  expect(result).not.toBeNull();
354
- expect(result!.event.message.content).toBe("@leo edited");
326
+ expect(result!.event.message.content).toBe("@vex @leo edited");
355
327
  expect(result!.event.message.isEdit).toBe(true);
356
328
  expect(result!.event.message.externalMessageId).toBe("evt-edit-render");
357
329
  expect(result!.event.source.messageId).toBe("1700000000.000100");
@@ -424,7 +396,7 @@ describe("normalizeSlackMessageEdit", () => {
424
396
  expect(result).toBeNull();
425
397
  });
426
398
 
427
- test("strips bot mention from edited text", () => {
399
+ test("renders bot mention in edited text", () => {
428
400
  const config = makeConfig();
429
401
  const event = makeMessageChangedEvent({
430
402
  message: {
@@ -433,10 +405,18 @@ describe("normalizeSlackMessageEdit", () => {
433
405
  ts: "1700000000.000100",
434
406
  },
435
407
  });
436
- const result = normalizeSlackMessageEdit(event, "evt-104", config);
408
+ const result = normalizeSlackMessageEdit(
409
+ event,
410
+ "evt-104",
411
+ config,
412
+ "U123BOT",
413
+ {
414
+ userLabels: { U123BOT: "vex" },
415
+ },
416
+ );
437
417
 
438
418
  expect(result).not.toBeNull();
439
- expect(result!.event.message.content).toBe("edited content");
419
+ expect(result!.event.message.content).toBe("@vex edited content");
440
420
  });
441
421
 
442
422
  test("sets actor.actorExternalId from edited message user", () => {
@@ -293,7 +293,7 @@ describe("SlackSocketModeClient thread tracking", () => {
293
293
  expect(emitted).toHaveLength(2);
294
294
  expect(emitted[0].event.source.updateId).toBe("Ev-race-mention");
295
295
  expect(emitted[0].event.message.content).toBe(
296
- "@Example User can you help here?",
296
+ "@Example User @Example User can you help here?",
297
297
  );
298
298
  expect(emitted[1].event.source.updateId).toBe("Ev-race-reply");
299
299
  expect(emitted[1].event.message.content).toBe(
@@ -644,7 +644,9 @@ describe("SlackSocketModeClient thread tracking", () => {
644
644
  await flushAsyncEventEmission();
645
645
 
646
646
  expect(emitted).toHaveLength(1);
647
- expect(emitted[0].event.message.content).toBe("@Leo please look");
647
+ expect(emitted[0].event.message.content).toBe(
648
+ "@Example User @Leo please look",
649
+ );
648
650
  expect(emitted[0].event.message.content).not.toContain("<@ULEO>");
649
651
  expect(emitted[0].event.message.content).not.toContain("ULEO");
650
652
  } finally {
@@ -20,6 +20,9 @@ const { reconcileTelegramWebhook } =
20
20
 
21
21
  afterEach(() => {
22
22
  fetchMock = mock(async () => new Response());
23
+ delete process.env.IS_CONTAINERIZED;
24
+ delete process.env.VELLUM_PLATFORM_URL;
25
+ delete process.env.ASSISTANT_API_KEY;
23
26
  });
24
27
 
25
28
  function makeTelegramResponse(result: unknown) {
@@ -290,10 +293,9 @@ describe("reconcileTelegramWebhook", () => {
290
293
  "https://platform.example.com/v1/gateway/callbacks/11111111-2222-4333-8444-555555555555/webhooks/telegram/",
291
294
  );
292
295
  expect((calls[2].body as any).secret_token).toBe("test-webhook-secret");
293
- delete process.env.IS_CONTAINERIZED;
294
296
  });
295
297
 
296
- test("registers via credential cache for assistant ID", async () => {
298
+ test("registers via env assistant key and credential cache for assistant ID", async () => {
297
299
  const calls: {
298
300
  method: string;
299
301
  body: unknown;
@@ -301,7 +303,7 @@ describe("reconcileTelegramWebhook", () => {
301
303
  }[] = [];
302
304
  process.env.IS_CONTAINERIZED = "true";
303
305
  process.env.VELLUM_PLATFORM_URL = "https://env-platform.example.com";
304
- process.env.PLATFORM_INTERNAL_API_KEY = "internal-key-from-env";
306
+ process.env.ASSISTANT_API_KEY = "env-key";
305
307
 
306
308
  const caches = makeCaches({
307
309
  ingressUrl: undefined,
@@ -359,21 +361,14 @@ describe("reconcileTelegramWebhook", () => {
359
361
  callback_path: "webhooks/telegram",
360
362
  type: "telegram",
361
363
  });
362
- // PLATFORM_INTERNAL_API_KEY should use Bearer auth scheme
363
- expect(calls[0].headers?.Authorization).toBe(
364
- "Bearer internal-key-from-env",
365
- );
364
+ expect(calls[0].headers?.Authorization).toBe("Api-Key env-key");
366
365
  expect(calls[2].method).toBe("setWebhook");
367
366
  expect((calls[2].body as any).url).toBe(
368
367
  "https://env-platform.example.com/v1/gateway/callbacks/aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee/webhooks/telegram/",
369
368
  );
370
-
371
- delete process.env.IS_CONTAINERIZED;
372
- delete process.env.VELLUM_PLATFORM_URL;
373
- delete process.env.PLATFORM_INTERNAL_API_KEY;
374
369
  });
375
370
 
376
- test("credential cache for assistant ID and base URL, env for auth key", async () => {
371
+ test("credential cache for assistant ID, base URL, and auth key", async () => {
377
372
  const calls: {
378
373
  method: string;
379
374
  body: unknown;
@@ -381,7 +376,6 @@ describe("reconcileTelegramWebhook", () => {
381
376
  }[] = [];
382
377
  process.env.IS_CONTAINERIZED = "true";
383
378
  process.env.VELLUM_PLATFORM_URL = "https://env-platform.example.com";
384
- process.env.PLATFORM_INTERNAL_API_KEY = "env-internal-key";
385
379
 
386
380
  const caches = makeCaches({
387
381
  ingressUrl: undefined,
@@ -435,28 +429,20 @@ describe("reconcileTelegramWebhook", () => {
435
429
  expect(calls).toHaveLength(3);
436
430
  expect(calls[0].method).toBe("registerCallbackRoute");
437
431
  // platform_base_url: credential cache takes precedence over env var
438
- // PLATFORM_INTERNAL_API_KEY: env var takes
439
- // precedence, matching the daemon's resolvePlatformCallbackRegistrationContext().
440
432
  expect(calls[0].body).toEqual({
441
433
  assistant_id: "cache-assistant-id",
442
434
  callback_path: "webhooks/telegram",
443
435
  type: "telegram",
444
436
  });
445
- expect(calls[0].headers?.Authorization).toBe("Bearer env-internal-key");
437
+ expect(calls[0].headers?.Authorization).toBe("Api-Key cache-api-key");
446
438
  // Registration URL should use cache platform URL
447
439
  expect((calls[2].body as any).url).toBe(
448
440
  "https://cache-platform.example.com/v1/gateway/callbacks/cache-assistant-id/webhooks/telegram/",
449
441
  );
450
-
451
- delete process.env.IS_CONTAINERIZED;
452
- delete process.env.VELLUM_PLATFORM_URL;
453
- delete process.env.PLATFORM_INTERNAL_API_KEY;
454
442
  });
455
443
 
456
444
  test("skips registration when no platform URL is available from cache or env", async () => {
457
445
  process.env.IS_CONTAINERIZED = "true";
458
- process.env.PLATFORM_INTERNAL_API_KEY = "internal-key-from-env";
459
- delete process.env.VELLUM_PLATFORM_URL;
460
446
 
461
447
  const caches = makeCaches({
462
448
  ingressUrl: undefined,
@@ -471,9 +457,6 @@ describe("reconcileTelegramWebhook", () => {
471
457
 
472
458
  // No fetch calls should be made — registration is skipped
473
459
  expect(fetchMock).not.toHaveBeenCalled();
474
-
475
- delete process.env.IS_CONTAINERIZED;
476
- delete process.env.PLATFORM_INTERNAL_API_KEY;
477
460
  });
478
461
 
479
462
  test("calls setWebhook when current URL is empty", async () => {
@@ -150,11 +150,7 @@ function makeCaches(
150
150
  ingressUrl?: string;
151
151
  } = {},
152
152
  ) {
153
- const {
154
- authToken = AUTH_TOKEN,
155
- ingressEnabled,
156
- ingressUrl,
157
- } = opts;
153
+ const { authToken = AUTH_TOKEN, ingressEnabled, ingressUrl } = opts;
158
154
  const credentials = {
159
155
  get: async (key: string, _opts?: { force?: boolean }) => {
160
156
  if (key === credentialKey("twilio", "auth_token")) return authToken;
@@ -876,7 +872,7 @@ describe("Twilio webhook signature with canonical ingress base URL", () => {
876
872
  });
877
873
 
878
874
  describe("Twilio webhook force retry", () => {
879
- test("refreshes Twilio-specific ingress URL before retrying signature validation", async () => {
875
+ test("refreshes configured ingress URL before retrying signature validation", async () => {
880
876
  fetchMock = mock(
881
877
  async () =>
882
878
  new Response('<?xml version="1.0" encoding="UTF-8"?><Response/>', {
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Tests for upsertVerifiedContactChannel: must not reactivate
3
+ * revoked or blocked channels.
4
+ */
5
+
6
+ import { describe, test, expect, beforeEach, mock } from "bun:test";
7
+
8
+ import "./test-preload.js";
9
+
10
+ // ---------------------------------------------------------------------------
11
+ // DB mock — configurable per test
12
+ // ---------------------------------------------------------------------------
13
+
14
+ type ExistingRow = {
15
+ channelId: string;
16
+ contactId: string;
17
+ channelStatus: string;
18
+ };
19
+
20
+ let queryRows: ExistingRow[] = [];
21
+ const runCalls: { sql: string; params: unknown[] }[] = [];
22
+
23
+ mock.module("../db/assistant-db-proxy.js", () => ({
24
+ assistantDbQuery: async (_sql: string, _params: unknown[]) => queryRows,
25
+ assistantDbRun: async (sql: string, params: unknown[]) => {
26
+ runCalls.push({ sql, params });
27
+ },
28
+ }));
29
+
30
+ mock.module("../db/connection.js", () => ({
31
+ getGatewayDb: () => ({
32
+ update: () => ({ set: () => ({ where: () => ({ run: () => {} }) }) }),
33
+ insert: () => ({
34
+ values: () => ({ onConflictDoNothing: () => ({ run: () => {} }) }),
35
+ }),
36
+ }),
37
+ }));
38
+
39
+ mock.module("../db/schema.js", () => ({
40
+ contactChannels: "contactChannels",
41
+ contacts: "contacts",
42
+ }));
43
+
44
+ mock.module("drizzle-orm", () => ({
45
+ eq: (col: unknown, val: unknown) => ({ col, val }),
46
+ }));
47
+
48
+ mock.module("../verification/identity.js", () => ({
49
+ canonicalizeInboundIdentity: (_channel: string, id: string) => id,
50
+ }));
51
+
52
+ mock.module("../ipc/socket-path.js", () => ({
53
+ resolveIpcSocketPath: () => ({ path: "/tmp/test.sock" }),
54
+ }));
55
+
56
+ // Import after mocks
57
+ const { upsertVerifiedContactChannel } = await import(
58
+ "../verification/contact-helpers.js"
59
+ );
60
+
61
+ beforeEach(() => {
62
+ queryRows = [];
63
+ runCalls.length = 0;
64
+ });
65
+
66
+ // ---------------------------------------------------------------------------
67
+ // Tests
68
+ // ---------------------------------------------------------------------------
69
+
70
+ describe("upsertVerifiedContactChannel — revoked/blocked guards", () => {
71
+ test("skips update when existing channel is revoked", async () => {
72
+ queryRows = [
73
+ {
74
+ channelId: "ch-1",
75
+ contactId: "co-1",
76
+ channelStatus: "revoked",
77
+ },
78
+ ];
79
+
80
+ await upsertVerifiedContactChannel({
81
+ sourceChannel: "phone",
82
+ externalUserId: "+15550001111",
83
+ externalChatId: "+15550001111",
84
+ });
85
+
86
+ expect(runCalls.filter((c) => c.sql.includes("UPDATE"))).toHaveLength(0);
87
+ });
88
+
89
+ test("skips update when channel is blocked", async () => {
90
+ queryRows = [
91
+ {
92
+ channelId: "ch-2",
93
+ contactId: "co-2",
94
+ channelStatus: "blocked",
95
+ },
96
+ ];
97
+
98
+ await upsertVerifiedContactChannel({
99
+ sourceChannel: "phone",
100
+ externalUserId: "+15550001111",
101
+ externalChatId: "+15550001111",
102
+ });
103
+
104
+ expect(runCalls.filter((c) => c.sql.includes("UPDATE"))).toHaveLength(0);
105
+ });
106
+
107
+ test("skips update when a guardian's channel is revoked", async () => {
108
+ queryRows = [
109
+ {
110
+ channelId: "ch-3",
111
+ contactId: "co-3",
112
+ channelStatus: "revoked",
113
+ },
114
+ ];
115
+
116
+ await upsertVerifiedContactChannel({
117
+ sourceChannel: "phone",
118
+ externalUserId: "+15550001111",
119
+ externalChatId: "+15550001111",
120
+ });
121
+
122
+ expect(runCalls.filter((c) => c.sql.includes("UPDATE"))).toHaveLength(0);
123
+ });
124
+
125
+ test("updates an active channel belonging to a guardian contact", async () => {
126
+ queryRows = [
127
+ {
128
+ channelId: "ch-4",
129
+ contactId: "co-4",
130
+ channelStatus: "active",
131
+ },
132
+ ];
133
+
134
+ await upsertVerifiedContactChannel({
135
+ sourceChannel: "phone",
136
+ externalUserId: "+15550001111",
137
+ externalChatId: "+15550001111",
138
+ });
139
+
140
+ expect(runCalls.filter((c) => c.sql.includes("UPDATE"))).toHaveLength(1);
141
+ });
142
+
143
+ test("updates an active channel belonging to a non-guardian contact", async () => {
144
+ queryRows = [
145
+ {
146
+ channelId: "ch-5",
147
+ contactId: "co-5",
148
+ channelStatus: "active",
149
+ },
150
+ ];
151
+
152
+ await upsertVerifiedContactChannel({
153
+ sourceChannel: "phone",
154
+ externalUserId: "+15550001111",
155
+ externalChatId: "+15550001111",
156
+ });
157
+
158
+ expect(runCalls.filter((c) => c.sql.includes("UPDATE"))).toHaveLength(1);
159
+ });
160
+
161
+ test("creates new contact + channel when no existing channel found", async () => {
162
+ queryRows = [];
163
+
164
+ await upsertVerifiedContactChannel({
165
+ sourceChannel: "phone",
166
+ externalUserId: "+15550009999",
167
+ externalChatId: "+15550009999",
168
+ });
169
+
170
+ const inserts = runCalls.filter((c) => c.sql.includes("INSERT"));
171
+ expect(inserts).toHaveLength(2);
172
+ });
173
+ });