@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
@@ -65,3 +65,96 @@ describe("ipc-route-policy: inference provider connections", () => {
65
65
  expect(policy!.requiredScopes).toEqual(["settings.write"]);
66
66
  });
67
67
  });
68
+
69
+ describe("ipc-route-policy: ATL-315 Batch 18 — new operationIds", () => {
70
+ // Batch 18 added IPC policy entries for operationIds shipped after the
71
+ // initial ATL-315 cutover. Without these entries, an authenticated edge
72
+ // JWT with only `chat.read` could reach sensitive routes (config writes,
73
+ // platform connect, schedule creation, credential mutations, sequence
74
+ // mutations, debug bash, etc.) by setting `X-Vellum-Proxy-Server: ipc`.
75
+ //
76
+ // These tests pin the scope mapping so a future refactor can't silently
77
+ // weaken it.
78
+
79
+ // Scope assertions — every Batch 18 entry must match the daemon HTTP
80
+ // scope its `policyKey`/method resolves to. Asserted explicitly here
81
+ // so a future refactor can't silently widen IPC vs. HTTP.
82
+ test.each([
83
+ // Reads — settings.read
84
+ ["backup_destinations_list", "settings.read"],
85
+ ["backup_status", "settings.read"],
86
+ ["backups_list", "settings.read"],
87
+ ["backups_verify", "settings.read"],
88
+ ["config_allowlist_validate", "settings.read"],
89
+ ["config_schema_get", "settings.read"],
90
+ ["credentials_status", "settings.read"],
91
+ ["domain_status", "settings.read"],
92
+ ["email_attachment_get", "settings.read"],
93
+ ["email_attachment_list", "settings.read"],
94
+ ["email_download", "settings.read"],
95
+ ["email_list", "settings.read"],
96
+ ["email_status", "settings.read"],
97
+ ["platform_callback_routes_list", "settings.read"],
98
+ ["platform_status", "settings.read"],
99
+ ["sequence_get", "settings.read"],
100
+ ["sequence_guardrails_show", "settings.read"],
101
+ ["sequence_list", "settings.read"],
102
+ ["sequence_stats", "settings.read"],
103
+
104
+ // Writes — settings.write
105
+ ["backup_destinations_add", "settings.write"],
106
+ ["backup_destinations_remove", "settings.write"],
107
+ ["backup_destinations_set_encrypt", "settings.write"],
108
+ ["backup_disable", "settings.write"],
109
+ ["backup_enable", "settings.write"],
110
+ ["backups_create", "settings.write"],
111
+ ["backups_restore", "settings.write"],
112
+ ["config_set", "settings.write"],
113
+ ["createSchedule", "settings.write"],
114
+ ["credentials_delete", "settings.write"],
115
+ ["credentials_set", "settings.write"],
116
+ ["debug_bash", "settings.write"],
117
+ ["domain_register", "settings.write"],
118
+ ["email_register", "settings.write"],
119
+ ["email_send", "settings.write"],
120
+ ["email_unregister", "settings.write"],
121
+ ["platform_callback_routes_register", "settings.write"],
122
+ ["platform_connect", "settings.write"],
123
+ ["platform_disconnect", "settings.write"],
124
+ ["sequence_cancel_enrollment", "settings.write"],
125
+ ["sequence_guardrails_set", "settings.write"],
126
+ ["sequence_pause", "settings.write"],
127
+ ["sequence_resume", "settings.write"],
128
+
129
+ // Conversation CLI — daemon elevates from chat.* to settings.*
130
+ // because `conversations/cli/clear` wipes every conversation +
131
+ // message + vector collection. IPC mirrors that elevation.
132
+ ["conversation_export_cli", "settings.read"],
133
+ ["conversation_list_cli", "settings.read"],
134
+ ["conversation_create_cli", "settings.write"],
135
+ ["conversations_clear_cli", "settings.write"],
136
+
137
+ // Credentials — every credential route uses policyKey: "secrets",
138
+ // which resolves to settings.write on POST and settings.read on GET.
139
+ ["credentials_inspect", "settings.write"],
140
+ ["credentials_list", "settings.write"],
141
+ ["credentials_reveal", "settings.write"],
142
+
143
+ // STT / TTS CLI — mirror daemon HTTP scopes
144
+ ["stt_transcribe_file", "chat.write"],
145
+ ["tts_synthesize_cli", "chat.read"],
146
+ ] as const)("%s requires %s", (operationId, expectedScope) => {
147
+ const policy = getIpcRoutePolicy(operationId);
148
+ expect(policy).toBeDefined();
149
+ expect(policy!.requiredScopes).toEqual([expectedScope]);
150
+ });
151
+
152
+ // Principal restriction — `stt_transcribe_file` reads arbitrary host
153
+ // filesystem paths. The daemon HTTP policy locks it to ["local"] for
154
+ // that reason; the IPC entry must mirror that boundary.
155
+ test("stt_transcribe_file is restricted to local principal", () => {
156
+ const policy = getIpcRoutePolicy("stt_transcribe_file");
157
+ expect(policy).toBeDefined();
158
+ expect(policy!.allowedPrincipalTypes).toEqual(["local"]);
159
+ });
160
+ });
@@ -0,0 +1,115 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import {
3
+ existsSync,
4
+ mkdtempSync,
5
+ readdirSync,
6
+ rmSync,
7
+ writeFileSync,
8
+ } from "node:fs";
9
+ import { tmpdir } from "node:os";
10
+ import { join } from "node:path";
11
+
12
+ import {
13
+ initLogger,
14
+ pruneOldLogFiles,
15
+ getLogger,
16
+ } from "../logger.js";
17
+
18
+ // NOTE: `sleep-wake-detector.test.ts` installs a process-global
19
+ // `mock.module("../logger.js", …)` with a no-op `initLogger`. That would
20
+ // break the `initLogger prunes once at startup` case below if both files
21
+ // ran in the same Bun process. They don't — `gateway/scripts/test.sh` runs
22
+ // each test file in its own `bun test` invocation precisely to dodge this
23
+ // class of mock cross-talk. If you ever invoke `bun test` directly across
24
+ // multiple files, expect order-dependent flakes and use the canonical
25
+ // runner (`bun run test`) instead.
26
+
27
+ /**
28
+ * Helper that fabricates a log file dated `daysAgo` calendar days ago, in
29
+ * either pretty `.log` or sidecar `.jsonl` form. Used to assert that the
30
+ * retention sweep prunes both formats once `retentionDays` has elapsed.
31
+ */
32
+ function makeLogFile(dir: string, daysAgo: number, ext: "log" | "jsonl"): string {
33
+ const d = new Date();
34
+ d.setUTCDate(d.getUTCDate() - daysAgo);
35
+ const stamp = `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")}-${String(d.getUTCDate()).padStart(2, "0")}`;
36
+ const name = `gateway-${stamp}.${ext}`;
37
+ writeFileSync(join(dir, name), `placeholder for ${stamp}\n`);
38
+ return name;
39
+ }
40
+
41
+ describe("gateway log retention", () => {
42
+ let tmp: string;
43
+
44
+ beforeEach(() => {
45
+ tmp = mkdtempSync(join(tmpdir(), "gw-retention-"));
46
+ });
47
+
48
+ afterEach(() => {
49
+ rmSync(tmp, { recursive: true, force: true });
50
+ });
51
+
52
+ test("pruneOldLogFiles removes both .log and .jsonl past retention", () => {
53
+ const young = makeLogFile(tmp, 1, "log");
54
+ const youngJsonl = makeLogFile(tmp, 1, "jsonl");
55
+ const old = makeLogFile(tmp, 10, "log");
56
+ const oldJsonl = makeLogFile(tmp, 10, "jsonl");
57
+
58
+ const removed = pruneOldLogFiles(tmp, 7);
59
+
60
+ expect(removed).toBe(2);
61
+ const remaining = readdirSync(tmp).sort();
62
+ expect(remaining).toEqual([young, youngJsonl].sort());
63
+ expect(existsSync(join(tmp, old))).toBe(false);
64
+ expect(existsSync(join(tmp, oldJsonl))).toBe(false);
65
+ });
66
+
67
+ test("pruneOldLogFiles ignores unrelated files in the log dir", () => {
68
+ makeLogFile(tmp, 10, "log");
69
+ writeFileSync(join(tmp, "something-else.log"), "not a gateway log\n");
70
+ writeFileSync(join(tmp, "README"), "noise\n");
71
+
72
+ pruneOldLogFiles(tmp, 7);
73
+
74
+ expect(existsSync(join(tmp, "something-else.log"))).toBe(true);
75
+ expect(existsSync(join(tmp, "README"))).toBe(true);
76
+ });
77
+
78
+ test("initLogger prunes once at startup", () => {
79
+ const old = makeLogFile(tmp, 10, "log");
80
+ const oldJsonl = makeLogFile(tmp, 10, "jsonl");
81
+
82
+ initLogger({ dir: tmp, retentionDays: 7 });
83
+
84
+ expect(existsSync(join(tmp, old))).toBe(false);
85
+ expect(existsSync(join(tmp, oldJsonl))).toBe(false);
86
+ // Detach from this temp dir before the test teardown rmSyncs it.
87
+ initLogger({ dir: undefined, retentionDays: 0 });
88
+ });
89
+
90
+ test("retentionDays=0 disables pruning", () => {
91
+ const old = makeLogFile(tmp, 365, "log");
92
+
93
+ // Exercises the guard directly; doesn't depend on `initLogger`'s
94
+ // module-level state (or any mocks thereof).
95
+ const removed = pruneOldLogFiles(tmp, 0);
96
+
97
+ expect(removed).toBe(0);
98
+ expect(existsSync(join(tmp, old))).toBe(true);
99
+ });
100
+
101
+ test("retentionDays<0 also disables pruning (defense-in-depth)", () => {
102
+ const old = makeLogFile(tmp, 365, "log");
103
+ expect(pruneOldLogFiles(tmp, -7)).toBe(0);
104
+ expect(existsSync(join(tmp, old))).toBe(true);
105
+ });
106
+
107
+ test("getLogger() works against a configured dir without throwing", () => {
108
+ initLogger({ dir: tmp, retentionDays: 7 });
109
+ const log = getLogger("retention-test");
110
+ // Smoke: just verify the proxy yields a callable .info — full file
111
+ // emission is covered by the smoke test in logger.ts's own usage.
112
+ expect(typeof log.info).toBe("function");
113
+ initLogger({ dir: undefined, retentionDays: 0 });
114
+ });
115
+ });
@@ -24,6 +24,7 @@ const dummyFileContext: FileClassificationContext = {
24
24
  protectedDir: "/tmp/test-protected",
25
25
  deprecatedDir: "/tmp/test-deprecated",
26
26
  hooksDir: "/tmp/test-hooks",
27
+ pluginsDir: "/tmp/test-plugins",
27
28
  skillSourceDirs: [],
28
29
  };
