@vellumai/vellum-gateway 0.10.3 → 0.10.4-staging.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/vellum-gateway",
3
- "version": "0.10.3",
3
+ "version": "0.10.4-staging.1",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -0,0 +1,217 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import { readFileSync, readdirSync, statSync } from "node:fs";
3
+ import { join } from "node:path";
4
+
5
+ /**
6
+ * DROP-safety guard: the gateway no longer WRITES the 8 ACL columns into the
7
+ * assistant DB (the gateway DB is the source of truth). This source scan fails
8
+ * if any assistantDbRun(/assistantDbExec( call site writes one of those columns,
9
+ * so the assistant-side DROP-COLUMN migration (302) stays safe.
10
+ *
11
+ * 8 ACL columns (gateway-owned, assistant-mirror dropped):
12
+ * contacts.role, contacts.principal_id
13
+ * contact_channels.{status, policy, verified_at, verified_via,
14
+ * revoked_reason, blocked_reason}
15
+ *
16
+ * RESIDUAL ASSISTANT-DB ACL READS still pending before the DROP:
17
+ * - gateway/src/db/data-migrations/m0006-*: one-time reconcile reads assistant
18
+ * ACL columns to seed the gateway DB.
19
+ * - gateway/src/db/data-migrations/m0008-*: one-time backfill reads assistant
20
+ * ACL columns.
21
+ * These append-only one-time migrations are the ONLY remaining assistant-DB
22
+ * ACL reads; they are retired at the assistant DROP-COLUMN migration (302).
23
+ * The heal/seed reads (the channel-mirror heal and the inbound contact-seed
24
+ * blocked-check) are drained — they read identity/info only and resolve ACL
25
+ * from the gateway DB. The WRITE side is clean — this guard asserts that.
26
+ */
27
+
28
+ // Deliberate, greppable exceptions. Must stay empty: every entry is an
29
+ // assistant-DB ACL write that the DROP migration would break.
30
+ const ACL_WRITE_ALLOWLIST: string[] = [];
31
+
32
+ const ACL_COLUMNS = [
33
+ "role",
34
+ "principal_id",
35
+ "status",
36
+ "policy",
37
+ "verified_at",
38
+ "verified_via",
39
+ "revoked_reason",
40
+ "blocked_reason",
41
+ ] as const;
42
+
43
+ // Info columns are assistant-owned and intentionally NOT flagged.
44
+ const INFO_COLUMNS = ["last_seen_at", "interaction_count", "last_interaction"];
45
+
46
+ const GATEWAY_SRC = join(import.meta.dirname!, "..");
47
+
48
+ const EXCLUDED_DIRS = new Set(["__tests__", "data-migrations"]);
49
+ const EXCLUDED_FILES = new Set(["assistant-db-proxy.ts"]);
50
+
51
+ function collectSourceFiles(dir: string): string[] {
52
+ const out: string[] = [];
53
+ for (const entry of readdirSync(dir)) {
54
+ const full = join(dir, entry);
55
+ const st = statSync(full);
56
+ if (st.isDirectory()) {
57
+ if (EXCLUDED_DIRS.has(entry)) continue;
58
+ out.push(...collectSourceFiles(full));
59
+ continue;
60
+ }
61
+ if (!entry.endsWith(".ts")) continue;
62
+ if (entry.endsWith(".test.ts")) continue;
63
+ if (EXCLUDED_FILES.has(entry)) continue;
64
+ out.push(full);
65
+ }
66
+ return out;
67
+ }
68
+
69
+ /**
70
+ * Extract the SQL string literal argument of an assistantDbRun/assistantDbExec
71
+ * call starting at `callStart`. Handles template literals and quoted strings
72
+ * spanning multiple lines. Returns the literal text (without delimiters) and
73
+ * the index just past it, or null if no string literal follows the open paren.
74
+ */
75
+ function extractSqlArg(
76
+ src: string,
77
+ callStart: number,
78
+ ): { sql: string; end: number } | null {
79
+ const open = src.indexOf("(", callStart);
80
+ if (open === -1) return null;
81
+ let i = open + 1;
82
+ // Skip whitespace to the first argument.
83
+ while (i < src.length && /\s/.test(src[i])) i++;
84
+ const quote = src[i];
85
+ if (quote !== "`" && quote !== '"' && quote !== "'") return null;
86
+ const start = i + 1;
87
+ i = start;
88
+ while (i < src.length) {
89
+ if (src[i] === "\\") {
90
+ i += 2;
91
+ continue;
92
+ }
93
+ if (src[i] === quote) {
94
+ return { sql: src.slice(start, i), end: i + 1 };
95
+ }
96
+ i++;
97
+ }
98
+ return null;
99
+ }
100
+
101
+ // Only writes to these tables carry the dropped ACL columns. status/policy
102
+ // etc. on other tables (channel_verification_sessions, *_ingress_invites) are
103
+ // unrelated and must not be flagged.
104
+ const ACL_TABLES = ["contacts", "contact_channels"];
105
+
106
+ /** True if `sql` writes one of the 8 ACL columns into contacts/contact_channels. */
107
+ function sqlWritesAclColumn(sql: string): boolean {
108
+ const normalized = sql.replace(/\s+/g, " ").toLowerCase();
109
+
110
+ // INSERT INTO <table> (col, col, ...) — an ACL column named for an ACL table.
111
+ const insertMatch = normalized.match(
112
+ /insert(?:\s+or\s+\w+)?\s+into\s+([a-z_]+)\s*\(([^)]*)\)/,
113
+ );
114
+ if (insertMatch && ACL_TABLES.includes(insertMatch[1])) {
115
+ const cols = insertMatch[2].split(",").map((c) => c.trim());
116
+ if (cols.some((c) => (ACL_COLUMNS as readonly string[]).includes(c))) {
117
+ return true;
118
+ }
119
+ }
120
+
121
+ // UPDATE <table> SET <acl_col> = — an ACL column assigned on an ACL table.
122
+ // Scope to the SET clause (up to WHERE) so a WHERE-clause ACL column isn't
123
+ // mistaken for an assignment.
124
+ const updateMatch = normalized.match(
125
+ /update\s+([a-z_]+)\s+set\s+(.*?)(?:\swhere\s|$)/,
126
+ );
127
+ if (updateMatch && ACL_TABLES.includes(updateMatch[1])) {
128
+ const setClause = updateMatch[2];
129
+ for (const col of ACL_COLUMNS) {
130
+ // Require an assignment to avoid flagging references, and word-boundary
131
+ // so principal_id doesn't match as a substring.
132
+ const re = new RegExp(`\\b${col}\\s*=`, "i");
133
+ if (re.test(setClause)) return true;
134
+ }
135
+ }
136
+
137
+ return false;
138
+ }
139
+
140
+ describe("assistant-DB ACL write drain guard", () => {
141
+ test("no assistantDbRun/assistantDbExec call writes a dropped ACL column", () => {
142
+ const files = collectSourceFiles(GATEWAY_SRC);
143
+ const violations: string[] = [];
144
+
145
+ const callRe = /assistantDb(?:Run|Exec)\s*\(/g;
146
+
147
+ for (const file of files) {
148
+ const src = readFileSync(file, "utf-8");
149
+ let m: RegExpExecArray | null;
150
+ callRe.lastIndex = 0;
151
+ while ((m = callRe.exec(src)) !== null) {
152
+ const arg = extractSqlArg(src, m.index);
153
+ if (!arg) continue;
154
+ if (sqlWritesAclColumn(arg.sql)) {
155
+ const rel = file.slice(GATEWAY_SRC.length + 1);
156
+ if (ACL_WRITE_ALLOWLIST.includes(rel)) continue;
157
+ violations.push(`${rel}: ${arg.sql.replace(/\s+/g, " ").trim()}`);
158
+ }
159
+ }
160
+ }
161
+
162
+ expect(violations).toEqual([]);
163
+ });
164
+
165
+ test("matcher flags ACL writes but not info-column or read SQL", () => {
166
+ // ACL writes (must be flagged)
167
+ expect(
168
+ sqlWritesAclColumn("UPDATE contacts SET role = ? WHERE id = ?"),
169
+ ).toBe(true);
170
+ expect(
171
+ sqlWritesAclColumn(
172
+ "UPDATE contact_channels SET status = ?, policy = ? WHERE id = ?",
173
+ ),
174
+ ).toBe(true);
175
+ expect(
176
+ sqlWritesAclColumn(
177
+ "INSERT INTO contacts (id, role, principal_id) VALUES (?, ?, ?)",
178
+ ),
179
+ ).toBe(true);
180
+
181
+ // Info-column writes (must NOT be flagged)
182
+ expect(
183
+ sqlWritesAclColumn(
184
+ "UPDATE contact_channels SET last_seen_at = ?, interaction_count = ? WHERE id = ?",
185
+ ),
186
+ ).toBe(false);
187
+ expect(
188
+ sqlWritesAclColumn(
189
+ "INSERT INTO contact_channels (id, last_seen_at, interaction_count, last_interaction) VALUES (?, ?, ?, ?)",
190
+ ),
191
+ ).toBe(false);
192
+
193
+ // Identity/info-only writes (must NOT be flagged)
194
+ expect(
195
+ sqlWritesAclColumn(
196
+ "UPDATE contacts SET display_name = ?, updated_at = ? WHERE id = ?",
197
+ ),
198
+ ).toBe(false);
199
+
200
+ // Reads referencing ACL columns (must NOT be flagged)
201
+ expect(
202
+ sqlWritesAclColumn(
203
+ "SELECT cc.status FROM contact_channels cc WHERE cc.status = 'active'",
204
+ ),
205
+ ).toBe(false);
206
+ });
207
+
208
+ test("allowlist is empty (no sanctioned ACL writes remain)", () => {
209
+ expect(ACL_WRITE_ALLOWLIST).toEqual([]);
210
+ // Guard that INFO_COLUMNS and ACL_COLUMNS stay disjoint.
211
+ expect(
212
+ INFO_COLUMNS.filter((c) =>
213
+ (ACL_COLUMNS as readonly string[]).includes(c),
214
+ ),
215
+ ).toEqual([]);
216
+ });
217
+ });
@@ -1,9 +1,8 @@
1
1
  /**
2
- * Tests for the guardian binding-helper lookups, which read the gateway DB
2
+ * Tests for the guardian binding helpers, which read and write the gateway DB
3
3
  * (source of truth for ACL). The gateway DB is a real (file-backed) DB seeded
4
- * per test; the assistant DB proxy is mocked and throws on read so the tests
5
- * prove the lookups never touch the assistant mirror. revokeExistingChannel
6
- * Guardian still writes both the assistant UPDATE and the gateway dual-write.
4
+ * per test; the assistant DB proxy is mocked and throws on every call so the
5
+ * tests prove the helpers never touch the assistant mirror.
7
6
  */
8
7
 
9
8
  import {
@@ -18,59 +17,14 @@ import {
18
17
 
19
18
  import "./test-preload.js";
20
19
 
21
- // Assistant DB proxy: reads throw (lookups must not touch it); the revoke
22
- // UPDATE is captured so we can assert the assistant mirror write still fires.
23
- // A simple in-memory `contact_channels` model lets us prove the id-keyed
24
- // update vs. the (type,address) logical-key fallback under id-divergence.
25
- const assistantRunCalls: { sql: string; bind?: unknown[] }[] = [];
26
-
27
- type AsstChannel = {
28
- id: string;
29
- type: string;
30
- address: string;
31
- status: string;
32
- policy: string;
33
- };
34
- const asstChannels: AsstChannel[] = [];
35
-
36
- function seedAsstChannel(c: AsstChannel): void {
37
- asstChannels.push(c);
38
- }
39
-
40
- let throwOnAsstRun = false;
41
-
20
+ // Assistant DB proxy: every call throws so the tests prove the helpers read
21
+ // and write only the gateway DB.
42
22
  mock.module("../db/assistant-db-proxy.js", () => ({
43
23
  assistantDbQuery: mock(async () => {
44
- throw new Error("assistant DB read not expected in binding lookups");
24
+ throw new Error("assistant DB read not expected in binding helpers");
45
25
  }),
46
- assistantDbRun: mock(async (sql: string, bind?: unknown[]) => {
47
- assistantRunCalls.push({ sql, bind });
48
- if (throwOnAsstRun) throw new Error("assistant DB proxy unavailable");
49
- let changes = 0;
50
- if (/WHERE id = \?/.test(sql)) {
51
- const id = bind?.[1];
52
- for (const ch of asstChannels) {
53
- if (ch.id === id) {
54
- ch.status = "revoked";
55
- ch.policy = "deny";
56
- changes++;
57
- }
58
- }
59
- } else if (/WHERE type = \? AND address = \?/.test(sql)) {
60
- const type = bind?.[1];
61
- const address = bind?.[2];
62
- for (const ch of asstChannels) {
63
- if (
64
- ch.type === type &&
65
- ch.address.toLowerCase() === String(address).toLowerCase()
66
- ) {
67
- ch.status = "revoked";
68
- ch.policy = "deny";
69
- changes++;
70
- }
71
- }
72
- }
73
- return { changes, lastInsertRowid: 0 };
26
+ assistantDbRun: mock(async () => {
27
+ throw new Error("assistant DB write not expected in binding helpers");
74
28
  }),
75
29
  assistantDbExec: mock(async () => undefined),
76
30
  }));
@@ -96,8 +50,6 @@ beforeEach(() => {
96
50
  const db = getGatewayDb();
97
51
  db.delete(contactChannels).run();
98
52
  db.delete(contacts).run();
99
- assistantRunCalls.length = 0;
100
- asstChannels.length = 0;
101
53
  });
102
54
 
103
55
  afterAll(() => {
@@ -248,7 +200,7 @@ describe("resolveCanonicalPrincipal", () => {
248
200
  });
249
201
 
250
202
  describe("revokeExistingChannelGuardian", () => {
251
- test("revokes the gateway channel and mirrors the write to the assistant DB", async () => {
203
+ test("revokes the active guardian channel in the gateway DB only", async () => {
252
204
  seedContact({ id: "g1", role: "guardian" });
253
205
  const chId = seedChannel({
254
206
  contactId: "g1",
@@ -256,18 +208,9 @@ describe("revokeExistingChannelGuardian", () => {
256
208
  address: "U_OWNER",
257
209
  status: "active",
258
210
  });
259
- // Assistant row shares the same id — the id-keyed update matches.
260
- seedAsstChannel({
261
- id: chId,
262
- type: "slack",
263
- address: "U_OWNER",
264
- status: "active",
265
- policy: "allow",
266
- });
267
211
 
268
212
  await revokeExistingChannelGuardian("slack");
269
213
 
270
- // Gateway DB status flipped to revoked.
271
214
  const after = getGatewayDb()
272
215
  .select()
273
216
  .from(contactChannels)
@@ -275,79 +218,48 @@ describe("revokeExistingChannelGuardian", () => {
275
218
  .find((r) => r.id === chId);
276
219
  expect(after?.status).toBe("revoked");
277
220
  expect(after?.policy).toBe("deny");
278
-
279
- // Assistant mirror revoked via the id-keyed update (no fallback needed).
280
- expect(assistantRunCalls.length).toBe(1);
281
- expect(assistantRunCalls[0]!.sql).toContain("WHERE id = ?");
282
- expect(assistantRunCalls[0]!.bind).toContain(chId);
283
- expect(asstChannels[0]!.status).toBe("revoked");
284
- expect(asstChannels[0]!.policy).toBe("deny");
285
221
  });
286
222
 
287
- test("revokes the assistant mirror by (type,address) when ids diverge", async () => {
223
+ test("revokes every active guardian channel for the type", async () => {
288
224
  seedContact({ id: "g1", role: "guardian" });
289
- // Gateway guardian channel under id G.
290
- seedChannel({
291
- id: "G",
225
+ const a = seedChannel({
292
226
  contactId: "g1",
293
227
  type: "slack",
294
- address: "U_OWNER",
228
+ address: "U_A",
295
229
  status: "active",
296
230
  });
297
- // Assistant row shares (type,address) but sits under a DIFFERENT id A.
298
- seedAsstChannel({
299
- id: "A",
231
+ const b = seedChannel({
232
+ contactId: "g1",
300
233
  type: "slack",
301
- address: "U_OWNER",
234
+ address: "U_B",
302
235
  status: "active",
303
- policy: "allow",
304
236
  });
305
237
 
306
238
  await revokeExistingChannelGuardian("slack");
307
239
 
308
- // id-keyed update missed; logical-key fallback revoked row A.
309
- expect(asstChannels[0]!.status).toBe("revoked");
310
- expect(asstChannels[0]!.policy).toBe("deny");
311
- expect(assistantRunCalls.length).toBe(2);
312
- expect(assistantRunCalls[0]!.sql).toContain("WHERE id = ?");
313
- expect(assistantRunCalls[1]!.sql).toContain(
314
- "WHERE type = ? AND address = ? COLLATE NOCASE",
315
- );
316
- });
317
-
318
- test("no-ops (no writes) when no active guardian binding exists", async () => {
319
- seedContact({ id: "g1", role: "guardian" });
320
- seedChannel({ contactId: "g1", type: "slack", status: "revoked" });
321
-
322
- await revokeExistingChannelGuardian("slack");
323
-
324
- expect(assistantRunCalls.length).toBe(0);
240
+ const rows = getGatewayDb().select().from(contactChannels).all();
241
+ for (const id of [a, b]) {
242
+ const row = rows.find((r) => r.id === id);
243
+ expect(row?.status).toBe("revoked");
244
+ expect(row?.policy).toBe("deny");
245
+ }
325
246
  });
326
247
 
327
- test("revokes the gateway row even when the assistant mirror fails", async () => {
248
+ test("no-ops when no active guardian binding exists", async () => {
328
249
  seedContact({ id: "g1", role: "guardian" });
329
250
  const chId = seedChannel({
330
251
  contactId: "g1",
331
252
  type: "slack",
332
- address: "U_OWNER",
333
- status: "active",
253
+ status: "revoked",
334
254
  });
335
255
 
336
- throwOnAsstRun = true;
337
- try {
338
- // Must not throw — the best-effort mirror failure is swallowed.
339
- await revokeExistingChannelGuardian("slack");
340
- } finally {
341
- throwOnAsstRun = false;
342
- }
256
+ await revokeExistingChannelGuardian("slack");
343
257
 
344
- // Gateway (source of truth) is revoked despite the mirror failure.
345
258
  const after = getGatewayDb()
346
259
  .select()
347
260
  .from(contactChannels)
348
261
  .all()
349
262
  .find((r) => r.id === chId);
350
263
  expect(after?.status).toBe("revoked");
351
- expect(after?.policy).toBe("deny");
352
264
  });
353
265
  });
@@ -233,26 +233,23 @@ describe("mark_channel_verified IPC handler", () => {
233
233
  ).rejects.toThrow(/not found/);
234
234
  });
235
235
 
236
- test("inherits assistant-mirror behavior: a gateway-absent channel is mirrored then verified", async () => {
236
+ test("does not verify a channel absent from the gateway DB", async () => {
237
237
  seedAssistantContact("c1");
238
238
  seedAssistantChannel({ id: "ch1", contactId: "c1", status: "unverified" });
239
239
 
240
- const res = (await markChannelVerifiedHandler({
241
- contactChannelId: "ch1",
242
- })) as { ok: boolean; didWrite: boolean; channel: { status: string } };
243
-
244
- expect(res.ok).toBe(true);
245
- expect(res.didWrite).toBe(true);
246
- expect(res.channel.status).toBe("active");
240
+ // The channel lives only in the assistant DB. The gateway DB is the source
241
+ // of truth and does not heal from the assistant, so verification reports
242
+ // not found and nothing lands in the gateway DB.
243
+ await expect(
244
+ markChannelVerifiedHandler({ contactChannelId: "ch1" }),
245
+ ).rejects.toThrow(/not found/);
247
246
 
248
- // Channel + parent contact were materialized into the gateway DB.
249
247
  const channelInGateway = getGatewayDb()
250
248
  .select()
251
249
  .from(contactChannels)
252
250
  .where(eq(contactChannels.id, "ch1"))
253
251
  .get();
254
- expect(channelInGateway).toBeTruthy();
255
- expect(channelInGateway!.contactId).toBe("c1");
252
+ expect(channelInGateway).toBeUndefined();
256
253
  });
257
254
  });
258
255
 
@@ -704,65 +704,7 @@ describe("handleContactPromptSubmit", () => {
704
704
  expect(ipcCall.body.error).toBeUndefined();
705
705
  });
706
706
 
707
- test("guardian bootstrap-create — assistant mirror keeps role='guardian' even if create-mirror INSERT fails", async () => {
708
- // No guardian seeded: handler mints one gateway-first. Make the
709
- // bootstrap-create assistant mirror INSERT (role='guardian') fail, so the
710
- // assistant DB has no guardian row when Phase-2 upsertContact runs. Without
711
- // the role re-assert, that upsert would INSERT the id with role='contact',
712
- // downgrading the guardian in the mirror.
713
- const realDb = testAssistantDb!;
714
- let failNextGuardianInsert = true;
715
- testAssistantDb = {
716
- prepare(sql: string) {
717
- if (
718
- failNextGuardianInsert &&
719
- /INSERT INTO contacts/i.test(sql) &&
720
- /'guardian'/.test(sql)
721
- ) {
722
- failNextGuardianInsert = false;
723
- throw new Error("assistant DB guardian INSERT unavailable");
724
- }
725
- return realDb.prepare(sql);
726
- },
727
- } as unknown as Database;
728
-
729
- let res: Response;
730
- try {
731
- res = await handleContactPromptSubmit(
732
- makeRequest({
733
- requestId: "req-boot-downgrade",
734
- address: "+15552223333",
735
- channelType: "phone",
736
- role: "guardian",
737
- displayName: "Mirror Guardian",
738
- }),
739
- );
740
- } finally {
741
- testAssistantDb = realDb;
742
- }
743
-
744
- expect(res.status).toBe(200);
745
- const body = (await res.json()) as Record<string, unknown>;
746
- expect(body.accepted).toBe(true);
747
-
748
- // Gateway DB: guardian minted with role=guardian (authoritative).
749
- const gwGuardians = getGatewayDb()
750
- .select()
751
- .from(gwContacts)
752
- .where(eq(gwContacts.role, "guardian"))
753
- .all();
754
- expect(gwGuardians).toHaveLength(1);
755
-
756
- // Assistant mirror: the role re-assert healed any Phase-2 downgrade — the
757
- // row exists and is role='guardian', NOT 'contact'.
758
- const asContacts = testAssistantDb!
759
- .prepare(`SELECT role FROM contacts WHERE id = ?`)
760
- .all(gwGuardians[0].id) as { role: string }[];
761
- expect(asContacts).toHaveLength(1);
762
- expect(asContacts[0].role).toBe("guardian");
763
- });
764
-
765
- test("guardian bootstrap-create — assistant mirror row is role='guardian'", async () => {
707
+ test("guardian bootstrap-create — gateway is authoritative; assistant mirror row exists without ACL role", async () => {
766
708
  const res = await handleContactPromptSubmit(
767
709
  makeRequest({
768
710
  requestId: "req-boot-role",
@@ -776,6 +718,7 @@ describe("handleContactPromptSubmit", () => {
776
718
  expect(res.status).toBe(200);
777
719
  expect(((await res.json()) as Record<string, unknown>).accepted).toBe(true);
778
720
 
721
+ // Gateway DB is the source of truth for the guardian ACL role.
779
722
  const gwGuardians = getGatewayDb()
780
723
  .select()
781
724
  .from(gwContacts)
@@ -783,11 +726,13 @@ describe("handleContactPromptSubmit", () => {
783
726
  .all();
784
727
  expect(gwGuardians).toHaveLength(1);
785
728
 
729
+ // Assistant mirror is an identity mirror only — the row exists but no ACL
730
+ // role is written through, so it falls back to the schema default 'contact'.
786
731
  const asContacts = testAssistantDb!
787
732
  .prepare(`SELECT role FROM contacts WHERE id = ?`)
788
733
  .all(gwGuardians[0].id) as { role: string }[];
789
734
  expect(asContacts).toHaveLength(1);
790
- expect(asContacts[0].role).toBe("guardian");
735
+ expect(asContacts[0].role).toBe("contact");
791
736
  });
792
737
 
793
738
  test("non-guardian prompt — 500 + daemon error when channel can't be resolved (no empty channelId)", async () => {