@vellumai/vellum-gateway 0.6.1 → 0.6.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/Dockerfile CHANGED
@@ -20,6 +20,8 @@ WORKDIR /app
20
20
 
21
21
  RUN apt-get update && apt-get upgrade -y && apt-get install -y \
22
22
  ca-certificates \
23
+ iproute2 \
24
+ procps \
23
25
  && rm -rf /var/lib/apt/lists/*
24
26
 
25
27
  # Copy bun binary from builder
package/bun.lock CHANGED
@@ -5,19 +5,19 @@
5
5
  "": {
6
6
  "name": "vellum-gateway",
7
7
  "dependencies": {
8
- "file-type": "^21.3.0",
9
- "minimatch": "^10.2.4",
10
- "pino": "^9.6.0",
11
- "pino-pretty": "^13.1.3",
12
- "uuid": "^13.0.0",
8
+ "file-type": "21.3.0",
9
+ "minimatch": "10.2.4",
10
+ "pino": "9.14.0",
11
+ "pino-pretty": "13.1.3",
12
+ "uuid": "13.0.0",
13
13
  },
14
14
  "devDependencies": {
15
- "@types/bun": "^1.2.4",
16
- "eslint": "^10.0.0",
17
- "knip": "^5.83.1",
18
- "prettier": "^3.8.1",
19
- "typescript": "^5.7.3",
20
- "typescript-eslint": "^8.56.0",
15
+ "@types/bun": "1.3.9",
16
+ "eslint": "10.0.0",
17
+ "knip": "5.83.1",
18
+ "prettier": "3.8.1",
19
+ "typescript": "5.9.3",
20
+ "typescript-eslint": "8.56.0",
21
21
  },
22
22
  },
23
23
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/vellum-gateway",
3
- "version": "0.6.1",
3
+ "version": "0.6.3",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -23,18 +23,18 @@
23
23
  "postinstall": "cd .. && (git config core.hooksPath || git config core.hooksPath .githooks 2>/dev/null || true) && ([ -f meta/feature-flags/sync-bundled-copies.ts ] && bun run meta/feature-flags/sync-bundled-copies.ts 2>/dev/null || true)"
24
24
  },
25
25
  "dependencies": {
26
- "file-type": "^21.3.0",
27
- "minimatch": "^10.2.4",
28
- "pino": "^9.6.0",
29
- "pino-pretty": "^13.1.3",
30
- "uuid": "^13.0.0"
26
+ "file-type": "21.3.0",
27
+ "minimatch": "10.2.4",
28
+ "pino": "9.14.0",
29
+ "pino-pretty": "13.1.3",
30
+ "uuid": "13.0.0"
31
31
  },
32
32
  "devDependencies": {
33
- "@types/bun": "^1.2.4",
34
- "eslint": "^10.0.0",
35
- "knip": "^5.83.1",
36
- "prettier": "^3.8.1",
37
- "typescript": "^5.7.3",
38
- "typescript-eslint": "^8.56.0"
33
+ "@types/bun": "1.3.9",
34
+ "eslint": "10.0.0",
35
+ "knip": "5.83.1",
36
+ "prettier": "3.8.1",
37
+ "typescript": "5.9.3",
38
+ "typescript-eslint": "8.56.0"
39
39
  }
40
40
  }
@@ -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
 
@@ -76,7 +76,7 @@ async function startGateway(): Promise<void> {
76
76
  stdio: ["ignore", "pipe", "pipe"],
77
77
  });
78
78
 
79
- const deadline = Date.now() + 10_000;
79
+ const deadline = Date.now() + 30_000;
80
80
  while (Date.now() < deadline) {
81
81
  try {
82
82
  const res = await fetch(`http://localhost:${gatewayPort}/healthz`);
@@ -86,7 +86,7 @@ async function startGateway(): Promise<void> {
86
86
  }
87
87
  await new Promise((resolve) => setTimeout(resolve, 100));
88
88
  }
89
- throw new Error("Gateway failed to start within 10 seconds");
89
+ throw new Error("Gateway failed to start within 30 seconds");
90
90
  }
91
91
 
92
92
  function startFakeCes(opts: {
@@ -176,7 +176,7 @@ describe("gateway managed credential bootstrap retry", () => {
176
176
  }
177
177
 
178
178
  expect(status).toBe(401);
179
- }, 20_000);
179
+ }, 45_000);
180
180
 
181
181
  test("keeps retrying until configured credential reads succeed after CES list is already available", async () => {
182
182
  mkdirSync(testDir, { recursive: true });
@@ -220,7 +220,7 @@ describe("gateway managed credential bootstrap retry", () => {
220
220
  }
221
221
 
222
222
  expect(status).toBe(401);
223
- }, 20_000);
223
+ }, 45_000);
224
224
 
225
225
  test("detects new Slack credentials when metadata is written after startup with no initial channel services", async () => {
226
226
  // Start with NO channel service metadata — only non-channel credentials
@@ -274,5 +274,5 @@ describe("gateway managed credential bootstrap retry", () => {
274
274
  }
275
275
 
276
276
  expect(status).toBe(401);
277
- }, 20_000);
277
+ }, 45_000);
278
278
  });
@@ -97,7 +97,7 @@ const {
97
97
  } = await import("../feature-flag-defaults.js");
98
98
  const { clearFeatureFlagStoreCache, readPersistedFeatureFlags } =
99
99
  await import("../feature-flag-store.js");
100
- const { clearRemoteFeatureFlagStoreCache } =
100
+ const { clearRemoteFeatureFlagStoreCache, writeRemoteFeatureFlags } =
101
101
  await import("../feature-flag-remote-store.js");
102
102
 
103
103
  describe("GET /v1/feature-flags handler", () => {
@@ -337,6 +337,43 @@ describe("GET /v1/feature-flags handler", () => {
337
337
  expect(emailFlag.enabled).toBe(false);
338
338
  });
339
339
 
340
+ test("reflects updated flags after remote sync writes new values (stale cache regression)", async () => {
341
+ // Scenario: the LD poller (RemoteFeatureFlagSync) writes
342
+ // email-channel: false, the gateway caches it, then a subsequent
343
+ // poll writes email-channel: true. The GET handler should return
344
+ // the updated value because writeRemoteFeatureFlags() updates
345
+ // both disk and the in-memory cache.
346
+
347
+ // Step 1: First poll writes email-channel: false (simulated via
348
+ // writeRemoteFeatureFlags, which is what the poller calls internally).
349
+ writeRemoteFeatureFlags({ "email-channel": false });
350
+
351
+ const handler = createFeatureFlagsGetHandler();
352
+ const res1 = await handler(
353
+ new Request("http://gateway.test/v1/feature-flags"),
354
+ );
355
+ const body1 = await res1.json();
356
+ const emailFlag1 = body1.flags.find(
357
+ (f: { key: string }) => f.key === "email-channel",
358
+ );
359
+ expect(emailFlag1.enabled).toBe(false);
360
+
361
+ // Step 2: Second poll writes email-channel: true — the poller
362
+ // calls writeRemoteFeatureFlags which updates file + cache.
363
+ writeRemoteFeatureFlags({ "email-channel": true });
364
+
365
+ // Step 3: The GET handler should immediately reflect the update
366
+ // without needing a file-watcher round-trip.
367
+ const res2 = await handler(
368
+ new Request("http://gateway.test/v1/feature-flags"),
369
+ );
370
+ const body2 = await res2.json();
371
+ const emailFlag2 = body2.flags.find(
372
+ (f: { key: string }) => f.key === "email-channel",
373
+ );
374
+ expect(emailFlag2.enabled).toBe(true);
375
+ });
376
+
340
377
  test("registry default used when neither local nor remote is set", async () => {
341
378
  // No local override
342
379
  if (existsSync(featureFlagStorePath)) {