@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.
- package/ARCHITECTURE.md +111 -28
- package/Dockerfile +2 -2
- package/bun.lock +12 -11
- package/bunfig.toml +6 -0
- package/package.json +13 -12
- package/src/__tests__/browser-relay-websocket.test.ts +391 -5
- package/src/__tests__/config-file-cache.test.ts +6 -6
- package/src/__tests__/config.test.ts +1 -1
- package/src/__tests__/credential-reader.test.ts +12 -12
- package/src/__tests__/credential-watcher-managed-bootstrap.test.ts +81 -15
- package/src/__tests__/credential-watcher.test.ts +3 -2
- package/src/__tests__/feature-flags-route.test.ts +5 -5
- package/src/__tests__/ipc-contact-routes.test.ts +302 -0
- package/src/__tests__/ipc-feature-flag-routes.test.ts +284 -0
- package/src/__tests__/mock-fetch.ts +87 -0
- package/src/__tests__/privacy-config-route.test.ts +911 -0
- package/src/__tests__/remote-feature-flag-sync.test.ts +70 -6
- package/src/__tests__/runtime-proxy.test.ts +114 -0
- package/src/__tests__/schema.test.ts +2 -0
- package/src/__tests__/slack-deliver.test.ts +287 -1
- package/src/__tests__/slack-errors.test.ts +14 -0
- package/src/__tests__/stt-stream-websocket.test.ts +392 -0
- package/src/__tests__/test-preload.ts +28 -0
- package/src/__tests__/twilio-media-websocket.test.ts +618 -0
- package/src/auth/token-exchange.ts +0 -20
- package/src/auth/token-service.ts +4 -9
- package/src/avatar-sync/avatar-channel-syncer.ts +78 -0
- package/src/avatar-sync/avatar-sync-watcher.ts +80 -0
- package/src/avatar-sync/slack-avatar-syncer.ts +70 -0
- package/src/avatar-sync/types.ts +16 -0
- package/src/channels/transport-hints.ts +36 -3
- package/src/cli/enable-proxy.ts +3 -6
- package/src/config.ts +3 -18
- package/src/credential-reader.ts +16 -22
- package/src/credential-watcher.ts +3 -3
- package/src/db/connection.ts +97 -6
- package/src/db/contact-store.ts +156 -0
- package/src/db/data-migrations/index.ts +73 -0
- package/src/db/data-migrations/m0001-guardian-init-lock.ts +62 -0
- package/src/email/register-callback.test.ts +253 -0
- package/src/email/register-callback.ts +142 -0
- package/src/feature-flag-registry.json +85 -14
- package/src/feature-flag-remote-store.ts +4 -9
- package/src/feature-flag-store.ts +4 -9
- 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/channel-verification-session-proxy.ts +2 -2
- package/src/http/routes/email-webhook.test.ts +7 -7
- package/src/http/routes/email-webhook.ts +21 -7
- package/src/http/routes/log-export.test.ts +0 -1
- package/src/http/routes/privacy-config.ts +217 -8
- package/src/http/routes/slack-deliver.ts +147 -24
- package/src/http/routes/stt-stream-websocket.ts +277 -0
- package/src/http/routes/twilio-media-websocket.ts +271 -0
- package/src/index.ts +315 -26
- package/src/ipc/contact-handlers.ts +65 -0
- package/src/ipc/feature-flag-handlers.ts +61 -0
- package/src/ipc/server.ts +272 -0
- package/src/logger.ts +1 -1
- package/src/paths.ts +83 -0
- package/src/platform-url.ts +24 -0
- package/src/remote-feature-flag-sync.ts +36 -1
- package/src/schema.ts +260 -39
- package/src/slack/errors.ts +10 -0
- package/src/telegram/webhook-manager.ts +18 -0
- 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
|
-
|
|
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
|
|
|
@@ -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
|
|
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
|
-
|
|
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.
|
|
28
|
+
process.env.VELLUM_WORKSPACE_DIR = workspaceDir;
|
|
29
29
|
});
|
|
30
30
|
|
|
31
31
|
afterEach(() => {
|
|
32
|
-
if (
|
|
33
|
-
delete process.env.
|
|
32
|
+
if (savedWorkspaceDir === undefined) {
|
|
33
|
+
delete process.env.VELLUM_WORKSPACE_DIR;
|
|
34
34
|
} else {
|
|
35
|
-
process.env.
|
|
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(
|
|
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, "
|
|
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
|
-
|
|
110
|
-
|
|
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
|
-
|
|
135
|
-
mkdirSync(protectedDir, { recursive: true });
|
|
134
|
+
mkdirSync(testDir, { recursive: true });
|
|
136
135
|
|
|
137
136
|
const storeKey = randomBytes(KEY_LENGTH);
|
|
138
|
-
writeFileSync(join(
|
|
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(
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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(
|
|
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"));
|