@vellumai/vellum-gateway 0.10.3 → 0.10.4-dev.202607010028.1a4efcc
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/node_modules/@vellumai/gateway-client/src/gateway-ipc-contracts.ts +18 -0
- package/node_modules/@vellumai/service-contracts/src/__tests__/package-boundary.test.ts +1 -5
- 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 +40 -11
- package/src/__tests__/contact-prompt-submit.test.ts +110 -85
- package/src/__tests__/contact-store-mark-channel-verified.test.ts +44 -152
- package/src/__tests__/contacts-control-plane-proxy.test.ts +563 -39
- package/src/__tests__/contacts-info-joiner.test.ts +47 -0
- package/src/__tests__/data-migration-m0008-upsert-acl-columns-from-assistant.test.ts +491 -0
- package/src/__tests__/guardian-bootstrap-binding.test.ts +28 -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 +161 -217
- package/src/db/connection.ts +6 -0
- package/src/db/contact-store.ts +165 -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/feature-flag-registry.json +16 -24
- package/src/http/routes/contact-prompt.ts +32 -30
- package/src/http/routes/contacts-control-plane-proxy.ts +174 -37
- package/src/http/routes/twilio-voice-verify-callback.ts +12 -79
- package/src/ipc/contact-handlers.ts +16 -0
- package/src/ipc/ipc-health.test.ts +109 -0
- package/src/ipc/ipc-health.ts +82 -0
- package/src/risk/bash-risk-classifier.test.ts +9 -0
- package/src/risk/command-registry/commands/assistant.ts +32 -0
- package/src/risk/command-registry.test.ts +17 -0
- 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/src/verification/outbound-voice-verification-sync.ts +9 -0
- package/src/verification/voice-approval-sync.ts +8 -0
|
@@ -291,3 +291,21 @@ export const GetContactIpcResponseSchema = z.object({
|
|
|
291
291
|
export type GetContactIpcResponse = z.infer<
|
|
292
292
|
typeof GetContactIpcResponseSchema
|
|
293
293
|
>;
|
|
294
|
+
|
|
295
|
+
export const GetGuardianContactIpcParamsSchema = z
|
|
296
|
+
.object({})
|
|
297
|
+
.strict()
|
|
298
|
+
.default({});
|
|
299
|
+
|
|
300
|
+
export type GetGuardianContactIpcParams = z.infer<
|
|
301
|
+
typeof GetGuardianContactIpcParamsSchema
|
|
302
|
+
>;
|
|
303
|
+
|
|
304
|
+
export const GetGuardianContactIpcResponseSchema = z.object({
|
|
305
|
+
ok: z.boolean(),
|
|
306
|
+
guardianIds: z.array(z.string()),
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
export type GetGuardianContactIpcResponse = z.infer<
|
|
310
|
+
typeof GetGuardianContactIpcResponseSchema
|
|
311
|
+
>;
|
|
@@ -5,8 +5,7 @@
|
|
|
5
5
|
* 1. Does NOT import from assistant, gateway, credential-executor, or other
|
|
6
6
|
* service runtime modules.
|
|
7
7
|
* 2. Does NOT import from runtime shared packages that sit above it in the
|
|
8
|
-
* dependency hierarchy (@vellumai/credential-storage, @vellumai/egress-proxy
|
|
9
|
-
* @vellumai/skill-host-contracts).
|
|
8
|
+
* dependency hierarchy (@vellumai/credential-storage, @vellumai/egress-proxy).
|
|
10
9
|
* 3. Does NOT import from x-client packages (@vellumai/assistant-client,
|
|
11
10
|
* @vellumai/ces-client, @vellumai/gateway-client).
|
|
12
11
|
* 4. Remains a pure schema/type package — no runtime dependencies beyond zod.
|
|
@@ -73,8 +72,6 @@ const FORBIDDEN_IMPORT_PATTERNS = [
|
|
|
73
72
|
/require\s*\(\s*["']@vellumai\/credential-storage(?:\/|["'])/,
|
|
74
73
|
/from\s+["']@vellumai\/egress-proxy(?:\/|["'])/,
|
|
75
74
|
/require\s*\(\s*["']@vellumai\/egress-proxy(?:\/|["'])/,
|
|
76
|
-
/from\s+["']@vellumai\/skill-host-contracts(?:\/|["'])/,
|
|
77
|
-
/require\s*\(\s*["']@vellumai\/skill-host-contracts(?:\/|["'])/,
|
|
78
75
|
|
|
79
76
|
// x-client packages (higher layer)
|
|
80
77
|
/from\s+["']@vellumai\/assistant-client(?:\/|["'])/,
|
|
@@ -143,7 +140,6 @@ describe("package boundary", () => {
|
|
|
143
140
|
"@vellumai/assistant",
|
|
144
141
|
"@vellumai/credential-storage",
|
|
145
142
|
"@vellumai/egress-proxy",
|
|
146
|
-
"@vellumai/skill-host-contracts",
|
|
147
143
|
"@vellumai/assistant-client",
|
|
148
144
|
"@vellumai/ces-client",
|
|
149
145
|
"@vellumai/gateway-client",
|
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
|
});
|
|
@@ -94,6 +94,10 @@ const upsertVerifiedChannelHandler = contactRoutes.find(
|
|
|
94
94
|
(r) => r.method === "upsert_verified_channel",
|
|
95
95
|
)!.handler;
|
|
96
96
|
|
|
97
|
+
const getGuardianContactHandler = contactRoutes.find(
|
|
98
|
+
(r) => r.method === "get_guardian_contact",
|
|
99
|
+
)!.handler;
|
|
100
|
+
|
|
97
101
|
beforeAll(async () => {
|
|
98
102
|
await initGatewayDb();
|
|
99
103
|
});
|
|
@@ -233,26 +237,23 @@ describe("mark_channel_verified IPC handler", () => {
|
|
|
233
237
|
).rejects.toThrow(/not found/);
|
|
234
238
|
});
|
|
235
239
|
|
|
236
|
-
test("
|
|
240
|
+
test("does not verify a channel absent from the gateway DB", async () => {
|
|
237
241
|
seedAssistantContact("c1");
|
|
238
242
|
seedAssistantChannel({ id: "ch1", contactId: "c1", status: "unverified" });
|
|
239
243
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
expect(res.channel.status).toBe("active");
|
|
244
|
+
// The channel lives only in the assistant DB. The gateway DB is the source
|
|
245
|
+
// of truth and does not heal from the assistant, so verification reports
|
|
246
|
+
// not found and nothing lands in the gateway DB.
|
|
247
|
+
await expect(
|
|
248
|
+
markChannelVerifiedHandler({ contactChannelId: "ch1" }),
|
|
249
|
+
).rejects.toThrow(/not found/);
|
|
247
250
|
|
|
248
|
-
// Channel + parent contact were materialized into the gateway DB.
|
|
249
251
|
const channelInGateway = getGatewayDb()
|
|
250
252
|
.select()
|
|
251
253
|
.from(contactChannels)
|
|
252
254
|
.where(eq(contactChannels.id, "ch1"))
|
|
253
255
|
.get();
|
|
254
|
-
expect(channelInGateway).
|
|
255
|
-
expect(channelInGateway!.contactId).toBe("c1");
|
|
256
|
+
expect(channelInGateway).toBeUndefined();
|
|
256
257
|
});
|
|
257
258
|
});
|
|
258
259
|
|
|
@@ -352,6 +353,34 @@ describe("mark_channel_revoked IPC handler", () => {
|
|
|
352
353
|
});
|
|
353
354
|
});
|
|
354
355
|
|
|
356
|
+
describe("get_guardian_contact IPC handler", () => {
|
|
357
|
+
test("returns the guardian contact id(s) from the gateway DB", async () => {
|
|
358
|
+
seedContact("g1", "guardian");
|
|
359
|
+
seedContact("c1", "contact");
|
|
360
|
+
|
|
361
|
+
const res = (await getGuardianContactHandler({})) as {
|
|
362
|
+
ok: boolean;
|
|
363
|
+
guardianIds: string[];
|
|
364
|
+
};
|
|
365
|
+
|
|
366
|
+
expect(res.ok).toBe(true);
|
|
367
|
+
expect(res.guardianIds).toEqual(["g1"]);
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
test("excludes non-guardian contacts", async () => {
|
|
371
|
+
seedContact("c1", "contact");
|
|
372
|
+
seedContact("c2", "contact");
|
|
373
|
+
|
|
374
|
+
const res = (await getGuardianContactHandler({})) as {
|
|
375
|
+
ok: boolean;
|
|
376
|
+
guardianIds: string[];
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
expect(res.ok).toBe(true);
|
|
380
|
+
expect(res.guardianIds).toEqual([]);
|
|
381
|
+
});
|
|
382
|
+
});
|
|
383
|
+
|
|
355
384
|
describe("upsert_verified_channel IPC handler", () => {
|
|
356
385
|
test("creates + verifies a new gateway channel and returns it", async () => {
|
|
357
386
|
const before = Date.now();
|