@vellumai/vellum-gateway 0.8.0 → 0.8.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +10 -0
- package/ARCHITECTURE.md +6 -5
- package/Dockerfile +2 -1
- package/README.md +4 -3
- package/package.json +1 -1
- package/src/__tests__/contact-prompt-submit.test.ts +5 -5
- package/src/__tests__/contact-store-mark-channel-verified.test.ts +390 -0
- package/src/__tests__/contacts-control-plane-proxy.test.ts +334 -6
- package/src/__tests__/contacts-control-plane-route-match.test.ts +16 -0
- package/src/__tests__/edge-auth.test.ts +253 -0
- package/src/__tests__/edge-guardian-auth.test.ts +198 -0
- package/src/__tests__/guardian-binding-channel-reuse.test.ts +275 -0
- package/src/__tests__/ipc-route-policy.test.ts +117 -0
- package/src/__tests__/live-voice-websocket.test.ts +1 -1
- package/src/__tests__/logger-retention.test.ts +115 -0
- package/src/__tests__/nonbash-trust-rule-overrides.test.ts +1 -0
- package/src/__tests__/slack-display-name.test.ts +70 -0
- package/src/__tests__/slack-normalize.test.ts +132 -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__/upsert-verified-contact-channel.test.ts +49 -6
- package/src/auth/guardian-bootstrap.ts +64 -12
- package/src/auth/ipc-route-policy.ts +124 -1
- package/src/db/assistant-db-proxy.ts +76 -7
- package/src/db/contact-store.ts +893 -1
- package/src/db/schema.ts +29 -0
- package/src/db/trust-rule-store.ts +22 -17
- package/src/feature-flag-registry.json +57 -1
- package/src/handlers/handle-inbound.ts +8 -11
- package/src/http/middleware/auth.ts +193 -40
- package/src/http/router.ts +26 -4
- package/src/http/routes/channel-verification-session-proxy.ts +53 -6
- package/src/http/routes/contact-prompt.ts +44 -15
- package/src/http/routes/contacts-control-plane-proxy.ts +340 -2
- package/src/http/routes/contacts-control-plane-route-match.ts +12 -0
- package/src/http/routes/ipc-runtime-proxy.test.ts +38 -43
- package/src/http/routes/ipc-runtime-proxy.ts +2 -2
- package/src/http/routes/log-export.test.ts +26 -0
- package/src/http/routes/log-export.ts +28 -7
- package/src/http/routes/log-tail.test.ts +73 -9
- package/src/http/routes/log-tail.ts +12 -3
- package/src/http/routes/pair.ts +8 -0
- package/src/http/routes/trust-rules.ts +2 -2
- package/src/http/routes/twilio-voice-webhook.ts +11 -2
- package/src/index.ts +171 -18
- package/src/ipc/assistant-client.test.ts +67 -15
- package/src/ipc/assistant-client.ts +12 -118
- package/src/ipc/risk-classification-handlers.test.ts +76 -0
- package/src/ipc/risk-classification-handlers.ts +24 -10
- package/src/logger.ts +102 -28
- package/src/post-assistant-ready.ts +9 -3
- package/src/risk/bash-risk-classifier.test.ts +49 -1
- package/src/risk/command-registry/commands/assistant.ts +105 -26
- package/src/risk/command-registry.test.ts +8 -1
- package/src/risk/file-risk-classifier.test.ts +117 -0
- package/src/risk/file-risk-classifier.ts +46 -3
- package/src/runtime/client.ts +6 -1
- package/src/schema.ts +32 -0
- package/src/slack/normalize.test.ts +51 -0
- package/src/slack/normalize.ts +140 -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/velay/allowed-paths.test.ts +69 -0
- package/src/velay/allowed-paths.ts +53 -0
- package/src/velay/bridge-utils.ts +10 -0
- package/src/velay/client.test.ts +170 -1
- package/src/velay/client.ts +110 -5
- package/src/velay/http-bridge.test.ts +29 -0
- package/src/velay/http-bridge.ts +7 -0
- package/src/velay/protocol.ts +12 -1
- 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 +214 -0
- package/src/verification/voice-approval-sync.ts +2 -2
- package/src/webhook-pipeline.ts +7 -1
|
@@ -17,6 +17,59 @@ mock.module("../fetch.js", () => ({
|
|
|
17
17
|
fetchImpl: (...args: Parameters<FetchFn>) => fetchMock(...args),
|
|
18
18
|
}));
|
|
19
19
|
|
|
20
|
+
// ── Assistant DB proxy mocks ──────────────────────────────────────────────────
|
|
21
|
+
type DbQueryFn = (sql: string, bind?: unknown[]) => Promise<Record<string, unknown>[]>;
|
|
22
|
+
let assistantDbQueryMock: ReturnType<typeof mock<DbQueryFn>> = mock(async () => []);
|
|
23
|
+
|
|
24
|
+
type DbRunFn = (sql: string, bind?: unknown[]) => Promise<void>;
|
|
25
|
+
let assistantDbRunMock: ReturnType<typeof mock<DbRunFn>> = mock(async () => {});
|
|
26
|
+
|
|
27
|
+
mock.module("../db/assistant-db-proxy.js", () => ({
|
|
28
|
+
assistantDbQuery: (...args: Parameters<DbQueryFn>) => assistantDbQueryMock(...args),
|
|
29
|
+
assistantDbRun: (...args: Parameters<DbRunFn>) => assistantDbRunMock(...args),
|
|
30
|
+
}));
|
|
31
|
+
|
|
32
|
+
// ── IPC assistant client mock ─────────────────────────────────────────────────
|
|
33
|
+
type IpcCallFn = (method: string, params: unknown) => Promise<unknown>;
|
|
34
|
+
let ipcCallAssistantMock: ReturnType<typeof mock<IpcCallFn>> = mock(async () => ({}));
|
|
35
|
+
|
|
36
|
+
mock.module("../ipc/assistant-client.js", () => ({
|
|
37
|
+
ipcCallAssistant: (...args: Parameters<IpcCallFn>) => ipcCallAssistantMock(...args),
|
|
38
|
+
}));
|
|
39
|
+
|
|
40
|
+
// ── ContactStore mock ─────────────────────────────────────────────────────────
|
|
41
|
+
// upsertContact is now async and returns a full ContactWithChannels shape; the
|
|
42
|
+
// service layer owns the assistant-DB dual-write internally.
|
|
43
|
+
const DEFAULT_MOCK_CONTACT = {
|
|
44
|
+
id: "ct_mock",
|
|
45
|
+
displayName: "Mock Contact",
|
|
46
|
+
notes: null as string | null,
|
|
47
|
+
role: "contact",
|
|
48
|
+
contactType: "human",
|
|
49
|
+
principalId: null as string | null,
|
|
50
|
+
userFile: null as string | null,
|
|
51
|
+
createdAt: 1000000,
|
|
52
|
+
updatedAt: 1000000,
|
|
53
|
+
interactionCount: 0,
|
|
54
|
+
lastInteraction: null as number | null,
|
|
55
|
+
channels: [] as unknown[],
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
type UpsertResult = { contact: typeof DEFAULT_MOCK_CONTACT; created: boolean };
|
|
59
|
+
type UpsertFn = (params: unknown) => Promise<UpsertResult>;
|
|
60
|
+
let contactStoreUpsertMock: ReturnType<typeof mock<UpsertFn>> = mock(async () => ({
|
|
61
|
+
contact: DEFAULT_MOCK_CONTACT,
|
|
62
|
+
created: false,
|
|
63
|
+
}));
|
|
64
|
+
|
|
65
|
+
mock.module("../db/contact-store.js", () => ({
|
|
66
|
+
ContactStore: class MockContactStore {
|
|
67
|
+
upsertContact(...args: Parameters<UpsertFn>) {
|
|
68
|
+
return contactStoreUpsertMock(...args);
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
}));
|
|
72
|
+
|
|
20
73
|
const { createContactsControlPlaneProxyHandler } =
|
|
21
74
|
await import("../http/routes/contacts-control-plane-proxy.js");
|
|
22
75
|
|
|
@@ -50,6 +103,13 @@ function makeConfig(overrides: Partial<GatewayConfig> = {}): GatewayConfig {
|
|
|
50
103
|
|
|
51
104
|
afterEach(() => {
|
|
52
105
|
fetchMock = mock(async () => new Response());
|
|
106
|
+
assistantDbQueryMock = mock(async () => []);
|
|
107
|
+
assistantDbRunMock = mock(async () => {});
|
|
108
|
+
ipcCallAssistantMock = mock(async () => ({}));
|
|
109
|
+
contactStoreUpsertMock = mock(async () => ({
|
|
110
|
+
contact: DEFAULT_MOCK_CONTACT,
|
|
111
|
+
created: false,
|
|
112
|
+
}));
|
|
53
113
|
});
|
|
54
114
|
|
|
55
115
|
describe("contacts control-plane proxy", () => {
|
|
@@ -68,9 +128,6 @@ describe("contacts control-plane proxy", () => {
|
|
|
68
128
|
await handler.handleListContacts(
|
|
69
129
|
new Request("http://localhost:7830/v1/contacts?limit=10"),
|
|
70
130
|
);
|
|
71
|
-
await handler.handleUpsertContact(
|
|
72
|
-
new Request("http://localhost:7830/v1/contacts", { method: "POST" }),
|
|
73
|
-
);
|
|
74
131
|
await handler.handleGetContact(
|
|
75
132
|
new Request("http://localhost:7830/v1/contacts/ct_1"),
|
|
76
133
|
"ct_1",
|
|
@@ -89,7 +146,6 @@ describe("contacts control-plane proxy", () => {
|
|
|
89
146
|
|
|
90
147
|
expect(captured).toEqual([
|
|
91
148
|
"http://localhost:7821/v1/contacts?limit=10",
|
|
92
|
-
"http://localhost:7821/v1/contacts",
|
|
93
149
|
"http://localhost:7821/v1/contacts/ct_1",
|
|
94
150
|
"http://localhost:7821/v1/contacts/merge",
|
|
95
151
|
"http://localhost:7821/v1/contact-channels/ch_1",
|
|
@@ -259,8 +315,8 @@ describe("contacts control-plane proxy", () => {
|
|
|
259
315
|
});
|
|
260
316
|
|
|
261
317
|
const handler = createContactsControlPlaneProxyHandler(makeConfig());
|
|
262
|
-
const res = await handler.
|
|
263
|
-
new Request("http://localhost:7830/v1/contacts"
|
|
318
|
+
const res = await handler.handleListContacts(
|
|
319
|
+
new Request("http://localhost:7830/v1/contacts"),
|
|
264
320
|
);
|
|
265
321
|
|
|
266
322
|
expect(res.status).toBe(200);
|
|
@@ -269,3 +325,275 @@ describe("contacts control-plane proxy", () => {
|
|
|
269
325
|
expect(res.headers.get("x-custom")).toBe("preserved");
|
|
270
326
|
});
|
|
271
327
|
});
|
|
328
|
+
|
|
329
|
+
describe("handleUpsertContact (gateway-native)", () => {
|
|
330
|
+
test("returns 400 when displayName is missing", async () => {
|
|
331
|
+
const handler = createContactsControlPlaneProxyHandler(makeConfig());
|
|
332
|
+
const res = await handler.handleUpsertContact(
|
|
333
|
+
new Request("http://localhost:7830/v1/contacts", {
|
|
334
|
+
method: "POST",
|
|
335
|
+
headers: { "content-type": "application/json" },
|
|
336
|
+
body: JSON.stringify({ contactType: "human" }),
|
|
337
|
+
}),
|
|
338
|
+
);
|
|
339
|
+
|
|
340
|
+
expect(res.status).toBe(400);
|
|
341
|
+
const body = await res.json();
|
|
342
|
+
expect(body.error.code).toBe("BAD_REQUEST");
|
|
343
|
+
expect(body.error.message).toMatch(/displayName/);
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
test("returns 400 for invalid contactType", async () => {
|
|
347
|
+
const handler = createContactsControlPlaneProxyHandler(makeConfig());
|
|
348
|
+
const res = await handler.handleUpsertContact(
|
|
349
|
+
new Request("http://localhost:7830/v1/contacts", {
|
|
350
|
+
method: "POST",
|
|
351
|
+
headers: { "content-type": "application/json" },
|
|
352
|
+
body: JSON.stringify({ displayName: "Alice", contactType: "robot" }),
|
|
353
|
+
}),
|
|
354
|
+
);
|
|
355
|
+
|
|
356
|
+
expect(res.status).toBe(400);
|
|
357
|
+
const body = await res.json();
|
|
358
|
+
expect(body.error.code).toBe("BAD_REQUEST");
|
|
359
|
+
expect(body.error.message).toMatch(/contactType/);
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
test("creates contact natively and returns contact shape", async () => {
|
|
363
|
+
const mockContact = {
|
|
364
|
+
...DEFAULT_MOCK_CONTACT,
|
|
365
|
+
id: "ct_abc123",
|
|
366
|
+
displayName: "Alice",
|
|
367
|
+
};
|
|
368
|
+
contactStoreUpsertMock = mock(async () => ({
|
|
369
|
+
contact: mockContact,
|
|
370
|
+
created: true,
|
|
371
|
+
}));
|
|
372
|
+
|
|
373
|
+
const handler = createContactsControlPlaneProxyHandler(makeConfig());
|
|
374
|
+
const res = await handler.handleUpsertContact(
|
|
375
|
+
new Request("http://localhost:7830/v1/contacts", {
|
|
376
|
+
method: "POST",
|
|
377
|
+
headers: { "content-type": "application/json" },
|
|
378
|
+
body: JSON.stringify({ displayName: "Alice" }),
|
|
379
|
+
}),
|
|
380
|
+
);
|
|
381
|
+
|
|
382
|
+
expect(res.status).toBe(200);
|
|
383
|
+
const body = await res.json();
|
|
384
|
+
expect(body.ok).toBe(true);
|
|
385
|
+
expect(body.contact.id).toBe("ct_abc123");
|
|
386
|
+
expect(body.contact.displayName).toBe("Alice");
|
|
387
|
+
expect(body.contact.channels).toEqual([]);
|
|
388
|
+
// Service layer owns the upsert + dual-write.
|
|
389
|
+
expect(contactStoreUpsertMock).toHaveBeenCalledTimes(1);
|
|
390
|
+
const [params] = contactStoreUpsertMock.mock.calls[0] as [
|
|
391
|
+
Record<string, unknown>,
|
|
392
|
+
];
|
|
393
|
+
expect(params.displayName).toBe("Alice");
|
|
394
|
+
});
|
|
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
|
+
|
|
433
|
+
test("returns 400 when body is invalid JSON", async () => {
|
|
434
|
+
const handler = createContactsControlPlaneProxyHandler(makeConfig());
|
|
435
|
+
const res = await handler.handleUpsertContact(
|
|
436
|
+
new Request("http://localhost:7830/v1/contacts", {
|
|
437
|
+
method: "POST",
|
|
438
|
+
headers: { "content-type": "application/json" },
|
|
439
|
+
body: "not-json",
|
|
440
|
+
}),
|
|
441
|
+
);
|
|
442
|
+
|
|
443
|
+
expect(res.status).toBe(400);
|
|
444
|
+
const body = await res.json();
|
|
445
|
+
expect(body.error.code).toBe("BAD_REQUEST");
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
test("returns 400 when channel.type is missing", async () => {
|
|
449
|
+
const handler = createContactsControlPlaneProxyHandler(makeConfig());
|
|
450
|
+
const res = await handler.handleUpsertContact(
|
|
451
|
+
new Request("http://localhost:7830/v1/contacts", {
|
|
452
|
+
method: "POST",
|
|
453
|
+
headers: { "content-type": "application/json" },
|
|
454
|
+
body: JSON.stringify({
|
|
455
|
+
displayName: "Alice",
|
|
456
|
+
channels: [{ address: "alice@example.com" }],
|
|
457
|
+
}),
|
|
458
|
+
}),
|
|
459
|
+
);
|
|
460
|
+
|
|
461
|
+
expect(res.status).toBe(400);
|
|
462
|
+
const body = await res.json();
|
|
463
|
+
expect(body.error.message).toMatch(/channel\.type/);
|
|
464
|
+
expect(contactStoreUpsertMock).not.toHaveBeenCalled();
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
test("returns 400 when channel.address is missing", async () => {
|
|
468
|
+
const handler = createContactsControlPlaneProxyHandler(makeConfig());
|
|
469
|
+
const res = await handler.handleUpsertContact(
|
|
470
|
+
new Request("http://localhost:7830/v1/contacts", {
|
|
471
|
+
method: "POST",
|
|
472
|
+
headers: { "content-type": "application/json" },
|
|
473
|
+
body: JSON.stringify({
|
|
474
|
+
displayName: "Alice",
|
|
475
|
+
channels: [{ type: "email" }],
|
|
476
|
+
}),
|
|
477
|
+
}),
|
|
478
|
+
);
|
|
479
|
+
|
|
480
|
+
expect(res.status).toBe(400);
|
|
481
|
+
const body = await res.json();
|
|
482
|
+
expect(body.error.message).toMatch(/channel\.address/);
|
|
483
|
+
expect(contactStoreUpsertMock).not.toHaveBeenCalled();
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
test("returns 400 when channel.address is empty/whitespace", async () => {
|
|
487
|
+
const handler = createContactsControlPlaneProxyHandler(makeConfig());
|
|
488
|
+
const res = await handler.handleUpsertContact(
|
|
489
|
+
new Request("http://localhost:7830/v1/contacts", {
|
|
490
|
+
method: "POST",
|
|
491
|
+
headers: { "content-type": "application/json" },
|
|
492
|
+
body: JSON.stringify({
|
|
493
|
+
displayName: "Alice",
|
|
494
|
+
channels: [{ type: "email", address: " " }],
|
|
495
|
+
}),
|
|
496
|
+
}),
|
|
497
|
+
);
|
|
498
|
+
|
|
499
|
+
expect(res.status).toBe(400);
|
|
500
|
+
const body = await res.json();
|
|
501
|
+
expect(body.error.message).toMatch(/channel\.address/);
|
|
502
|
+
expect(contactStoreUpsertMock).not.toHaveBeenCalled();
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
test("rejects unsupported species (e.g. openclaw)", async () => {
|
|
506
|
+
const handler = createContactsControlPlaneProxyHandler(makeConfig());
|
|
507
|
+
const res = await handler.handleUpsertContact(
|
|
508
|
+
new Request("http://localhost:7830/v1/contacts", {
|
|
509
|
+
method: "POST",
|
|
510
|
+
headers: { "content-type": "application/json" },
|
|
511
|
+
body: JSON.stringify({
|
|
512
|
+
displayName: "Some Bot",
|
|
513
|
+
contactType: "assistant",
|
|
514
|
+
assistantMetadata: { species: "openclaw", metadata: {} },
|
|
515
|
+
}),
|
|
516
|
+
}),
|
|
517
|
+
);
|
|
518
|
+
|
|
519
|
+
expect(res.status).toBe(400);
|
|
520
|
+
const body = await res.json();
|
|
521
|
+
expect(body.error.message).toMatch(/species/);
|
|
522
|
+
expect(contactStoreUpsertMock).not.toHaveBeenCalled();
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
test("rejects vellum metadata missing assistantId", async () => {
|
|
526
|
+
const handler = createContactsControlPlaneProxyHandler(makeConfig());
|
|
527
|
+
const res = await handler.handleUpsertContact(
|
|
528
|
+
new Request("http://localhost:7830/v1/contacts", {
|
|
529
|
+
method: "POST",
|
|
530
|
+
headers: { "content-type": "application/json" },
|
|
531
|
+
body: JSON.stringify({
|
|
532
|
+
displayName: "Vellum Bot",
|
|
533
|
+
contactType: "assistant",
|
|
534
|
+
assistantMetadata: {
|
|
535
|
+
species: "vellum",
|
|
536
|
+
metadata: { gatewayUrl: "https://x.example" },
|
|
537
|
+
},
|
|
538
|
+
}),
|
|
539
|
+
}),
|
|
540
|
+
);
|
|
541
|
+
|
|
542
|
+
expect(res.status).toBe(400);
|
|
543
|
+
const body = await res.json();
|
|
544
|
+
expect(body.error.message).toMatch(/assistantId/);
|
|
545
|
+
expect(contactStoreUpsertMock).not.toHaveBeenCalled();
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
test("rejects vellum metadata missing gatewayUrl", async () => {
|
|
549
|
+
const handler = createContactsControlPlaneProxyHandler(makeConfig());
|
|
550
|
+
const res = await handler.handleUpsertContact(
|
|
551
|
+
new Request("http://localhost:7830/v1/contacts", {
|
|
552
|
+
method: "POST",
|
|
553
|
+
headers: { "content-type": "application/json" },
|
|
554
|
+
body: JSON.stringify({
|
|
555
|
+
displayName: "Vellum Bot",
|
|
556
|
+
contactType: "assistant",
|
|
557
|
+
assistantMetadata: {
|
|
558
|
+
species: "vellum",
|
|
559
|
+
metadata: { assistantId: "asst_123" },
|
|
560
|
+
},
|
|
561
|
+
}),
|
|
562
|
+
}),
|
|
563
|
+
);
|
|
564
|
+
|
|
565
|
+
expect(res.status).toBe(400);
|
|
566
|
+
const body = await res.json();
|
|
567
|
+
expect(body.error.message).toMatch(/gatewayUrl/);
|
|
568
|
+
expect(contactStoreUpsertMock).not.toHaveBeenCalled();
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
test("accepts vellum assistant with full metadata", async () => {
|
|
572
|
+
const handler = createContactsControlPlaneProxyHandler(makeConfig());
|
|
573
|
+
const res = await handler.handleUpsertContact(
|
|
574
|
+
new Request("http://localhost:7830/v1/contacts", {
|
|
575
|
+
method: "POST",
|
|
576
|
+
headers: { "content-type": "application/json" },
|
|
577
|
+
body: JSON.stringify({
|
|
578
|
+
displayName: "Vellum Bot",
|
|
579
|
+
contactType: "assistant",
|
|
580
|
+
assistantMetadata: {
|
|
581
|
+
species: "vellum",
|
|
582
|
+
metadata: {
|
|
583
|
+
assistantId: "asst_123",
|
|
584
|
+
gatewayUrl: "https://gw.example.com",
|
|
585
|
+
},
|
|
586
|
+
},
|
|
587
|
+
}),
|
|
588
|
+
}),
|
|
589
|
+
);
|
|
590
|
+
|
|
591
|
+
expect(res.status).toBe(200);
|
|
592
|
+
expect(contactStoreUpsertMock).toHaveBeenCalledTimes(1);
|
|
593
|
+
const [params] = contactStoreUpsertMock.mock.calls[0] as [
|
|
594
|
+
{ assistantMetadata?: { species: string; metadata?: Record<string, unknown> } },
|
|
595
|
+
];
|
|
596
|
+
expect(params.assistantMetadata?.species).toBe("vellum");
|
|
597
|
+
expect(params.assistantMetadata?.metadata?.assistantId).toBe("asst_123");
|
|
598
|
+
});
|
|
599
|
+
});
|
|
@@ -15,6 +15,15 @@ describe("matchContactsControlPlaneRoute", () => {
|
|
|
15
15
|
expect(
|
|
16
16
|
matchContactsControlPlaneRoute("/v1/contact-channels/ch_1", "PATCH"),
|
|
17
17
|
).toEqual({ kind: "updateContactChannel", contactChannelId: "ch_1" });
|
|
18
|
+
expect(
|
|
19
|
+
matchContactsControlPlaneRoute(
|
|
20
|
+
"/v1/contact-channels/ch_1/verify",
|
|
21
|
+
"POST",
|
|
22
|
+
),
|
|
23
|
+
).toEqual({
|
|
24
|
+
kind: "verifyContactChannel",
|
|
25
|
+
contactChannelId: "ch_1",
|
|
26
|
+
});
|
|
18
27
|
expect(matchContactsControlPlaneRoute("/v1/contacts/ct_1", "GET")).toEqual({
|
|
19
28
|
kind: "getContact",
|
|
20
29
|
contactId: "ct_1",
|
|
@@ -27,6 +36,13 @@ describe("matchContactsControlPlaneRoute", () => {
|
|
|
27
36
|
expect(
|
|
28
37
|
matchContactsControlPlaneRoute("/v1/contact-channels/ch_1", "GET"),
|
|
29
38
|
).toBeNull();
|
|
39
|
+
// PATCH on verify subpath does not match (POST only)
|
|
40
|
+
expect(
|
|
41
|
+
matchContactsControlPlaneRoute(
|
|
42
|
+
"/v1/contact-channels/ch_1/verify",
|
|
43
|
+
"PATCH",
|
|
44
|
+
),
|
|
45
|
+
).toBeNull();
|
|
30
46
|
});
|
|
31
47
|
|
|
32
48
|
test("GET /v1/contacts/merge falls through to getContact", () => {
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for `requireEdgeAuth` and `requireEdgeAuthWithScope` — the
|
|
3
|
+
* client-facing edge guards.
|
|
4
|
+
*
|
|
5
|
+
* Two auth modes (mirrors `requireEdgeGuardianAuth`):
|
|
6
|
+
*
|
|
7
|
+
* 1. Platform-managed (DISABLE_HTTP_AUTH=true + IS_PLATFORM=true): identity
|
|
8
|
+
* asserted via `X-Vellum-User-Id` header cross-referenced against the
|
|
9
|
+
* stored `vellum:platform_user_id` credential. Scope authorization is
|
|
10
|
+
* delegated to the upstream platform proxy.
|
|
11
|
+
* 2. Default: edge JWT validated; scoped guard additionally checks the
|
|
12
|
+
* scope_profile claim.
|
|
13
|
+
*
|
|
14
|
+
* Importantly, DISABLE_HTTP_AUTH alone (without IS_PLATFORM) does NOT
|
|
15
|
+
* bypass JWT validation — protects against accidental misconfig.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
|
|
19
|
+
|
|
20
|
+
import "./test-preload.js";
|
|
21
|
+
|
|
22
|
+
// --- Mocks (set BEFORE importing the module under test) -------------------
|
|
23
|
+
|
|
24
|
+
let mockReadCredential = mock(
|
|
25
|
+
async (_key: string): Promise<string | undefined> => undefined,
|
|
26
|
+
);
|
|
27
|
+
mock.module("../credential-reader.js", () => ({
|
|
28
|
+
readCredential: (key: string) => mockReadCredential(key),
|
|
29
|
+
}));
|
|
30
|
+
|
|
31
|
+
let mockValidateEdgeToken = mock(
|
|
32
|
+
(
|
|
33
|
+
_token: string,
|
|
34
|
+
):
|
|
35
|
+
| { ok: true; claims: { sub: string; scope_profile: string } }
|
|
36
|
+
| { ok: false; reason: string } => ({
|
|
37
|
+
ok: false,
|
|
38
|
+
reason: "noop",
|
|
39
|
+
}),
|
|
40
|
+
);
|
|
41
|
+
mock.module("../auth/token-exchange.js", () => ({
|
|
42
|
+
validateEdgeToken: (token: string) => mockValidateEdgeToken(token),
|
|
43
|
+
}));
|
|
44
|
+
|
|
45
|
+
const { AuthRateLimiter } = await import("../auth-rate-limiter.js");
|
|
46
|
+
const { createAuthMiddleware } = await import("../http/middleware/auth.js");
|
|
47
|
+
|
|
48
|
+
const PLATFORM_USER_ID = "user-abc-123";
|
|
49
|
+
|
|
50
|
+
function makeMiddleware() {
|
|
51
|
+
const rl = new AuthRateLimiter();
|
|
52
|
+
return createAuthMiddleware(rl, () => "1.2.3.4");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function makeReq(headers: Record<string, string> = {}): Request {
|
|
56
|
+
return new Request("http://gateway.local/v1/something", {
|
|
57
|
+
method: "POST",
|
|
58
|
+
headers,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
beforeEach(() => {
|
|
63
|
+
mockReadCredential = mock(async () => undefined);
|
|
64
|
+
mockValidateEdgeToken = mock(() => ({ ok: false, reason: "noop" }));
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
afterEach(() => {
|
|
68
|
+
delete process.env.DISABLE_HTTP_AUTH;
|
|
69
|
+
delete process.env.IS_PLATFORM;
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// =========================================================================
|
|
73
|
+
// requireEdgeAuth — platform bypass active
|
|
74
|
+
// =========================================================================
|
|
75
|
+
|
|
76
|
+
describe("requireEdgeAuth — DISABLE_HTTP_AUTH + IS_PLATFORM", () => {
|
|
77
|
+
beforeEach(() => {
|
|
78
|
+
process.env.DISABLE_HTTP_AUTH = "true";
|
|
79
|
+
process.env.IS_PLATFORM = "true";
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("401 when X-Vellum-User-Id header is missing", async () => {
|
|
83
|
+
const { requireEdgeAuth } = makeMiddleware();
|
|
84
|
+
const res = await requireEdgeAuth(makeReq());
|
|
85
|
+
expect(res?.status).toBe(401);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("403 when no platform_user_id is stored", async () => {
|
|
89
|
+
mockReadCredential = mock(async () => undefined);
|
|
90
|
+
const { requireEdgeAuth } = makeMiddleware();
|
|
91
|
+
const res = await requireEdgeAuth(
|
|
92
|
+
makeReq({ "x-vellum-user-id": PLATFORM_USER_ID }),
|
|
93
|
+
);
|
|
94
|
+
expect(res?.status).toBe(403);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("403 when X-Vellum-User-Id does not match stored credential", async () => {
|
|
98
|
+
mockReadCredential = mock(async () => PLATFORM_USER_ID);
|
|
99
|
+
const { requireEdgeAuth } = makeMiddleware();
|
|
100
|
+
const res = await requireEdgeAuth(
|
|
101
|
+
makeReq({ "x-vellum-user-id": "different-user" }),
|
|
102
|
+
);
|
|
103
|
+
expect(res?.status).toBe(403);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("503 when readCredential throws", async () => {
|
|
107
|
+
mockReadCredential = mock(async () => {
|
|
108
|
+
throw new Error("cred store unavailable");
|
|
109
|
+
});
|
|
110
|
+
const { requireEdgeAuth } = makeMiddleware();
|
|
111
|
+
const res = await requireEdgeAuth(
|
|
112
|
+
makeReq({ "x-vellum-user-id": PLATFORM_USER_ID }),
|
|
113
|
+
);
|
|
114
|
+
expect(res?.status).toBe(503);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("null (auth ok) when X-Vellum-User-Id matches stored credential", async () => {
|
|
118
|
+
mockReadCredential = mock(async () => PLATFORM_USER_ID);
|
|
119
|
+
const { requireEdgeAuth } = makeMiddleware();
|
|
120
|
+
const res = await requireEdgeAuth(
|
|
121
|
+
makeReq({ "x-vellum-user-id": PLATFORM_USER_ID }),
|
|
122
|
+
);
|
|
123
|
+
expect(res).toBeNull();
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// =========================================================================
|
|
128
|
+
// requireEdgeAuth — accidental misconfig (only one flag set)
|
|
129
|
+
// =========================================================================
|
|
130
|
+
|
|
131
|
+
describe("requireEdgeAuth — DISABLE_HTTP_AUTH alone is insufficient", () => {
|
|
132
|
+
test("DISABLE_HTTP_AUTH=true without IS_PLATFORM still runs JWT validation", async () => {
|
|
133
|
+
process.env.DISABLE_HTTP_AUTH = "true";
|
|
134
|
+
// IS_PLATFORM intentionally NOT set
|
|
135
|
+
const { requireEdgeAuth } = makeMiddleware();
|
|
136
|
+
const res = await requireEdgeAuth(makeReq());
|
|
137
|
+
// No bearer token + no bypass → 401, NOT a free pass
|
|
138
|
+
expect(res?.status).toBe(401);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("IS_PLATFORM=true without DISABLE_HTTP_AUTH still runs JWT validation", async () => {
|
|
142
|
+
process.env.IS_PLATFORM = "true";
|
|
143
|
+
// DISABLE_HTTP_AUTH intentionally NOT set
|
|
144
|
+
const { requireEdgeAuth } = makeMiddleware();
|
|
145
|
+
const res = await requireEdgeAuth(makeReq());
|
|
146
|
+
expect(res?.status).toBe(401);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("both flags unset → JWT validation runs", async () => {
|
|
150
|
+
const { requireEdgeAuth } = makeMiddleware();
|
|
151
|
+
const res = await requireEdgeAuth(makeReq());
|
|
152
|
+
expect(res?.status).toBe(401);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
// =========================================================================
|
|
157
|
+
// requireEdgeAuth — default (JWT) mode
|
|
158
|
+
// =========================================================================
|
|
159
|
+
|
|
160
|
+
describe("requireEdgeAuth — JWT mode", () => {
|
|
161
|
+
test("null on valid bearer token", async () => {
|
|
162
|
+
mockValidateEdgeToken = mock(() => ({
|
|
163
|
+
ok: true,
|
|
164
|
+
claims: { sub: "actor:asst:123", scope_profile: "actor_client_v1" },
|
|
165
|
+
}));
|
|
166
|
+
const { requireEdgeAuth } = makeMiddleware();
|
|
167
|
+
const res = await requireEdgeAuth(
|
|
168
|
+
makeReq({ authorization: "Bearer good.jwt.here" }),
|
|
169
|
+
);
|
|
170
|
+
expect(res).toBeNull();
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("401 on invalid bearer token", async () => {
|
|
174
|
+
mockValidateEdgeToken = mock(() => ({ ok: false, reason: "expired" }));
|
|
175
|
+
const { requireEdgeAuth } = makeMiddleware();
|
|
176
|
+
const res = await requireEdgeAuth(
|
|
177
|
+
makeReq({ authorization: "Bearer bad.jwt.here" }),
|
|
178
|
+
);
|
|
179
|
+
expect(res?.status).toBe(401);
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
// =========================================================================
|
|
184
|
+
// requireEdgeAuthWithScope — same bypass model + scope check on JWT path
|
|
185
|
+
// =========================================================================
|
|
186
|
+
|
|
187
|
+
describe("requireEdgeAuthWithScope — DISABLE_HTTP_AUTH + IS_PLATFORM", () => {
|
|
188
|
+
beforeEach(() => {
|
|
189
|
+
process.env.DISABLE_HTTP_AUTH = "true";
|
|
190
|
+
process.env.IS_PLATFORM = "true";
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("uses platform header check; no scope check on bypass path", async () => {
|
|
194
|
+
// Even with a scope profile that wouldn't grant the required scope under
|
|
195
|
+
// JWT mode, the bypass path only cross-checks the user header. Scope is
|
|
196
|
+
// enforced upstream by the platform proxy.
|
|
197
|
+
mockReadCredential = mock(async () => PLATFORM_USER_ID);
|
|
198
|
+
const { requireEdgeAuthWithScope } = makeMiddleware();
|
|
199
|
+
const res = await requireEdgeAuthWithScope(
|
|
200
|
+
makeReq({ "x-vellum-user-id": PLATFORM_USER_ID }),
|
|
201
|
+
// any scope — bypass path does not look at it
|
|
202
|
+
"ingress.write",
|
|
203
|
+
);
|
|
204
|
+
expect(res).toBeNull();
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test("401 when X-Vellum-User-Id missing under bypass", async () => {
|
|
208
|
+
const { requireEdgeAuthWithScope } = makeMiddleware();
|
|
209
|
+
const res = await requireEdgeAuthWithScope(makeReq(), "ingress.write");
|
|
210
|
+
expect(res?.status).toBe(401);
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
describe("requireEdgeAuthWithScope — JWT mode", () => {
|
|
215
|
+
test("403 when token's scope_profile lacks the required scope", async () => {
|
|
216
|
+
// actor_client_v1 grants chat.* and settings.*, but NOT ingress.write
|
|
217
|
+
mockValidateEdgeToken = mock(() => ({
|
|
218
|
+
ok: true,
|
|
219
|
+
claims: {
|
|
220
|
+
sub: "actor:asst:123",
|
|
221
|
+
scope_profile: "actor_client_v1",
|
|
222
|
+
},
|
|
223
|
+
}));
|
|
224
|
+
const { requireEdgeAuthWithScope } = makeMiddleware();
|
|
225
|
+
const res = await requireEdgeAuthWithScope(
|
|
226
|
+
makeReq({ authorization: "Bearer good.jwt.here" }),
|
|
227
|
+
"ingress.write",
|
|
228
|
+
);
|
|
229
|
+
expect(res?.status).toBe(403);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test("null when token's scope_profile contains the required scope", async () => {
|
|
233
|
+
mockValidateEdgeToken = mock(() => ({
|
|
234
|
+
ok: true,
|
|
235
|
+
claims: {
|
|
236
|
+
sub: "actor:asst:123",
|
|
237
|
+
scope_profile: "actor_client_v1",
|
|
238
|
+
},
|
|
239
|
+
}));
|
|
240
|
+
const { requireEdgeAuthWithScope } = makeMiddleware();
|
|
241
|
+
const res = await requireEdgeAuthWithScope(
|
|
242
|
+
makeReq({ authorization: "Bearer good.jwt.here" }),
|
|
243
|
+
"chat.write",
|
|
244
|
+
);
|
|
245
|
+
expect(res).toBeNull();
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test("401 when bearer token missing", async () => {
|
|
249
|
+
const { requireEdgeAuthWithScope } = makeMiddleware();
|
|
250
|
+
const res = await requireEdgeAuthWithScope(makeReq(), "chat.write");
|
|
251
|
+
expect(res?.status).toBe(401);
|
|
252
|
+
});
|
|
253
|
+
});
|