@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 +2 -0
- package/bun.lock +11 -11
- package/package.json +12 -12
- package/src/__tests__/browser-relay-websocket.test.ts +391 -5
- package/src/__tests__/credential-watcher-managed-bootstrap.test.ts +5 -5
- package/src/__tests__/feature-flags-route.test.ts +38 -1
- package/src/__tests__/mock-fetch.ts +87 -0
- package/src/__tests__/remote-feature-flag-sync.test.ts +158 -1
- package/src/__tests__/slack-deliver.test.ts +0 -1
- package/src/auth/token-exchange.ts +0 -20
- package/src/channels/transport-hints.ts +36 -3
- package/src/credential-reader.ts +6 -0
- package/src/email/register-callback.test.ts +253 -0
- package/src/email/register-callback.ts +135 -0
- package/src/feature-flag-registry.json +46 -7
- package/src/feature-flag-remote-store.ts +19 -0
- package/src/feature-flag-watcher.ts +38 -12
- package/src/http/middleware/cors.ts +90 -0
- package/src/http/middleware/rate-limit.ts +1 -3
- package/src/http/routes/browser-relay-websocket.ts +152 -16
- package/src/http/routes/email-webhook.test.ts +7 -7
- package/src/http/routes/email-webhook.ts +15 -5
- package/src/http/routes/log-export.test.ts +0 -1
- package/src/index.ts +132 -27
- package/src/remote-feature-flag-sync.ts +111 -18
- package/src/schema.ts +1 -38
package/Dockerfile
CHANGED
package/bun.lock
CHANGED
|
@@ -5,19 +5,19 @@
|
|
|
5
5
|
"": {
|
|
6
6
|
"name": "vellum-gateway",
|
|
7
7
|
"dependencies": {
|
|
8
|
-
"file-type": "
|
|
9
|
-
"minimatch": "
|
|
10
|
-
"pino": "
|
|
11
|
-
"pino-pretty": "
|
|
12
|
-
"uuid": "
|
|
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": "
|
|
16
|
-
"eslint": "
|
|
17
|
-
"knip": "
|
|
18
|
-
"prettier": "
|
|
19
|
-
"typescript": "
|
|
20
|
-
"typescript-eslint": "
|
|
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.
|
|
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": "
|
|
27
|
-
"minimatch": "
|
|
28
|
-
"pino": "
|
|
29
|
-
"pino-pretty": "
|
|
30
|
-
"uuid": "
|
|
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": "
|
|
34
|
-
"eslint": "
|
|
35
|
-
"knip": "
|
|
36
|
-
"prettier": "
|
|
37
|
-
"typescript": "
|
|
38
|
-
"typescript-eslint": "
|
|
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
|
-
|
|
15
|
-
|
|
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:
|
|
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
|
|
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
|
|
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() +
|
|
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
|
|
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
|
-
},
|
|
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
|
-
},
|
|
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
|
-
},
|
|
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)) {
|