29
30
 
@@ -15,8 +15,11 @@ mock.module("../fetch.js", () => ({
15
15
 
16
16
  const {
17
17
  normalizeSlackAppMention,
18
+ resolveSlackChannel,
18
19
  resolveSlackUser,
20
+ clearChannelInfoCache,
19
21
  clearUserInfoCache,
22
+ getChannelInfoCacheSize,
20
23
  getUserInfoCacheSize,
21
24
  } = await import("../slack/normalize.js");
22
25
  import type { SlackAppMentionEvent } from "../slack/normalize.js";
@@ -63,6 +66,7 @@ function makeEvent(
63
66
 
64
67
  beforeEach(() => {
65
68
  clearUserInfoCache();
69
+ clearChannelInfoCache();
66
70
  });
67
71
 
68
72
  describe("resolveSlackUser", () => {
@@ -149,6 +153,72 @@ describe("resolveSlackUser", () => {
149
153
  });
150
154
  });
151
155
 
156
+ describe("resolveSlackChannel", () => {
157
+ test("resolves channel name from conversations.info", async () => {
158
+ fetchMock = mock(async () => {
159
+ return new Response(
160
+ JSON.stringify({
161
+ ok: true,
162
+ channel: { id: "C123", name: "user-feedback" },
163
+ }),
164
+ { status: 200, headers: { "content-type": "application/json" } },
165
+ );
166
+ });
167
+
168
+ const info = await resolveSlackChannel("C123", "xoxb-token");
169
+ expect(info).not.toBeUndefined();
170
+ expect(info!.name).toBe("user-feedback");
171
+ });
172
+
173
+ test("uses name_normalized when name is absent", async () => {
174
+ fetchMock = mock(async () => {
175
+ return new Response(
176
+ JSON.stringify({
177
+ ok: true,
178
+ channel: { id: "C123", name_normalized: "normalized-channel" },
179
+ }),
180
+ { status: 200, headers: { "content-type": "application/json" } },
181
+ );
182
+ });
183
+
184
+ const info = await resolveSlackChannel("C123", "xoxb-token");
185
+ expect(info!.name).toBe("normalized-channel");
186
+ });
187
+
188
+ test("returns undefined on API failure", async () => {
189
+ fetchMock = mock(async () => {
190
+ return new Response(
191
+ JSON.stringify({ ok: false, error: "channel_not_found" }),
192
+ { status: 200, headers: { "content-type": "application/json" } },
193
+ );
194
+ });
195
+
196
+ const info = await resolveSlackChannel("C_UNKNOWN", "xoxb-token");
197
+ expect(info).toBeUndefined();
198
+ });
199
+
200
+ test("caches channel names to avoid repeated API calls", async () => {
201
+ let callCount = 0;
202
+ fetchMock = mock(async () => {
203
+ callCount++;
204
+ return new Response(
205
+ JSON.stringify({
206
+ ok: true,
207
+ channel: { id: "C_CACHED", name: "cached-channel" },
208
+ }),
209
+ { status: 200, headers: { "content-type": "application/json" } },
210
+ );
211
+ });
212
+
213
+ await resolveSlackChannel("C_CACHED", "xoxb-token");
214
+ await resolveSlackChannel("C_CACHED", "xoxb-token");
215
+ await resolveSlackChannel("C_CACHED", "xoxb-token");
216
+
217
+ expect(callCount).toBe(1);
218
+ expect(getChannelInfoCacheSize()).toBe(1);
219
+ });
220
+ });
221
+
152
222
  describe("normalizeSlackAppMention with display name", () => {
153
223
  test("omits displayName on first call (cache miss), populates on second after cache warm", async () => {
154
224
  fetchMock = mock(async () => {
@@ -5,15 +5,16 @@ import { inspectSlackScopes } from "../slack/socket-mode.js";
5
5
  describe("inspectSlackScopes", () => {
6
6
  test("flags files:read when missing", () => {
7
7
  const result = inspectSlackScopes(
8
- "app_mentions:read,channels:history,im:history,groups:history,mpim:history",
8
+ "app_mentions:read,channels:history,im:history,groups:history,mpim:history,channels:read,im:read,groups:read,mpim:read",
9
9
  );
10
10
  expect(result.filesReadMissing).toBe(true);
11
11
  expect(result.missingHistoryScopes).toEqual([]);
12
+ expect(result.missingConversationInfoScopes).toEqual([]);
12
13
  });
13
14
 
14
15
  test("returns exactly the missing *:history scopes", () => {
15
16
  const result = inspectSlackScopes(
16
- "app_mentions:read,files:read,channels:history",
17
+ "app_mentions:read,files:read,channels:history,channels:read,im:read,groups:read,mpim:read",
17
18
  );
18
19
  expect(result.filesReadMissing).toBe(false);
19
20
  expect(result.missingHistoryScopes.sort()).toEqual([
@@ -21,14 +22,29 @@ describe("inspectSlackScopes", () => {
21
22
  "im:history",
22
23
  "mpim:history",
23
24
  ]);
25
+ expect(result.missingConversationInfoScopes).toEqual([]);
26
+ });
27
+
28
+ test("returns exactly the missing *:read scopes for conversations.info", () => {
29
+ const result = inspectSlackScopes(
30
+ "app_mentions:read,files:read,channels:history,im:history,groups:history,mpim:history,channels:read",
31
+ );
32
+ expect(result.filesReadMissing).toBe(false);
33
+ expect(result.missingHistoryScopes).toEqual([]);
34
+ expect(result.missingConversationInfoScopes.sort()).toEqual([
35
+ "groups:read",
36
+ "im:read",
37
+ "mpim:read",
38
+ ]);
24
39
  });
25
40
 
26
41
  test("returns no missing scopes when all required are present", () => {
27
42
  const result = inspectSlackScopes(
28
- "app_mentions:read,files:read,channels:history,im:history,groups:history,mpim:history",
43
+ "app_mentions:read,files:read,channels:history,im:history,groups:history,mpim:history,channels:read,im:read,groups:read,mpim:read",
29
44
  );
30
45
  expect(result.filesReadMissing).toBe(false);
31
46
  expect(result.missingHistoryScopes).toEqual([]);
47
+ expect(result.missingConversationInfoScopes).toEqual([]);
32
48
  });
33
49
 
34
50
  test("treats an empty scope header as everything missing", () => {
@@ -40,13 +56,20 @@ describe("inspectSlackScopes", () => {
40
56
  "im:history",
41
57
  "mpim:history",
42
58
  ]);
59
+ expect(result.missingConversationInfoScopes.sort()).toEqual([
60
+ "channels:read",
61
+ "groups:read",
62
+ "im:read",
63
+ "mpim:read",
64
+ ]);
43
65
  });
44
66
 
45
67
  test("ignores whitespace and empty entries in the header", () => {
46
68
  const result = inspectSlackScopes(
47
- " files:read , channels:history,, im:history ,groups:history,mpim:history",
69
+ " files:read , channels:history,, im:history ,groups:history,mpim:history, channels:read, im:read, groups:read, mpim:read",
48
70
  );
49
71
  expect(result.filesReadMissing).toBe(false);
50
72
  expect(result.missingHistoryScopes).toEqual([]);
73
+ expect(result.missingConversationInfoScopes).toEqual([]);
51
74
  });
52
75
  });
@@ -33,7 +33,7 @@ mock.module("../fetch.js", () => ({
33
33
  }));
34
34
 
35
35
  const { SlackSocketModeClient } = await import("../slack/socket-mode.js");
36
- const { clearUserInfoCache, resolveSlackUser } =
36
+ const { clearChannelInfoCache, clearUserInfoCache, resolveSlackUser } =
37
37
  await import("../slack/normalize.js");
38
38
  import type { SlackSocketModeConfig } from "../slack/socket-mode.js";
39
39
 
@@ -150,6 +150,7 @@ function flushAsyncEventEmission(): Promise<void> {
150
150
 
151
151
  beforeEach(() => {
152
152
  clearUserInfoCache();
153
+ clearChannelInfoCache();
153
154
  fetchMock = mock(async () => makeSlackUserResponse());
154
155
  });
155
156
 
@@ -653,4 +654,107 @@ describe("SlackSocketModeClient thread tracking", () => {
653
654
  rawDb.close();
654
655
  }
655
656
  });
657
+
658
+ test("renders live app mention channel refs as channel-name labels", async () => {
659
+ const { rawDb, store } = createSlackStore();
660
+ const emitted: NormalizedSlackEvent[] = [];
661
+ const client = createHarness(store, (event) => emitted.push(event));
662
+ const ws = makeOpenSocket();
663
+
664
+ fetchMock = mock(async (input) => {
665
+ const url = new URL(String(input));
666
+ if (url.pathname.endsWith("/conversations.info")) {
667
+ expect(url.searchParams.get("channel")).toBe("CFEEDBACK");
668
+ return new Response(
669
+ JSON.stringify({
670
+ ok: true,
671
+ channel: { id: "CFEEDBACK", name: "user-feedback" },
672
+ }),
673
+ { status: 200, headers: { "content-type": "application/json" } },
674
+ );
675
+ }
676
+ return makeSlackUserResponse();
677
+ });
678
+
679
+ try {
680
+ client.handleMessage(
681
+ JSON.stringify({
682
+ envelope_id: "env-channel-label",
683
+ type: "events_api",
684
+ payload: {
685
+ event_id: "Ev-channel-label",
686
+ event: {
687
+ type: "app_mention",
688
+ user: "U-actor",
689
+ text: "<@UBOT> continue in <#CFEEDBACK>",
690
+ ts: "1700000000.000900",
691
+ channel: "C-thread",
692
+ },
693
+ },
694
+ }),
695
+ ws,
696
+ );
697
+ await flushAsyncEventEmission();
698
+
699
+ expect(emitted).toHaveLength(1);
700
+ expect(emitted[0].event.message.content).toBe(
701
+ "@Example User continue in #user-feedback",
702
+ );
703
+ expect(emitted[0].event.message.content).not.toContain("CFEEDBACK");
704
+ } finally {
705
+ rawDb.close();
706
+ }
707
+ });
708
+
709
+ test("keeps embedded Slack channel labels without conversations.info lookup", async () => {
710
+ const { rawDb, store } = createSlackStore();
711
+ const emitted: NormalizedSlackEvent[] = [];
712
+ const client = createHarness(store, (event) => emitted.push(event));
713
+ const ws = makeOpenSocket();
714
+ let conversationInfoCalls = 0;
715
+
716
+ fetchMock = mock(async (input) => {
717
+ const url = new URL(String(input));
718
+ if (url.pathname.endsWith("/conversations.info")) {
719
+ conversationInfoCalls++;
720
+ return new Response(
721
+ JSON.stringify({
722
+ ok: true,
723
+ channel: { id: "CFEEDBACK", name: "private-name" },
724
+ }),
725
+ { status: 200, headers: { "content-type": "application/json" } },
726
+ );
727
+ }
728
+ return makeSlackUserResponse();
729
+ });
730
+
731
+ try {
732
+ client.handleMessage(
733
+ JSON.stringify({
734
+ envelope_id: "env-channel-embedded-label",
735
+ type: "events_api",
736
+ payload: {
737
+ event_id: "Ev-channel-embedded-label",
738
+ event: {
739
+ type: "app_mention",
740
+ user: "U-actor",
741
+ text: "<@UBOT> continue in <#CFEEDBACK|visible-name>",
742
+ ts: "1700000000.001000",
743
+ channel: "C-thread",
744
+ },
745
+ },
746
+ }),
747
+ ws,
748
+ );
749
+ await flushAsyncEventEmission();
750
+
751
+ expect(emitted).toHaveLength(1);
752
+ expect(emitted[0].event.message.content).toBe(
753
+ "@Example User continue in #visible-name",
754
+ );
755
+ expect(conversationInfoCalls).toBe(0);
756
+ } finally {
757
+ rawDb.close();
758
+ }
759
+ });
656
760
  });
@@ -87,6 +87,55 @@ describe("create()", () => {
87
87
  expect(rule.createdAt <= after).toBe(true);
88
88
  expect(rule.updatedAt).toBe(rule.createdAt);
89
89
  });
90
+
91
+ test("upserts when a rule with the same tool+pattern already exists", () => {
92
+ const original = store.create({
93
+ tool: "bash",
94
+ pattern: "create-upsert-echo",
95
+ risk: "low",
96
+ description: "Original description",
97
+ });
98
+
99
+ const updated = store.create({
100
+ tool: "bash",
101
+ pattern: "create-upsert-echo",
102
+ risk: "high",
103
+ description: "Updated description",
104
+ });
105
+
106
+ expect(updated.id).toBe(original.id);
107
+ expect(updated.risk).toBe("high");
108
+ expect(updated.description).toBe("Updated description");
109
+ expect(updated.origin).toBe("user_defined");
110
+ expect(updated.createdAt).toBe(original.createdAt);
111
+ expect(updated.updatedAt >= original.updatedAt).toBe(true);
112
+ });
113
+
114
+ test("upsert un-deletes a soft-deleted rule with the same tool+pattern", () => {
115
+ store.upsertDefault({
116
+ id: "default:bash:create-upsert-undelete",
117
+ tool: "bash",
118
+ pattern: "create-upsert-undelete",
119
+ risk: "low",
120
+ description: "Default rule",
121
+ });
122
+ store.remove("default:bash:create-upsert-undelete");
123
+
124
+ const deleted = store.getById("default:bash:create-upsert-undelete")!;
125
+ expect(deleted.deleted).toBe(true);
126
+
127
+ const recreated = store.create({
128
+ tool: "bash",
129
+ pattern: "create-upsert-undelete",
130
+ risk: "medium",
131
+ description: "Re-created by user",
132
+ });
133
+
134
+ expect(recreated.deleted).toBe(false);
135
+ expect(recreated.risk).toBe("medium");
136
+ expect(recreated.description).toBe("Re-created by user");
137
+ expect(recreated.origin).toBe("user_defined");
138
+ });
90
139
  });
91
140
 
92
141
  // ---------------------------------------------------------------------------
@@ -3,7 +3,10 @@
3
3
  * revoked or blocked channels.
4
4
  */
5
5
 
6
- import { describe, test, expect, beforeEach, mock } from "bun:test";
6
+ import { describe, test, expect, beforeEach, afterEach, mock } from "bun:test";
7
+ import { rmSync, writeFileSync } from "node:fs";
8
+ import { tmpdir } from "node:os";
9
+ import { join } from "node:path";
7
10
 
8
11
  import "./test-preload.js";
9
12
 
@@ -18,10 +21,18 @@ type ExistingRow = {
18
21
  };
19
22
 
20
23
  let queryRows: ExistingRow[] = [];
24
+ const queryCalls: { sql: string; params: unknown[] }[] = [];
21
25
  const runCalls: { sql: string; params: unknown[] }[] = [];
26
+ const TEST_SOCKET_PATH = join(
27
+ tmpdir(),
28
+ `vellum-upsert-contact-channel-test-${process.pid}.sock`,
29
+ );
22
30
 
23
31
  mock.module("../db/assistant-db-proxy.js", () => ({
24
- assistantDbQuery: async (_sql: string, _params: unknown[]) => queryRows,
32
+ assistantDbQuery: async (sql: string, params: unknown[]) => {
33
+ queryCalls.push({ sql, params });
34
+ return queryRows;
35
+ },
25
36
  assistantDbRun: async (sql: string, params: unknown[]) => {
26
37
  runCalls.push({ sql, params });
27
38
  },
@@ -50,17 +61,22 @@ mock.module("../verification/identity.js", () => ({
50
61
  }));
51
62
 
52
63
  mock.module("../ipc/socket-path.js", () => ({
53
- resolveIpcSocketPath: () => ({ path: "/tmp/test.sock" }),
64
+ resolveIpcSocketPath: () => ({ path: TEST_SOCKET_PATH }),
54
65
  }));
55
66
 
56
67
  // Import after mocks
57
- const { upsertVerifiedContactChannel } = await import(
58
- "../verification/contact-helpers.js"
59
- );
68
+ const { upsertContactChannel, upsertVerifiedContactChannel } =
69
+ await import("../verification/contact-helpers.js");
60
70
 
61
71
  beforeEach(() => {
62
72
  queryRows = [];
73
+ queryCalls.length = 0;
63
74
  runCalls.length = 0;
75
+ writeFileSync(TEST_SOCKET_PATH, "");
76
+ });
77
+
78
+ afterEach(() => {
79
+ rmSync(TEST_SOCKET_PATH, { force: true });
64
80
  });
65
81
 
66
82
  // ---------------------------------------------------------------------------
@@ -171,3 +187,30 @@ describe("upsertVerifiedContactChannel — revoked/blocked guards", () => {
171
187
  expect(inserts).toHaveLength(2);
172
188
  });
173
189
  });
190
+
191
+ describe("upsertContactChannel — channel address casing", () => {
192
+ test("preserves Slack actor ID casing when seeding an inbound contact channel", async () => {
193
+ queryRows = [];
194
+
195
+ await upsertContactChannel({
196
+ sourceChannel: "slack",
197
+ externalUserId: "U123EXAMPLE",
198
+ externalChatId: "D123EXAMPLE",
199
+ });
200
+
201
+ const channelInsert = runCalls.find((c) =>
202
+ c.sql.includes("INSERT OR IGNORE INTO contact_channels"),
203
+ );
204
+ expect(channelInsert).toBeTruthy();
205
+ expect(channelInsert!.params[3]).toBe("U123EXAMPLE");
206
+ expect(channelInsert!.params[4]).toBe("U123EXAMPLE");
207
+
208
+ expect(queryCalls[0]!.sql).toContain("CASE WHEN cc.address = ?");
209
+ expect(queryCalls[0]!.params).toEqual([
210
+ "slack",
211
+ "U123EXAMPLE",
212
+ "U123EXAMPLE",
213
+ "U123EXAMPLE",
214
+ ]);
215
+ });
216
+ });