@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.
Files changed (78) hide show
  1. package/AGENTS.md +10 -0
  2. package/ARCHITECTURE.md +6 -5
  3. package/Dockerfile +2 -1
  4. package/README.md +4 -3
  5. package/package.json +1 -1
  6. package/src/__tests__/contact-prompt-submit.test.ts +5 -5
  7. package/src/__tests__/contact-store-mark-channel-verified.test.ts +390 -0
  8. package/src/__tests__/contacts-control-plane-proxy.test.ts +334 -6
  9. package/src/__tests__/contacts-control-plane-route-match.test.ts +16 -0
  10. package/src/__tests__/edge-auth.test.ts +253 -0
  11. package/src/__tests__/edge-guardian-auth.test.ts +198 -0
  12. package/src/__tests__/guardian-binding-channel-reuse.test.ts +275 -0
  13. package/src/__tests__/ipc-route-policy.test.ts +117 -0
  14. package/src/__tests__/live-voice-websocket.test.ts +1 -1
  15. package/src/__tests__/logger-retention.test.ts +115 -0
  16. package/src/__tests__/nonbash-trust-rule-overrides.test.ts +1 -0
  17. package/src/__tests__/slack-display-name.test.ts +70 -0
  18. package/src/__tests__/slack-normalize.test.ts +132 -0
  19. package/src/__tests__/slack-socket-mode-scopes.test.ts +27 -4
  20. package/src/__tests__/slack-socket-mode-thread-tracking.test.ts +105 -1
  21. package/src/__tests__/trust-rule-store.test.ts +49 -0
  22. package/src/__tests__/upsert-verified-contact-channel.test.ts +49 -6
  23. package/src/auth/guardian-bootstrap.ts +64 -12
  24. package/src/auth/ipc-route-policy.ts +124 -1
  25. package/src/db/assistant-db-proxy.ts +76 -7
  26. package/src/db/contact-store.ts +893 -1
  27. package/src/db/schema.ts +29 -0
  28. package/src/db/trust-rule-store.ts +22 -17
  29. package/src/feature-flag-registry.json +57 -1
  30. package/src/handlers/handle-inbound.ts +8 -11
  31. package/src/http/middleware/auth.ts +193 -40
  32. package/src/http/router.ts +26 -4
  33. package/src/http/routes/channel-verification-session-proxy.ts +53 -6
  34. package/src/http/routes/contact-prompt.ts +44 -15
  35. package/src/http/routes/contacts-control-plane-proxy.ts +340 -2
  36. package/src/http/routes/contacts-control-plane-route-match.ts +12 -0
  37. package/src/http/routes/ipc-runtime-proxy.test.ts +38 -43
  38. package/src/http/routes/ipc-runtime-proxy.ts +2 -2
  39. package/src/http/routes/log-export.test.ts +26 -0
  40. package/src/http/routes/log-export.ts +28 -7
  41. package/src/http/routes/log-tail.test.ts +73 -9
  42. package/src/http/routes/log-tail.ts +12 -3
  43. package/src/http/routes/pair.ts +8 -0
  44. package/src/http/routes/trust-rules.ts +2 -2
  45. package/src/http/routes/twilio-voice-webhook.ts +11 -2
  46. package/src/index.ts +171 -18
  47. package/src/ipc/assistant-client.test.ts +67 -15
  48. package/src/ipc/assistant-client.ts +12 -118
  49. package/src/ipc/risk-classification-handlers.test.ts +76 -0
  50. package/src/ipc/risk-classification-handlers.ts +24 -10
  51. package/src/logger.ts +102 -28
  52. package/src/post-assistant-ready.ts +9 -3
  53. package/src/risk/bash-risk-classifier.test.ts +49 -1
  54. package/src/risk/command-registry/commands/assistant.ts +105 -26
  55. package/src/risk/command-registry.test.ts +8 -1
  56. package/src/risk/file-risk-classifier.test.ts +117 -0
  57. package/src/risk/file-risk-classifier.ts +46 -3
  58. package/src/runtime/client.ts +6 -1
  59. package/src/schema.ts +32 -0
  60. package/src/slack/normalize.test.ts +51 -0
  61. package/src/slack/normalize.ts +140 -29
  62. package/src/slack/socket-mode.ts +62 -8
  63. package/src/twilio/setup-state.test.ts +49 -0
  64. package/src/twilio/setup-state.ts +35 -0
  65. package/src/velay/allowed-paths.test.ts +69 -0
  66. package/src/velay/allowed-paths.ts +53 -0
  67. package/src/velay/bridge-utils.ts +10 -0
  68. package/src/velay/client.test.ts +170 -1
  69. package/src/velay/client.ts +110 -5
  70. package/src/velay/http-bridge.test.ts +29 -0
  71. package/src/velay/http-bridge.ts +7 -0
  72. package/src/velay/protocol.ts +12 -1
  73. package/src/verification/binding-helpers.ts +31 -0
  74. package/src/verification/contact-helpers.ts +83 -24
  75. package/src/verification/outbound-voice-verification-sync.test.ts +453 -0
  76. package/src/verification/outbound-voice-verification-sync.ts +214 -0
  77. package/src/verification/voice-approval-sync.ts +2 -2
  78. 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.handleUpsertContact(
263
- new Request("http://localhost:7830/v1/contacts", { method: "POST" }),
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
+ });