@vellumai/vellum-gateway 0.6.2 → 0.6.4

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 (67) hide show
  1. package/ARCHITECTURE.md +111 -28
  2. package/Dockerfile +2 -2
  3. package/bun.lock +12 -11
  4. package/bunfig.toml +6 -0
  5. package/package.json +13 -12
  6. package/src/__tests__/browser-relay-websocket.test.ts +391 -5
  7. package/src/__tests__/config-file-cache.test.ts +6 -6
  8. package/src/__tests__/config.test.ts +1 -1
  9. package/src/__tests__/credential-reader.test.ts +12 -12
  10. package/src/__tests__/credential-watcher-managed-bootstrap.test.ts +81 -15
  11. package/src/__tests__/credential-watcher.test.ts +3 -2
  12. package/src/__tests__/feature-flags-route.test.ts +5 -5
  13. package/src/__tests__/ipc-contact-routes.test.ts +302 -0
  14. package/src/__tests__/ipc-feature-flag-routes.test.ts +284 -0
  15. package/src/__tests__/mock-fetch.ts +87 -0
  16. package/src/__tests__/privacy-config-route.test.ts +911 -0
  17. package/src/__tests__/remote-feature-flag-sync.test.ts +70 -6
  18. package/src/__tests__/runtime-proxy.test.ts +114 -0
  19. package/src/__tests__/schema.test.ts +2 -0
  20. package/src/__tests__/slack-deliver.test.ts +287 -1
  21. package/src/__tests__/slack-errors.test.ts +14 -0
  22. package/src/__tests__/stt-stream-websocket.test.ts +392 -0
  23. package/src/__tests__/test-preload.ts +28 -0
  24. package/src/__tests__/twilio-media-websocket.test.ts +618 -0
  25. package/src/auth/token-exchange.ts +0 -20
  26. package/src/auth/token-service.ts +4 -9
  27. package/src/avatar-sync/avatar-channel-syncer.ts +78 -0
  28. package/src/avatar-sync/avatar-sync-watcher.ts +80 -0
  29. package/src/avatar-sync/slack-avatar-syncer.ts +70 -0
  30. package/src/avatar-sync/types.ts +16 -0
  31. package/src/channels/transport-hints.ts +36 -3
  32. package/src/cli/enable-proxy.ts +3 -6
  33. package/src/config.ts +3 -18
  34. package/src/credential-reader.ts +16 -22
  35. package/src/credential-watcher.ts +3 -3
  36. package/src/db/connection.ts +97 -6
  37. package/src/db/contact-store.ts +156 -0
  38. package/src/db/data-migrations/index.ts +73 -0
  39. package/src/db/data-migrations/m0001-guardian-init-lock.ts +62 -0
  40. package/src/email/register-callback.test.ts +253 -0
  41. package/src/email/register-callback.ts +142 -0
  42. package/src/feature-flag-registry.json +85 -14
  43. package/src/feature-flag-remote-store.ts +4 -9
  44. package/src/feature-flag-store.ts +4 -9
  45. package/src/http/middleware/cors.ts +90 -0
  46. package/src/http/middleware/rate-limit.ts +1 -3
  47. package/src/http/routes/browser-relay-websocket.ts +152 -16
  48. package/src/http/routes/channel-verification-session-proxy.ts +2 -2
  49. package/src/http/routes/email-webhook.test.ts +7 -7
  50. package/src/http/routes/email-webhook.ts +21 -7
  51. package/src/http/routes/log-export.test.ts +0 -1
  52. package/src/http/routes/privacy-config.ts +217 -8
  53. package/src/http/routes/slack-deliver.ts +147 -24
  54. package/src/http/routes/stt-stream-websocket.ts +277 -0
  55. package/src/http/routes/twilio-media-websocket.ts +271 -0
  56. package/src/index.ts +315 -26
  57. package/src/ipc/contact-handlers.ts +65 -0
  58. package/src/ipc/feature-flag-handlers.ts +61 -0
  59. package/src/ipc/server.ts +272 -0
  60. package/src/logger.ts +1 -1
  61. package/src/paths.ts +83 -0
  62. package/src/platform-url.ts +24 -0
  63. package/src/remote-feature-flag-sync.ts +36 -1
  64. package/src/schema.ts +260 -39
  65. package/src/slack/errors.ts +10 -0
  66. package/src/telegram/webhook-manager.ts +18 -0
  67. package/src/trust-store.ts +2 -6
