@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 +1 -1
- package/src/__tests__/assistant-acl-write-drain.test.ts +217 -0
- package/src/__tests__/binding-helpers.test.ts +24 -112
- package/src/__tests__/contact-handlers.test.ts +8 -11
- package/src/__tests__/contact-prompt-submit.test.ts +5 -60
- package/src/__tests__/contact-store-mark-channel-verified.test.ts +44 -152
- package/src/__tests__/contacts-control-plane-proxy.test.ts +98 -39
- package/src/__tests__/data-migration-m0008-upsert-acl-columns-from-assistant.test.ts +491 -0
- package/src/__tests__/guardian-bootstrap-lookups.test.ts +25 -75
- package/src/__tests__/guardian-init-lockfile.test.ts +26 -7
- package/src/__tests__/ipc-contact-routes.test.ts +35 -375
- package/src/__tests__/remote-web-pairing-token.test.ts +65 -11
- package/src/__tests__/upsert-verified-contact-channel.test.ts +141 -38
- package/src/auth/guardian-bootstrap.ts +154 -217
- package/src/db/contact-store.ts +76 -741
- package/src/db/data-migrations/index.ts +2 -0
- package/src/db/data-migrations/m0008-upsert-acl-columns-from-assistant.ts +223 -0
- package/src/http/routes/contact-prompt.ts +10 -30
- package/src/http/routes/contacts-control-plane-proxy.ts +13 -33
- package/src/http/routes/twilio-voice-verify-callback.ts +12 -79
- package/src/verification/binding-helpers.ts +6 -38
- package/src/verification/contact-helpers.ts +59 -78
- package/src/verification/outbound-voice-verification-sync.test.ts +19 -13
package/package.json
CHANGED
|
@@ -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
|
|
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
|
|
5
|
-
* prove the
|
|
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:
|
|
22
|
-
//
|
|
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
|
|
24
|
+
throw new Error("assistant DB read not expected in binding helpers");
|
|
45
25
|
}),
|
|
46
|
-
assistantDbRun: mock(async (
|
|
47
|
-
|
|
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
|
|
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
|
|
223
|
+
test("revokes every active guardian channel for the type", async () => {
|
|
288
224
|
seedContact({ id: "g1", role: "guardian" });
|
|
289
|
-
|
|
290
|
-
seedChannel({
|
|
291
|
-
id: "G",
|
|
225
|
+
const a = seedChannel({
|
|
292
226
|
contactId: "g1",
|
|
293
227
|
type: "slack",
|
|
294
|
-
address: "
|
|
228
|
+
address: "U_A",
|
|
295
229
|
status: "active",
|
|
296
230
|
});
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
id: "A",
|
|
231
|
+
const b = seedChannel({
|
|
232
|
+
contactId: "g1",
|
|
300
233
|
type: "slack",
|
|
301
|
-
address: "
|
|
234
|
+
address: "U_B",
|
|
302
235
|
status: "active",
|
|
303
|
-
policy: "allow",
|
|
304
236
|
});
|
|
305
237
|
|
|
306
238
|
await revokeExistingChannelGuardian("slack");
|
|
307
239
|
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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("
|
|
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
|
-
|
|
333
|
-
status: "active",
|
|
253
|
+
status: "revoked",
|
|
334
254
|
});
|
|
335
255
|
|
|
336
|
-
|
|
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("
|
|
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
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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).
|
|
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
|
|
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("
|
|
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 () => {
|