@vellumai/vellum-gateway 0.8.1 → 0.8.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.md +6 -5
- package/README.md +4 -3
- package/package.json +1 -1
- package/src/__tests__/config-file-watcher.test.ts +57 -0
- package/src/__tests__/contact-store-mark-channel-verified.test.ts +219 -6
- package/src/__tests__/contacts-control-plane-proxy.test.ts +37 -0
- package/src/__tests__/guardian-binding-channel-reuse.test.ts +275 -0
- package/src/__tests__/ipc-route-policy.test.ts +93 -0
- package/src/__tests__/logger-retention.test.ts +115 -0
- package/src/__tests__/nonbash-trust-rule-overrides.test.ts +1 -0
- package/src/__tests__/route-schema-guard.test.ts +4 -0
- package/src/__tests__/slack-display-name.test.ts +70 -0
- package/src/__tests__/slack-socket-mode-scopes.test.ts +27 -4
- package/src/__tests__/slack-socket-mode-thread-tracking.test.ts +105 -1
- package/src/__tests__/trust-rule-store.test.ts +49 -0
- package/src/__tests__/twilio-webhooks.test.ts +47 -0
- package/src/__tests__/upsert-verified-contact-channel.test.ts +49 -6
- package/src/auth/guardian-bootstrap.ts +61 -11
- package/src/auth/ipc-route-policy.ts +115 -1
- package/src/channels/inbound-event.ts +4 -2
- package/src/channels/types.ts +2 -0
- package/src/config-file-watcher.ts +44 -1
- package/src/db/contact-store.ts +164 -38
- package/src/db/trust-rule-store.ts +22 -17
- package/src/feature-flag-registry.json +41 -9
- package/src/http/routes/a2a-routes.test.ts +129 -0
- package/src/http/routes/a2a-routes.ts +121 -0
- package/src/http/routes/contacts-control-plane-proxy.ts +13 -2
- package/src/http/routes/log-export.test.ts +25 -0
- package/src/http/routes/log-export.ts +23 -9
- package/src/http/routes/log-tail.test.ts +63 -0
- package/src/http/routes/log-tail.ts +11 -5
- package/src/http/routes/trust-rules.ts +2 -2
- package/src/http/routes/twilio-voice-verify-callback.ts +41 -12
- package/src/http/routes/twilio-voice-webhook.test.ts +55 -0
- package/src/http/routes/twilio-voice-webhook.ts +10 -2
- package/src/index.ts +95 -6
- package/src/ipc/risk-classification-handlers.ts +4 -1
- package/src/logger.ts +31 -11
- package/src/risk/bash-risk-classifier.test.ts +49 -1
- package/src/risk/command-registry/commands/assistant.ts +65 -2
- package/src/risk/command-registry.test.ts +10 -0
- package/src/risk/file-risk-classifier.test.ts +117 -0
- package/src/risk/file-risk-classifier.ts +46 -3
- package/src/runtime/client.ts +72 -15
- package/src/slack/normalize.test.ts +46 -0
- package/src/slack/normalize.ts +133 -29
- package/src/slack/socket-mode.ts +62 -8
- package/src/twilio/setup-state.test.ts +49 -0
- package/src/twilio/setup-state.ts +35 -0
- package/src/twilio/validate-webhook.ts +7 -1
- package/src/types.ts +1 -0
- package/src/velay/allowed-paths.test.ts +69 -0
- package/src/velay/allowed-paths.ts +53 -0
- package/src/velay/client.test.ts +126 -13
- package/src/velay/client.ts +107 -12
- package/src/verification/binding-helpers.ts +31 -0
- package/src/verification/contact-helpers.ts +83 -24
- package/src/verification/outbound-voice-verification-sync.test.ts +453 -0
- package/src/verification/outbound-voice-verification-sync.ts +54 -11
- package/src/webhook-pipeline.ts +7 -1
package/ARCHITECTURE.md
CHANGED
|
@@ -298,7 +298,7 @@ The assistant runtime reads this URL via the centralized `public-ingress-urls.ts
|
|
|
298
298
|
|
|
299
299
|
Velay is a platform-managed tunnel for assistant-hosted HTTP and WebSocket traffic. When it is active, Velay publishes the registered public assistant URL to `ingress.publicBaseUrl` and marks it with `ingress.publicBaseUrlManagedBy: "velay"`.
|
|
300
300
|
|
|
301
|
-
When `VELAY_BASE_URL` is present in the gateway environment, the gateway
|
|
301
|
+
When `VELAY_BASE_URL` is present in the gateway environment, the gateway creates `VelayTunnelClient` but starts it only after Twilio setup has been started in the workspace. The Twilio setup skill writes `twilio.setupStarted: true` at the beginning of setup so the tunnel can open while credentials and phone-number selection are still in progress. On boot, existing Twilio credentials or existing `twilio.accountSid` / `twilio.phoneNumber` config also count as prior setup. Before credential-backed startup side effects run, the gateway clears any stale Velay-managed `ingress.publicBaseUrl`; if setup has not started, it does this without opening a tunnel. The client registers with Velay over `GET /v1/register` using the assistant API key, then receives a `registered` frame containing a public assistant URL such as `https://velay.vellum.ai/<assistant-id>`. If Vellum platform credentials change while the tunnel is already running or waiting on backoff, the gateway asks the client to reconnect with fresh credentials instead of waiting for process restart. The gateway writes the registered URL to `ingress.publicBaseUrl`. When the tunnel disconnects, it clears that value only if the Velay ownership marker is still present and the URL still matches what the tunnel published, leaving manual URLs intact.
|
|
302
302
|
|
|
303
303
|
Velay forwards both HTTP request frames and WebSocket frames into the local gateway loopback listener:
|
|
304
304
|
|
|
@@ -316,10 +316,11 @@ Local platform smoke-test flow:
|
|
|
316
316
|
|
|
317
317
|
1. In `vellum-assistant-platform`, run `vel up velay`.
|
|
318
318
|
2. Ensure vembda passes the environment-appropriate `VELAY_BASE_URL` into assistant gateway containers.
|
|
319
|
-
3.
|
|
320
|
-
4.
|
|
321
|
-
5.
|
|
322
|
-
6. Verify
|
|
319
|
+
3. Start or complete Twilio setup in the workspace so the gateway is allowed to connect the tunnel.
|
|
320
|
+
4. Re-hatch or restart the assistant so the gateway receives the new environment.
|
|
321
|
+
5. Confirm gateway logs show `Velay tunnel connected` and `Velay tunnel registered`.
|
|
322
|
+
6. Verify HTTP forwarding by requesting `${VELAY_PUBLIC_BASE_URL}/<assistant-id>/healthz` and `${VELAY_PUBLIC_BASE_URL}/<assistant-id>/schema`. When validating a JSON webhook route under active development, POST a small JSON body through the same Velay public URL and confirm it reaches the loopback gateway.
|
|
323
|
+
7. Verify Twilio WebSocket forwarding with a synthetic local WebSocket client against `${VELAY_PUBLIC_BASE_URL}/<assistant-id>/webhooks/twilio/relay?callSessionId=...&token=...`, then with a real Twilio call after the gateway has registered with Velay.
|
|
323
324
|
|
|
324
325
|
### URL Builders
|
|
325
326
|
|
package/README.md
CHANGED
|
@@ -212,7 +212,7 @@ The assistant runtime uses this URL to construct all webhook and OAuth callback
|
|
|
212
212
|
|
|
213
213
|
### Velay for Twilio Testing
|
|
214
214
|
|
|
215
|
-
Velay is a managed ingress transport for assistant-hosted HTTP and WebSocket traffic. When Velay registration succeeds, the gateway writes the registered public assistant URL to `ingress.publicBaseUrl` and marks it with `ingress.publicBaseUrlManagedBy: "velay"`. Twilio URL builders use that public base URL for voice, status, relay, and media-stream endpoints.
|
|
215
|
+
Velay is a managed ingress transport for assistant-hosted HTTP and WebSocket traffic. The gateway starts the Velay tunnel only after Twilio setup has been started in the workspace, or when existing Twilio config shows it was set up before. When Velay registration succeeds, the gateway writes the registered public assistant URL to `ingress.publicBaseUrl` and marks it with `ingress.publicBaseUrlManagedBy: "velay"`. Twilio URL builders use that public base URL for voice, status, relay, and media-stream endpoints.
|
|
216
216
|
|
|
217
217
|
Use Velay when testing Twilio voice webhooks or Twilio WebSocket upgrades through the platform-managed tunnel:
|
|
218
218
|
|
|
@@ -230,8 +230,9 @@ Use Velay when testing Twilio voice webhooks or Twilio WebSocket upgrades throug
|
|
|
230
230
|
|
|
231
231
|
Hosted environments should use their environment's deployed Velay URL instead.
|
|
232
232
|
|
|
233
|
-
3.
|
|
234
|
-
4.
|
|
233
|
+
3. Start or complete Twilio setup in the workspace so the gateway is allowed to connect the tunnel.
|
|
234
|
+
4. Re-hatch or restart the assistant so the gateway process receives `VELAY_BASE_URL`.
|
|
235
|
+
5. Confirm the gateway logs include `Velay tunnel connected` followed by `Velay tunnel registered`. Registration publishes the returned Velay URL to `ingress.publicBaseUrl`.
|
|
235
236
|
|
|
236
237
|
For an HTTP bridge smoke test, send a request to the registered Velay public URL and confirm it reaches the loopback gateway, for example:
|
|
237
238
|
|
package/package.json
CHANGED
|
@@ -42,6 +42,31 @@ function makeEvent(
|
|
|
42
42
|
};
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
function makeManualIntervalTimer() {
|
|
46
|
+
let intervalFn: (() => void) | undefined;
|
|
47
|
+
let cleared = false;
|
|
48
|
+
type IntervalHandle = ReturnType<typeof setInterval>;
|
|
49
|
+
const timer = 1 as unknown as IntervalHandle;
|
|
50
|
+
return {
|
|
51
|
+
timerApi: {
|
|
52
|
+
setInterval: (fn: () => void, _delayMs: number) => {
|
|
53
|
+
intervalFn = fn;
|
|
54
|
+
return timer;
|
|
55
|
+
},
|
|
56
|
+
clearInterval: (_timer: IntervalHandle) => {
|
|
57
|
+
cleared = true;
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
runInterval: () => {
|
|
61
|
+
if (!intervalFn) {
|
|
62
|
+
throw new Error("interval was not scheduled");
|
|
63
|
+
}
|
|
64
|
+
intervalFn();
|
|
65
|
+
},
|
|
66
|
+
isCleared: () => cleared,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
45
70
|
afterEach(() => {
|
|
46
71
|
try {
|
|
47
72
|
if (existsSync(configPath)) unlinkSync(configPath);
|
|
@@ -51,6 +76,38 @@ afterEach(() => {
|
|
|
51
76
|
});
|
|
52
77
|
|
|
53
78
|
describe("ConfigFileWatcher", () => {
|
|
79
|
+
test("polls config changes when file watcher events are missed", () => {
|
|
80
|
+
const events: ConfigChangeEvent[] = [];
|
|
81
|
+
const timer = makeManualIntervalTimer();
|
|
82
|
+
const watcher = new ConfigFileWatcher(
|
|
83
|
+
(event) => {
|
|
84
|
+
events.push(event);
|
|
85
|
+
},
|
|
86
|
+
{ pollIntervalMs: 10, timerApi: timer.timerApi },
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
watcher.start();
|
|
91
|
+
writeConfig({
|
|
92
|
+
twilio: {
|
|
93
|
+
setupStarted: true,
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
timer.runInterval();
|
|
98
|
+
|
|
99
|
+
expect(events).toHaveLength(1);
|
|
100
|
+
expect(events[0].changedKeys).toEqual(new Set(["twilio"]));
|
|
101
|
+
expect(events[0].changedFields.get("twilio")).toEqual(
|
|
102
|
+
new Set(["setupStarted"]),
|
|
103
|
+
);
|
|
104
|
+
} finally {
|
|
105
|
+
watcher.stop();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
expect(timer.isCleared()).toBe(true);
|
|
109
|
+
});
|
|
110
|
+
|
|
54
111
|
test("reports shallow ingress fields changed by Velay-managed URL writes", () => {
|
|
55
112
|
writeConfig({
|
|
56
113
|
ingress: {
|
|
@@ -2,8 +2,10 @@
|
|
|
2
2
|
* Tests for ContactStore.markChannelVerified — manual channel verification
|
|
3
3
|
* flow used by the /v1/contact-channels/:id/verify endpoint.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
5
|
+
* The assistant DB proxy is mocked behind a per-test fake (`fakeAssistantDb`)
|
|
6
|
+
* so tests can stage either an empty assistant DB (most cases) or a
|
|
7
|
+
* pre-populated one (mirror-from-assistant cases) without spinning up a
|
|
8
|
+
* daemon.
|
|
7
9
|
*/
|
|
8
10
|
|
|
9
11
|
import {
|
|
@@ -18,13 +20,75 @@ import {
|
|
|
18
20
|
|
|
19
21
|
import "./test-preload.js";
|
|
20
22
|
|
|
21
|
-
|
|
23
|
+
type FakeChannelRow = {
|
|
24
|
+
id: string;
|
|
25
|
+
contact_id: string;
|
|
26
|
+
type: string;
|
|
27
|
+
address: string;
|
|
28
|
+
is_primary: number;
|
|
29
|
+
external_user_id: string | null;
|
|
30
|
+
external_chat_id: string | null;
|
|
31
|
+
status: string;
|
|
32
|
+
policy: string;
|
|
33
|
+
verified_at: number | null;
|
|
34
|
+
verified_via: string | null;
|
|
35
|
+
invite_id: string | null;
|
|
36
|
+
revoked_reason: string | null;
|
|
37
|
+
blocked_reason: string | null;
|
|
38
|
+
last_seen_at: number | null;
|
|
39
|
+
interaction_count: number;
|
|
40
|
+
last_interaction: number | null;
|
|
41
|
+
created_at: number;
|
|
42
|
+
updated_at: number | null;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
type FakeContactRow = {
|
|
46
|
+
id: string;
|
|
47
|
+
display_name: string;
|
|
48
|
+
role: string | null;
|
|
49
|
+
principal_id: string | null;
|
|
50
|
+
created_at: number;
|
|
51
|
+
updated_at: number | null;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const fakeAssistantDb = {
|
|
55
|
+
channels: new Map<string, FakeChannelRow>(),
|
|
56
|
+
contacts: new Map<string, FakeContactRow>(),
|
|
57
|
+
runCalls: [] as { sql: string; bind?: unknown[] }[],
|
|
58
|
+
reset(): void {
|
|
59
|
+
this.channels.clear();
|
|
60
|
+
this.contacts.clear();
|
|
61
|
+
this.runCalls = [];
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
// Mock the assistant DB proxy before importing ContactStore. The fake
|
|
66
|
+
// honors `SELECT ... FROM contact_channels WHERE id = ?` and
|
|
67
|
+
// `SELECT ... FROM contacts WHERE id = ?`; all other SELECTs return [].
|
|
22
68
|
mock.module("../db/assistant-db-proxy.js", () => ({
|
|
23
|
-
assistantDbRun: mock(async (
|
|
24
|
-
|
|
69
|
+
assistantDbRun: mock(async (sql: string, bind?: unknown[]) => {
|
|
70
|
+
fakeAssistantDb.runCalls.push({ sql, bind });
|
|
71
|
+
return { changes: 1, lastInsertRowid: 0 };
|
|
72
|
+
}),
|
|
73
|
+
assistantDbQuery: mock(async (sql: string, bind?: unknown[]) => {
|
|
74
|
+
const lower = sql.toLowerCase();
|
|
75
|
+
if (lower.includes("from contact_channels")) {
|
|
76
|
+
const id = String(bind?.[0] ?? "");
|
|
77
|
+
const row = fakeAssistantDb.channels.get(id);
|
|
78
|
+
return row ? [row] : [];
|
|
79
|
+
}
|
|
80
|
+
if (lower.includes("from contacts")) {
|
|
81
|
+
const id = String(bind?.[0] ?? "");
|
|
82
|
+
const row = fakeAssistantDb.contacts.get(id);
|
|
83
|
+
return row ? [row] : [];
|
|
84
|
+
}
|
|
85
|
+
return [];
|
|
86
|
+
}),
|
|
25
87
|
assistantDbExec: mock(async () => undefined),
|
|
26
88
|
}));
|
|
27
89
|
|
|
90
|
+
import { eq } from "drizzle-orm";
|
|
91
|
+
|
|
28
92
|
import { ContactStore } from "../db/contact-store.js";
|
|
29
93
|
import {
|
|
30
94
|
initGatewayDb,
|
|
@@ -41,8 +105,48 @@ beforeEach(() => {
|
|
|
41
105
|
const db = getGatewayDb();
|
|
42
106
|
db.delete(contactChannels).run();
|
|
43
107
|
db.delete(contacts).run();
|
|
108
|
+
fakeAssistantDb.reset();
|
|
44
109
|
});
|
|
45
110
|
|
|
111
|
+
function seedAssistantContact(id: string, role: string = "guardian"): void {
|
|
112
|
+
fakeAssistantDb.contacts.set(id, {
|
|
113
|
+
id,
|
|
114
|
+
display_name: `name-${id}`,
|
|
115
|
+
role,
|
|
116
|
+
principal_id: `prin-${id}`,
|
|
117
|
+
created_at: 100,
|
|
118
|
+
updated_at: 100,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function seedAssistantChannel(opts: {
|
|
123
|
+
id: string;
|
|
124
|
+
contactId: string;
|
|
125
|
+
status?: string;
|
|
126
|
+
}): void {
|
|
127
|
+
fakeAssistantDb.channels.set(opts.id, {
|
|
128
|
+
id: opts.id,
|
|
129
|
+
contact_id: opts.contactId,
|
|
130
|
+
type: "vellum",
|
|
131
|
+
address: `addr-${opts.id}`,
|
|
132
|
+
is_primary: 0,
|
|
133
|
+
external_user_id: null,
|
|
134
|
+
external_chat_id: null,
|
|
135
|
+
status: opts.status ?? "unverified",
|
|
136
|
+
policy: "allow",
|
|
137
|
+
verified_at: null,
|
|
138
|
+
verified_via: null,
|
|
139
|
+
invite_id: null,
|
|
140
|
+
revoked_reason: null,
|
|
141
|
+
blocked_reason: null,
|
|
142
|
+
last_seen_at: null,
|
|
143
|
+
interaction_count: 0,
|
|
144
|
+
last_interaction: null,
|
|
145
|
+
created_at: 100,
|
|
146
|
+
updated_at: 100,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
46
150
|
afterAll(() => {
|
|
47
151
|
resetGatewayDb();
|
|
48
152
|
});
|
|
@@ -90,7 +194,7 @@ function seedChannel(opts: {
|
|
|
90
194
|
}
|
|
91
195
|
|
|
92
196
|
describe("ContactStore.markChannelVerified", () => {
|
|
93
|
-
test("returns null when
|
|
197
|
+
test("returns null when neither side has the channel", async () => {
|
|
94
198
|
const store = new ContactStore();
|
|
95
199
|
expect(await store.markChannelVerified("missing-id")).toBeNull();
|
|
96
200
|
});
|
|
@@ -174,4 +278,113 @@ describe("ContactStore.markChannelVerified", () => {
|
|
|
174
278
|
// Same verifiedAt — predicate prevented re-stamping
|
|
175
279
|
expect(b!.channel.verifiedAt).toBe(a!.channel.verifiedAt);
|
|
176
280
|
});
|
|
281
|
+
|
|
282
|
+
test("mirrors channel + contact from assistant DB when gateway is empty, then verifies", async () => {
|
|
283
|
+
seedAssistantContact("c1");
|
|
284
|
+
seedAssistantChannel({
|
|
285
|
+
id: "ch1",
|
|
286
|
+
contactId: "c1",
|
|
287
|
+
status: "unverified",
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
const before = Date.now();
|
|
291
|
+
const result = await new ContactStore().markChannelVerified("ch1");
|
|
292
|
+
expect(result).not.toBeNull();
|
|
293
|
+
expect(result!.didWrite).toBe(true);
|
|
294
|
+
expect(result!.channel.status).toBe("active");
|
|
295
|
+
expect(result!.channel.verifiedVia).toBe("manual");
|
|
296
|
+
expect(result!.channel.verifiedAt!).toBeGreaterThanOrEqual(before);
|
|
297
|
+
|
|
298
|
+
// Channel + contact were materialized in the gateway DB.
|
|
299
|
+
const channelInGateway = getGatewayDb()
|
|
300
|
+
.select()
|
|
301
|
+
.from(contactChannels)
|
|
302
|
+
.where(eq(contactChannels.id, "ch1"))
|
|
303
|
+
.get();
|
|
304
|
+
expect(channelInGateway).toBeTruthy();
|
|
305
|
+
expect(channelInGateway!.contactId).toBe("c1");
|
|
306
|
+
expect(channelInGateway!.type).toBe("vellum");
|
|
307
|
+
const contactInGateway = getGatewayDb()
|
|
308
|
+
.select()
|
|
309
|
+
.from(contacts)
|
|
310
|
+
.where(eq(contacts.id, "c1"))
|
|
311
|
+
.get();
|
|
312
|
+
expect(contactInGateway).toBeTruthy();
|
|
313
|
+
expect(contactInGateway!.displayName).toBe("name-c1");
|
|
314
|
+
expect(contactInGateway!.role).toBe("guardian");
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
test("refuses to mirror when assistant channel references a missing contact", async () => {
|
|
318
|
+
// Channel present, parent contact absent — broken state, refuse silently.
|
|
319
|
+
seedAssistantChannel({ id: "ch1", contactId: "orphan", status: "unverified" });
|
|
320
|
+
|
|
321
|
+
const result = await new ContactStore().markChannelVerified("ch1");
|
|
322
|
+
expect(result).toBeNull();
|
|
323
|
+
|
|
324
|
+
// Nothing landed in the gateway.
|
|
325
|
+
const channelInGateway = getGatewayDb()
|
|
326
|
+
.select()
|
|
327
|
+
.from(contactChannels)
|
|
328
|
+
.where(eq(contactChannels.id, "ch1"))
|
|
329
|
+
.get();
|
|
330
|
+
expect(channelInGateway).toBeUndefined();
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
test("mirror is idempotent across successive calls", async () => {
|
|
334
|
+
seedAssistantContact("c1");
|
|
335
|
+
seedAssistantChannel({
|
|
336
|
+
id: "ch1",
|
|
337
|
+
contactId: "c1",
|
|
338
|
+
status: "unverified",
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
const store = new ContactStore();
|
|
342
|
+
const first = await store.markChannelVerified("ch1");
|
|
343
|
+
const second = await store.markChannelVerified("ch1");
|
|
344
|
+
expect(first!.didWrite).toBe(true);
|
|
345
|
+
expect(second!.didWrite).toBe(false);
|
|
346
|
+
expect(second!.channel.verifiedAt).toBe(first!.channel.verifiedAt);
|
|
347
|
+
// Mirror INSERT OR IGNORE: still exactly one channel row, one contact row.
|
|
348
|
+
expect(
|
|
349
|
+
getGatewayDb().select().from(contactChannels).all().length,
|
|
350
|
+
).toBe(1);
|
|
351
|
+
expect(getGatewayDb().select().from(contacts).all().length).toBe(1);
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
test("gateway-present channel takes precedence over assistant copy (no mirror, no overwrite)", async () => {
|
|
355
|
+
// Gateway has the row (with a custom display_name for the contact);
|
|
356
|
+
// assistant has a different display_name. We should verify the gateway
|
|
357
|
+
// row in place — not overwrite gateway state with the assistant copy.
|
|
358
|
+
const now = Date.now();
|
|
359
|
+
getGatewayDb()
|
|
360
|
+
.insert(contacts)
|
|
361
|
+
.values({
|
|
362
|
+
id: "c1",
|
|
363
|
+
displayName: "gateway-name",
|
|
364
|
+
role: "guardian",
|
|
365
|
+
principalId: "prin-c1",
|
|
366
|
+
createdAt: now,
|
|
367
|
+
updatedAt: now,
|
|
368
|
+
})
|
|
369
|
+
.run();
|
|
370
|
+
seedChannel({ id: "ch1", contactId: "c1", status: "unverified" });
|
|
371
|
+
seedAssistantContact("c1");
|
|
372
|
+
seedAssistantChannel({
|
|
373
|
+
id: "ch1",
|
|
374
|
+
contactId: "c1",
|
|
375
|
+
status: "unverified",
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
const result = await new ContactStore().markChannelVerified("ch1");
|
|
379
|
+
expect(result).not.toBeNull();
|
|
380
|
+
expect(result!.didWrite).toBe(true);
|
|
381
|
+
expect(result!.channel.status).toBe("active");
|
|
382
|
+
|
|
383
|
+
const contactRow = getGatewayDb()
|
|
384
|
+
.select()
|
|
385
|
+
.from(contacts)
|
|
386
|
+
.where(eq(contacts.id, "c1"))
|
|
387
|
+
.get();
|
|
388
|
+
expect(contactRow!.displayName).toBe("gateway-name");
|
|
389
|
+
});
|
|
177
390
|
});
|
|
@@ -393,6 +393,43 @@ describe("handleUpsertContact (gateway-native)", () => {
|
|
|
393
393
|
expect(params.displayName).toBe("Alice");
|
|
394
394
|
});
|
|
395
395
|
|
|
396
|
+
test("strips role and principalId from request body (privilege escalation guard)", async () => {
|
|
397
|
+
// Regression: a malicious caller MUST NOT be able to rebind the guardian
|
|
398
|
+
// by sending `role: "guardian"` + their own principalId via POST
|
|
399
|
+
// /v1/contacts. The route handler must never pass those fields through
|
|
400
|
+
// to the service layer; ContactStore's params surface must not include
|
|
401
|
+
// them.
|
|
402
|
+
contactStoreUpsertMock = mock(async () => ({
|
|
403
|
+
contact: { ...DEFAULT_MOCK_CONTACT, id: "ct_target", role: "guardian" },
|
|
404
|
+
created: false,
|
|
405
|
+
}));
|
|
406
|
+
|
|
407
|
+
const handler = createContactsControlPlaneProxyHandler(makeConfig());
|
|
408
|
+
const res = await handler.handleUpsertContact(
|
|
409
|
+
new Request("http://localhost:7830/v1/contacts", {
|
|
410
|
+
method: "POST",
|
|
411
|
+
headers: { "content-type": "application/json" },
|
|
412
|
+
body: JSON.stringify({
|
|
413
|
+
id: "ct_target",
|
|
414
|
+
displayName: "Pwn3d",
|
|
415
|
+
role: "guardian",
|
|
416
|
+
principalId: "attacker-principal-id",
|
|
417
|
+
}),
|
|
418
|
+
}),
|
|
419
|
+
);
|
|
420
|
+
|
|
421
|
+
expect(res.status).toBe(200);
|
|
422
|
+
expect(contactStoreUpsertMock).toHaveBeenCalledTimes(1);
|
|
423
|
+
const [params] = contactStoreUpsertMock.mock.calls[0] as [
|
|
424
|
+
Record<string, unknown>,
|
|
425
|
+
];
|
|
426
|
+
expect(params.role).toBeUndefined();
|
|
427
|
+
expect(params.principalId).toBeUndefined();
|
|
428
|
+
// The other fields still flow through.
|
|
429
|
+
expect(params.id).toBe("ct_target");
|
|
430
|
+
expect(params.displayName).toBe("Pwn3d");
|
|
431
|
+
});
|
|
432
|
+
|
|
396
433
|
test("returns 400 when body is invalid JSON", async () => {
|
|
397
434
|
const handler = createContactsControlPlaneProxyHandler(makeConfig());
|
|
398
435
|
const res = await handler.handleUpsertContact(
|