@@ -3,6 +3,7 @@ import type { GatewayConfig } from "../config.js";
3
3
  import { initSigningKey, mintToken } from "../auth/token-service.js";
4
4
  import { CURRENT_POLICY_EPOCH } from "../auth/policy.js";
5
5
  import {
6
+ checkBrowserRelayAuth,
6
7
  createBrowserRelayWebsocketHandler,
7
8
  getBrowserRelayWebsocketHandlers,
8
9
  isLoopbackPeer,
@@ -11,17 +12,34 @@ import {
11
12
  const TEST_SIGNING_KEY = Buffer.from("test-signing-key-at-least-32-bytes-long");
12
13
  initSigningKey(TEST_SIGNING_KEY);
13
14
 
14
- /** Mint a valid edge JWT for browser relay auth. */
15
- function mintEdgeToken(): string {
15
+ const TEST_ACTOR_PRINCIPAL = "guardian-actor-123";
16
+
17
+ /** Mint a valid actor edge JWT for browser relay auth. */
18
+ function mintEdgeToken(actorPrincipalId: string = "test-user"): string {
16
19
  return mintToken({
17
20
  aud: "vellum-gateway",
18
- sub: "actor:test-assistant:test-user",
21
+ sub: `actor:test-assistant:${actorPrincipalId}`,
19
22
  scope_profile: "actor_client_v1",
20
23
  policy_epoch: CURRENT_POLICY_EPOCH,
21
24
  ttlSeconds: 300,
22
25
  });
23
26
  }
24
27
 
28
+ /**
29
+ * Mint a service-style browser-relay edge token (svc:browser-relay:self).
30
+ * This token is valid for the gateway audience but carries no actor
31
+ * principal in its sub claim.
32
+ */
33
+ function mintServiceEdgeToken(): string {
34
+ return mintToken({
35
+ aud: "vellum-gateway",
36
+ sub: "svc:browser-relay:self",
37
+ scope_profile: "gateway_service_v1",
38
+ policy_epoch: CURRENT_POLICY_EPOCH,
39
+ ttlSeconds: 300,
40
+ });
41
+ }
42
+
25
43
  const WS_CONNECTING = WebSocket.CONNECTING; // 0
26
44
  const WS_OPEN = WebSocket.OPEN; // 1
27
45
  const WS_CLOSED = WebSocket.CLOSED; // 3
@@ -257,6 +275,7 @@ describe("getBrowserRelayWebsocketHandlers", () => {
257
275
  config: makeConfig({
258
276
  assistantRuntimeBaseUrl: "http://runtime.internal:7821",
259
277
  }),
278
+ auth: { authenticated: false, authBypassed: true },
260
279
  });
261
280
 
262
281
  handlers.open(ws as never);
@@ -275,11 +294,378 @@ describe("getBrowserRelayWebsocketHandlers", () => {
275
294
  fakeUpstream.emit("message", { data: "runtime-message" });
276
295
  expect(ws.sent).toEqual(["runtime-message"]);
277
296
  });
297
+
298
+ test("open appends guardianId query param when auth context carries one", () => {
299
+ const ws = createFakeDownstreamWs({
300
+ wsType: "browser-relay",
301
+ config: makeConfig({
302
+ assistantRuntimeBaseUrl: "http://runtime.internal:7821",
303
+ }),
304
+ auth: {
305
+ authenticated: true,
306
+ authBypassed: false,
307
+ guardianId: TEST_ACTOR_PRINCIPAL,
308
+ },
309
+ });
310
+
311
+ handlers.open(ws as never);
312
+
313
+ const MockWS = globalThis.WebSocket as unknown as ReturnType<typeof mock>;
314
+ const calledUrl = (MockWS.mock.calls[0] as unknown[])[0] as string;
315
+ const parsed = new URL(calledUrl);
316
+ expect(parsed.protocol).toBe("ws:");
317
+ expect(parsed.host).toBe("runtime.internal:7821");
318
+ expect(parsed.pathname).toBe("/v1/browser-relay");
319
+ expect(parsed.searchParams.get("token")).toMatch(/^ey/);
320
+ expect(parsed.searchParams.get("guardianId")).toBe(TEST_ACTOR_PRINCIPAL);
321
+ });
322
+
323
+ test("open omits guardianId query param when auth context has none (auth-bypass context)", () => {
324
+ const ws = createFakeDownstreamWs({
325
+ wsType: "browser-relay",
326
+ config: makeConfig({
327
+ assistantRuntimeBaseUrl: "http://runtime.internal:7821",
328
+ }),
329
+ auth: { authenticated: true, authBypassed: false },
330
+ });
331
+
332
+ handlers.open(ws as never);
333
+
334
+ const MockWS = globalThis.WebSocket as unknown as ReturnType<typeof mock>;
335
+ const calledUrl = (MockWS.mock.calls[0] as unknown[])[0] as string;
336
+ const parsed = new URL(calledUrl);
337
+ // Auth-bypass contexts may omit guardianId; open() should not
338
+ // synthesize one.
339
+ expect(parsed.searchParams.has("guardianId")).toBe(false);
340
+ });
341
+
342
+ test("open appends clientInstanceId query param when auth context carries one", () => {
343
+ const ws = createFakeDownstreamWs({
344
+ wsType: "browser-relay",
345
+ config: makeConfig({
346
+ assistantRuntimeBaseUrl: "http://runtime.internal:7821",
347
+ }),
348
+ auth: {
349
+ authenticated: true,
350
+ authBypassed: false,
351
+ guardianId: TEST_ACTOR_PRINCIPAL,
352
+ clientInstanceId: "install-ABC-123",
353
+ },
354
+ });
355
+
356
+ handlers.open(ws as never);
357
+
358
+ const MockWS = globalThis.WebSocket as unknown as ReturnType<typeof mock>;
359
+ const calledUrl = (MockWS.mock.calls[0] as unknown[])[0] as string;
360
+ const parsed = new URL(calledUrl);
361
+ expect(parsed.pathname).toBe("/v1/browser-relay");
362
+ // Both guardianId and clientInstanceId should ride alongside the
363
+ // upstream service token, matching the runtime's accepted query
364
+ // param names.
365
+ expect(parsed.searchParams.get("guardianId")).toBe(TEST_ACTOR_PRINCIPAL);
366
+ expect(parsed.searchParams.get("clientInstanceId")).toBe("install-ABC-123");
367
+ });
368
+
369
+ test("open forwards clientInstanceId even without a guardianId", () => {
370
+ // Auth-bypass paths can still carry a
371
+ // clientInstanceId lifted from the downstream handshake. The
372
+ // gateway must propagate it so the runtime's multi-instance
373
+ // registry keys the connection correctly.
374
+ const ws = createFakeDownstreamWs({
375
+ wsType: "browser-relay",
376
+ config: makeConfig({
377
+ assistantRuntimeBaseUrl: "http://runtime.internal:7821",
378
+ }),
379
+ auth: {
380
+ authenticated: false,
381
+ authBypassed: true,
382
+ clientInstanceId: "install-XYZ-789",
383
+ },
384
+ });
385
+
386
+ handlers.open(ws as never);
387
+
388
+ const MockWS = globalThis.WebSocket as unknown as ReturnType<typeof mock>;
389
+ const calledUrl = (MockWS.mock.calls[0] as unknown[])[0] as string;
390
+ const parsed = new URL(calledUrl);
391
+ expect(parsed.searchParams.has("guardianId")).toBe(false);
392
+ expect(parsed.searchParams.get("clientInstanceId")).toBe("install-XYZ-789");
393
+ });
394
+
395
+ test("open omits clientInstanceId query param when auth context has none", () => {
396
+ const ws = createFakeDownstreamWs({
397
+ wsType: "browser-relay",
398
+ config: makeConfig({
399
+ assistantRuntimeBaseUrl: "http://runtime.internal:7821",
400
+ }),
401
+ auth: {
402
+ authenticated: true,
403
+ authBypassed: false,
404
+ guardianId: TEST_ACTOR_PRINCIPAL,
405
+ },
406
+ });
407
+
408
+ handlers.open(ws as never);
409
+
410
+ const MockWS = globalThis.WebSocket as unknown as ReturnType<typeof mock>;
411
+ const calledUrl = (MockWS.mock.calls[0] as unknown[])[0] as string;
412
+ const parsed = new URL(calledUrl);
413
+ // Older extension builds (or dev bypass paths) do not emit a
414
+ // clientInstanceId — the gateway must not synthesize one or send
415
+ // an empty string, both of which would break the runtime's
416
+ // legacy-key fallback semantics.
417
+ expect(parsed.searchParams.has("clientInstanceId")).toBe(false);
418
+ });
419
+ });
420
+
421
+ describe("checkBrowserRelayAuth", () => {
422
+ test("returns structured auth context with guardianId for actor edge tokens", () => {
423
+ const token = mintEdgeToken(TEST_ACTOR_PRINCIPAL);
424
+ const config = makeConfig({});
425
+ const req = new Request(
426
+ `http://localhost:7830/v1/browser-relay?token=${token}`,
427
+ { headers: { upgrade: "websocket" } },
428
+ );
429
+ const url = new URL(req.url);
430
+
431
+ const result = checkBrowserRelayAuth(req, url, config);
432
+
433
+ expect(result.ok).toBe(true);
434
+ if (!result.ok) throw new Error("expected ok");
435
+ expect(result.context.authenticated).toBe(true);
436
+ expect(result.context.authBypassed).toBe(false);
437
+ expect(result.context.guardianId).toBe(TEST_ACTOR_PRINCIPAL);
438
+ });
439
+
440
+ test("rejects service-style edge tokens with no actor principal", () => {
441
+ const token = mintServiceEdgeToken();
442
+ const config = makeConfig({});
443
+ const req = new Request(
444
+ `http://localhost:7830/v1/browser-relay?token=${token}`,
445
+ { headers: { upgrade: "websocket" } },
446
+ );
447
+ const url = new URL(req.url);
448
+
449
+ const result = checkBrowserRelayAuth(req, url, config);
450
+
451
+ expect(result.ok).toBe(false);
452
+ if (result.ok) throw new Error("expected error");
453
+ expect(result.response.status).toBe(401);
454
+ });
455
+
456
+ test("returns error response when token is missing and auth is required", () => {
457
+ const config = makeConfig({});
458
+ const req = new Request("http://localhost:7830/v1/browser-relay", {
459
+ headers: { upgrade: "websocket" },
460
+ });
461
+ const url = new URL(req.url);
462
+
463
+ const result = checkBrowserRelayAuth(req, url, config);
464
+
465
+ expect(result.ok).toBe(false);
466
+ if (result.ok) throw new Error("expected error");
467
+ expect(result.response.status).toBe(401);
468
+ });
469
+
470
+ test("returns bypassed context when runtimeProxyRequireAuth is disabled", () => {
471
+ const config = makeConfig({ runtimeProxyRequireAuth: false });
472
+ const req = new Request("http://localhost:7830/v1/browser-relay", {
473
+ headers: { upgrade: "websocket" },
474
+ });
475
+ const url = new URL(req.url);
476
+
477
+ const result = checkBrowserRelayAuth(req, url, config);
478
+
479
+ expect(result.ok).toBe(true);
480
+ if (!result.ok) throw new Error("expected ok");
481
+ expect(result.context.authBypassed).toBe(true);
482
+ expect(result.context.authenticated).toBe(false);
483
+ expect(result.context.guardianId).toBeUndefined();
484
+ });
485
+
486
+ test("lifts clientInstanceId from the query param on the downstream handshake", () => {
487
+ // Primary path: the Chrome extension sets clientInstanceId as a
488
+ // query param via `buildUrl`. The gateway must lift it off the
489
+ // handshake and expose it on the auth context so the open()
490
+ // handler can forward it to the runtime.
491
+ const token = mintEdgeToken(TEST_ACTOR_PRINCIPAL);
492
+ const config = makeConfig({});
493
+ const req = new Request(
494
+ `http://localhost:7830/v1/browser-relay?token=${token}&clientInstanceId=install-QUERY`,
495
+ { headers: { upgrade: "websocket" } },
496
+ );
497
+ const url = new URL(req.url);
498
+
499
+ const result = checkBrowserRelayAuth(req, url, config);
500
+
501
+ expect(result.ok).toBe(true);
502
+ if (!result.ok) throw new Error("expected ok");
503
+ expect(result.context.clientInstanceId).toBe("install-QUERY");
504
+ expect(result.context.guardianId).toBe(TEST_ACTOR_PRINCIPAL);
505
+ });
506
+
507
+ test("lifts clientInstanceId from the x-client-instance-id header", () => {
508
+ const token = mintEdgeToken(TEST_ACTOR_PRINCIPAL);
509
+ const config = makeConfig({});
510
+ const req = new Request(
511
+ `http://localhost:7830/v1/browser-relay?token=${token}`,
512
+ {
513
+ headers: {
514
+ upgrade: "websocket",
515
+ "x-client-instance-id": "install-HEADER",
516
+ },
517
+ },
518
+ );
519
+ const url = new URL(req.url);
520
+
521
+ const result = checkBrowserRelayAuth(req, url, config);
522
+
523
+ expect(result.ok).toBe(true);
524
+ if (!result.ok) throw new Error("expected ok");
525
+ expect(result.context.clientInstanceId).toBe("install-HEADER");
526
+ });
527
+
528
+ test("prefers x-client-instance-id header over query param when both are present", () => {
529
+ const token = mintEdgeToken(TEST_ACTOR_PRINCIPAL);
530
+ const config = makeConfig({});
531
+ const req = new Request(
532
+ `http://localhost:7830/v1/browser-relay?token=${token}&clientInstanceId=install-QUERY`,
533
+ {
534
+ headers: {
535
+ upgrade: "websocket",
536
+ "x-client-instance-id": "install-HEADER",
537
+ },
538
+ },
539
+ );
540
+ const url = new URL(req.url);
541
+
542
+ const result = checkBrowserRelayAuth(req, url, config);
543
+
544
+ expect(result.ok).toBe(true);
545
+ if (!result.ok) throw new Error("expected ok");
546
+ // The header form is considered the more explicit signal when
547
+ // both are set, matching the runtime's own precedence.
548
+ expect(result.context.clientInstanceId).toBe("install-HEADER");
549
+ });
550
+
551
+ test("treats an empty clientInstanceId query param as absent", () => {
552
+ const token = mintEdgeToken(TEST_ACTOR_PRINCIPAL);
553
+ const config = makeConfig({});
554
+ const req = new Request(
555
+ `http://localhost:7830/v1/browser-relay?token=${token}&clientInstanceId=`,
556
+ { headers: { upgrade: "websocket" } },
557
+ );
558
+ const url = new URL(req.url);
559
+
560
+ const result = checkBrowserRelayAuth(req, url, config);
561
+
562
+ expect(result.ok).toBe(true);
563
+ if (!result.ok) throw new Error("expected ok");
564
+ expect(result.context.clientInstanceId).toBeUndefined();
565
+ });
566
+
567
+ test("returns undefined clientInstanceId when neither form is present", () => {
568
+ const token = mintEdgeToken(TEST_ACTOR_PRINCIPAL);
569
+ const config = makeConfig({});
570
+ const req = new Request(
571
+ `http://localhost:7830/v1/browser-relay?token=${token}`,
572
+ { headers: { upgrade: "websocket" } },
573
+ );
574
+ const url = new URL(req.url);
575
+
576
+ const result = checkBrowserRelayAuth(req, url, config);
577
+
578
+ expect(result.ok).toBe(true);
579
+ if (!result.ok) throw new Error("expected ok");
580
+ expect(result.context.clientInstanceId).toBeUndefined();
581
+ });
582
+
583
+ test("lifts clientInstanceId even on the auth-bypass path", () => {
584
+ // When runtime proxy auth is disabled, the gateway still needs to
585
+ // propagate the per-install identifier so multi-instance routing
586
+ // continues to work in dev-bypass environments.
587
+ const config = makeConfig({ runtimeProxyRequireAuth: false });
588
+ const req = new Request(
589
+ "http://localhost:7830/v1/browser-relay?clientInstanceId=install-BYPASS",
590
+ { headers: { upgrade: "websocket" } },
591
+ );
592
+ const url = new URL(req.url);
593
+
594
+ const result = checkBrowserRelayAuth(req, url, config);
595
+
596
+ expect(result.ok).toBe(true);
597
+ if (!result.ok) throw new Error("expected ok");
598
+ expect(result.context.authBypassed).toBe(true);
599
+ expect(result.context.clientInstanceId).toBe("install-BYPASS");
600
+ });
601
+ });
602
+
603
+ describe("createBrowserRelayWebsocketHandler guardian propagation", () => {
604
+ test("upgrades with guardianId populated in auth context for actor edge tokens", () => {
605
+ const token = mintEdgeToken(TEST_ACTOR_PRINCIPAL);
606
+ const config = makeConfig({});
607
+ const handler = createBrowserRelayWebsocketHandler(config);
608
+ const req = new Request(
609
+ `http://localhost:7830/v1/browser-relay?token=${token}`,
610
+ { headers: { upgrade: "websocket" } },
611
+ );
612
+
613
+ let capturedData: unknown = null;
614
+ const fakeServer = {
615
+ requestIP: mock(() => ({
616
+ address: "127.0.0.1",
617
+ family: "IPv4",
618
+ port: 54000,
619
+ })),
620
+ upgrade: mock((_req: Request, opts?: { data?: unknown }) => {
621
+ capturedData = opts?.data;
622
+ return true;
623
+ }),
624
+ } as unknown as import("bun").Server<any>;
625
+
626
+ const res = handler(req, fakeServer);
627
+
628
+ expect(res).toBeUndefined();
629
+ expect(fakeServer.upgrade).toHaveBeenCalledTimes(1);
630
+ expect(capturedData).toMatchObject({
631
+ wsType: "browser-relay",
632
+ auth: {
633
+ authenticated: true,
634
+ authBypassed: false,
635
+ guardianId: TEST_ACTOR_PRINCIPAL,
636
+ },
637
+ });
638
+ });
639
+
640
+ test("rejects service-style edge tokens before upgrade", () => {
641
+ const token = mintServiceEdgeToken();
642
+ const config = makeConfig({});
643
+ const handler = createBrowserRelayWebsocketHandler(config);
644
+ const req = new Request(
645
+ `http://localhost:7830/v1/browser-relay?token=${token}`,
646
+ { headers: { upgrade: "websocket" } },
647
+ );
648
+
649
+ const fakeServer = {
650
+ requestIP: mock(() => ({
651
+ address: "127.0.0.1",
652
+ family: "IPv4",
653
+ port: 54000,
654
+ })),
655
+ upgrade: mock(() => true),
656
+ } as unknown as import("bun").Server<any>;
657
+
658
+ const res = handler(req, fakeServer);
659
+
660
+ expect(res).toBeInstanceOf(Response);
661
+ expect(res!.status).toBe(401);
662
+ expect(fakeServer.upgrade).not.toHaveBeenCalled();
663
+ });
278
664
  });
279
665
 
280
666
  describe("isLoopbackPeer", () => {
281
667
  test("uses x-forwarded-for first hop when trustProxy is enabled", () => {
282
- const req = new Request("http://localhost:7830/v1/browser-relay/token", {
668
+ const req = new Request("http://localhost:7830/v1/browser-relay", {
283
669
  headers: { "x-forwarded-for": "203.0.113.5, 127.0.0.1" },
284
670
  });
285
671
 
@@ -295,7 +681,7 @@ describe("isLoopbackPeer", () => {
295
681
  });
296
682
 
297
683
  test("falls back to peer IP when trustProxy is disabled", () => {
298
- const req = new Request("http://localhost:7830/v1/browser-relay/token", {
684
+ const req = new Request("http://localhost:7830/v1/browser-relay", {
299
685
  headers: { "x-forwarded-for": "203.0.113.5, 127.0.0.1" },
300
686
  });
301
687
 
@@ -13,26 +13,26 @@ import { ConfigFileCache } from "../config-file-cache.js";
13
13
  let testBaseDir: string;
14
14
  let workspaceDir: string;
15
15
  let configPath: string;
16
- let savedBaseDataDir: string | undefined;
16
+ let savedWorkspaceDir: string | undefined;
17
17
 
18
18
  function writeConfig(data: Record<string, unknown>): void {
19
19
  writeFileSync(configPath, JSON.stringify(data));
20
20
  }
21
21
 
22
22
  beforeEach(() => {
23
- savedBaseDataDir = process.env.BASE_DATA_DIR;
23
+ savedWorkspaceDir = process.env.VELLUM_WORKSPACE_DIR;
24
24
  testBaseDir = mkdtempSync(join(tmpdir(), "config-file-cache-test-"));
25
25
  workspaceDir = join(testBaseDir, ".vellum", "workspace");
26
26
  mkdirSync(workspaceDir, { recursive: true });
27
27
  configPath = join(workspaceDir, "config.json");
28
- process.env.BASE_DATA_DIR = testBaseDir;
28
+ process.env.VELLUM_WORKSPACE_DIR = workspaceDir;
29
29
  });
30
30
 
31
31
  afterEach(() => {
32
- if (savedBaseDataDir === undefined) {
33
- delete process.env.BASE_DATA_DIR;
32
+ if (savedWorkspaceDir === undefined) {
33
+ delete process.env.VELLUM_WORKSPACE_DIR;
34
34
  } else {
35
- process.env.BASE_DATA_DIR = savedBaseDataDir;
35
+ process.env.VELLUM_WORKSPACE_DIR = savedWorkspaceDir;
36
36
  }
37
37
  rmSync(testBaseDir, { recursive: true, force: true });
38
38
  });
@@ -23,7 +23,7 @@ describe("config: hardcoded defaults", () => {
23
23
  expect(config.unmappedPolicy).toBe("reject");
24
24
  expect(config.routingEntries).toEqual([]);
25
25
  expect(config.defaultAssistantId).toBeUndefined();
26
- expect(config.logFile.dir).toMatch(/\.vellum\/logs$/);
26
+ expect(config.logFile.dir).toMatch(/logs$/);
27
27
  expect(config.logFile.retentionDays).toBe(30);
28
28
  });
29
29
 
@@ -39,7 +39,7 @@ const testDir = join(
39
39
  );
40
40
 
41
41
  function metadataDir(): string {
42
- return join(testDir, ".vellum", "workspace", "data", "credentials");
42
+ return join(testDir, "data", "credentials");
43
43
  }
44
44
 
45
45
  function writeMetadata(
@@ -106,8 +106,8 @@ function encryptEntries(
106
106
  }
107
107
 
108
108
  function writeEncryptedStore(entries: Record<string, string>): void {
109
- const storePath = join(testDir, ".vellum", "protected", "keys.enc");
110
- mkdirSync(join(testDir, ".vellum", "protected"), { recursive: true });
109
+ mkdirSync(testDir, { recursive: true });
110
+ const storePath = join(testDir, "keys.enc");
111
111
 
112
112
  const salt = randomBytes(16);
113
113
  const key = pbkdf2Sync(
@@ -131,17 +131,16 @@ function writeEncryptedStore(entries: Record<string, string>): void {
131
131
  * The store.key is used directly as the AES-256-GCM key (no PBKDF2).
132
132
  */
133
133
  function writeEncryptedStoreV2(entries: Record<string, string>): void {
134
- const protectedDir = join(testDir, ".vellum", "protected");
135
- mkdirSync(protectedDir, { recursive: true });
134
+ mkdirSync(testDir, { recursive: true });
136
135
 
137
136
  const storeKey = randomBytes(KEY_LENGTH);
138
- writeFileSync(join(protectedDir, "store.key"), storeKey);
137
+ writeFileSync(join(testDir, "store.key"), storeKey);
139
138
 
140
139
  const store = {
141
140
  version: 2,
142
141
  entries: encryptEntries(entries, storeKey),
143
142
  };
144
- writeFileSync(join(protectedDir, "keys.enc"), JSON.stringify(store));
143
+ writeFileSync(join(testDir, "keys.enc"), JSON.stringify(store));
145
144
  }
146
145
 
147
146
  // ---------------------------------------------------------------------------
@@ -149,12 +148,14 @@ function writeEncryptedStoreV2(entries: Record<string, string>): void {
149
148
  // ---------------------------------------------------------------------------
150
149
 
151
150
  beforeEach(() => {
152
- process.env.BASE_DATA_DIR = testDir;
151
+ process.env.GATEWAY_SECURITY_DIR = testDir;
152
+ process.env.VELLUM_WORKSPACE_DIR = testDir;
153
153
  logCalls.length = 0;
154
154
  });
155
155
 
156
156
  afterEach(() => {
157
- delete process.env.BASE_DATA_DIR;
157
+ delete process.env.GATEWAY_SECURITY_DIR;
158
+ delete process.env.VELLUM_WORKSPACE_DIR;
158
159
  try {
159
160
  rmSync(testDir, { recursive: true, force: true });
160
161
  } catch {
@@ -178,8 +179,7 @@ describe("v2 encrypted store with store.key", () => {
178
179
 
179
180
  test("returns undefined for v2 store when store.key is missing", async () => {
180
181
  // Write a v2 store but without the store.key file
181
- const protectedDir = join(testDir, ".vellum", "protected");
182
- mkdirSync(protectedDir, { recursive: true });
182
+ mkdirSync(testDir, { recursive: true });
183
183
 
184
184
  const storeKey = randomBytes(KEY_LENGTH);
185
185
  const store = {
@@ -189,7 +189,7 @@ describe("v2 encrypted store with store.key", () => {
189
189
  storeKey,
190
190
  ),
191
191
  };
192
- writeFileSync(join(protectedDir, "keys.enc"), JSON.stringify(store));
192
+ writeFileSync(join(testDir, "keys.enc"), JSON.stringify(store));
193
193
  // Deliberately do NOT write store.key
194
194
 
195
195
  const result = await readCredential(credentialKey("test", "key"));