@vellumai/vellum-gateway 0.7.0 → 0.7.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +4 -0
- package/ARCHITECTURE.md +67 -25
- package/Dockerfile +2 -0
- package/README.md +50 -13
- package/bun.lock +16 -2
- package/knip.json +3 -1
- package/package.json +3 -1
- package/src/__tests__/auto-approve-thresholds.test.ts +49 -22
- package/src/__tests__/channel-verification-session-proxy.test.ts +0 -1
- package/src/__tests__/config-file-watcher.test.ts +181 -0
- package/src/__tests__/config.test.ts +0 -1
- package/src/__tests__/contacts-control-plane-proxy.test.ts +0 -1
- package/src/__tests__/credential-watcher-managed-bootstrap.test.ts +10 -2
- package/src/__tests__/credential-watcher.test.ts +30 -2
- package/src/__tests__/db-connection-isolation.test.ts +157 -0
- package/src/__tests__/fake-assistant-ipc.ts +39 -0
- package/src/__tests__/feature-flags-route.test.ts +8 -8
- package/src/__tests__/guardian-init-lockfile.test.ts +30 -4
- package/src/__tests__/ipc-feature-flag-routes.test.ts +1 -1
- package/src/__tests__/live-voice-websocket.test.ts +0 -1
- package/src/__tests__/load-guards.test.ts +0 -1
- package/src/__tests__/migration-teleport-gcs-proxy.test.ts +0 -1
- package/src/__tests__/oauth-callback.test.ts +0 -1
- package/src/__tests__/pair-origin-allowlist.test.ts +155 -0
- package/src/__tests__/rate-limit-loopback.test.ts +1 -1
- package/src/__tests__/remote-feature-flag-sync.test.ts +47 -7
- package/src/__tests__/resolve-assistant.test.ts +0 -1
- package/src/__tests__/route-schema-guard.test.ts +42 -6
- package/src/__tests__/runtime-client.test.ts +0 -1
- package/src/__tests__/runtime-health-proxy.test.ts +0 -1
- package/src/__tests__/runtime-proxy-auth.test.ts +0 -1
- package/src/__tests__/runtime-proxy.test.ts +0 -1
- package/src/__tests__/slack-control-plane-proxy.test.ts +0 -1
- package/src/__tests__/slack-display-name.test.ts +66 -1
- package/src/__tests__/slack-normalize.test.ts +158 -4
- package/src/__tests__/slack-reaction-normalize.test.ts +0 -1
- package/src/__tests__/slack-socket-mode-catchup.test.ts +857 -0
- package/src/__tests__/slack-socket-mode-scopes.test.ts +52 -0
- package/src/__tests__/slack-socket-mode-thread-tracking.test.ts +654 -0
- package/src/__tests__/stt-stream-websocket.test.ts +0 -1
- package/src/__tests__/telegram-control-plane-proxy.test.ts +0 -1
- package/src/__tests__/telegram-send-attachments.test.ts +0 -1
- package/src/__tests__/telegram-webhook-handler.test.ts +0 -1
- package/src/__tests__/text-verification-helpers.test.ts +136 -0
- package/src/__tests__/twilio-media-websocket.test.ts +0 -1
- package/src/__tests__/twilio-relay-websocket.test.ts +0 -1
- package/src/__tests__/twilio-webhooks.test.ts +220 -3
- package/src/__tests__/upstream-transport.test.ts +0 -36
- package/src/__tests__/whatsapp-download.test.ts +0 -1
- package/src/__tests__/whatsapp-webhook.test.ts +0 -1
- package/src/auth/guardian-refresh.ts +4 -18
- package/src/auth/ipc-route-policy.ts +217 -0
- package/src/backup/backup-key.ts +138 -0
- package/src/backup/backup-routes.ts +159 -0
- package/src/backup/backup-worker.ts +374 -0
- package/src/backup/list-snapshots.ts +97 -0
- package/src/backup/local-writer.ts +87 -0
- package/src/backup/offsite-writer.ts +182 -0
- package/src/backup/paths.ts +123 -0
- package/src/backup/stream-crypt.ts +258 -0
- package/src/chrome-extension-origins.ts +28 -0
- package/src/cli/enable-proxy.ts +0 -1
- package/src/config-file-cache.ts +3 -19
- package/src/config-file-utils.ts +124 -0
- package/src/config-file-watcher.ts +57 -25
- package/src/config.ts +4 -7
- package/src/db/connection.ts +65 -3
- package/src/db/contact-store.ts +30 -1
- package/src/db/data-migrations/index.ts +2 -0
- package/src/db/data-migrations/m0003-recover-backup-key.ts +71 -0
- package/src/db/schema.ts +92 -0
- package/src/db/slack-store.ts +144 -11
- package/src/feature-flag-registry.json +40 -152
- package/src/handlers/handle-inbound.ts +123 -0
- package/src/http/middleware/auth.ts +44 -1
- package/src/http/middleware/cors.ts +84 -0
- package/src/http/middleware/rate-limit.ts +6 -8
- package/src/http/routes/auto-approve-thresholds.ts +17 -1
- package/src/http/routes/brain-graph-proxy.ts +1 -1
- package/src/http/routes/channel-readiness-proxy.ts +2 -2
- package/src/http/routes/channel-verification-session-proxy.ts +19 -37
- package/src/http/routes/contact-prompt.ts +149 -0
- package/src/http/routes/contacts-control-plane-proxy.ts +2 -2
- package/src/http/routes/email-webhook.test.ts +0 -1
- package/src/http/routes/ipc-runtime-proxy.test.ts +197 -1
- package/src/http/routes/ipc-runtime-proxy.ts +95 -0
- package/src/http/routes/log-export.test.ts +0 -1
- package/src/http/routes/log-tail.test.ts +336 -0
- package/src/http/routes/log-tail.ts +87 -0
- package/src/http/routes/migration-proxy.ts +1 -2
- package/src/http/routes/oauth-apps-proxy.ts +2 -2
- package/src/http/routes/oauth-providers-proxy.ts +2 -2
- package/src/http/routes/pair.ts +322 -0
- package/src/http/routes/privacy-config.ts +65 -79
- package/src/http/routes/runtime-health-proxy.ts +2 -2
- package/src/http/routes/runtime-proxy.ts +3 -1
- package/src/http/routes/slack-control-plane-proxy.ts +3 -20
- package/src/http/routes/stt-stream-websocket.ts +2 -3
- package/src/http/routes/telegram-control-plane-proxy.ts +2 -2
- package/src/http/routes/telegram-webhook.test.ts +0 -1
- package/src/http/routes/telegram-webhook.ts +6 -0
- package/src/http/routes/trust-rules.suggest.test.ts +25 -0
- package/src/http/routes/trust-rules.ts +7 -0
- package/src/http/routes/twilio-control-plane-proxy.ts +2 -2
- package/src/http/routes/twilio-media-websocket.ts +5 -5
- package/src/http/routes/twilio-voice-verify-callback.ts +310 -0
- package/src/http/routes/twilio-voice-webhook.test.ts +65 -1
- package/src/http/routes/twilio-voice-webhook.ts +45 -1
- package/src/http/routes/whatsapp-webhook.test.ts +0 -1
- package/src/index.ts +357 -278
- package/src/ipc/assistant-client.ts +8 -4
- package/src/ipc/contact-handlers.ts +88 -3
- package/src/ipc/threshold-handlers.ts +2 -0
- package/src/post-assistant-ready.ts +5 -3
- package/src/risk/bash-risk-classifier.test.ts +35 -27
- package/src/risk/bash-risk-classifier.ts +44 -14
- package/src/risk/command-registry/commands/assistant.ts +8 -19
- package/src/risk/command-registry.test.ts +0 -15
- package/src/risk/risk-classifier-parity.test.ts +1 -3
- package/src/runtime/client.ts +58 -3
- package/src/schema.ts +277 -104
- package/src/slack/normalize.test.ts +98 -0
- package/src/slack/normalize.ts +107 -32
- package/src/slack/slack-web.ts +213 -0
- package/src/slack/socket-mode.ts +701 -39
- package/src/telegram/send.test.ts +0 -1
- package/src/twilio/validate-webhook.ts +53 -14
- package/src/twilio/webhook-sync-trigger.ts +58 -0
- package/src/twilio/webhook-sync.test.ts +286 -0
- package/src/twilio/webhook-sync.ts +84 -0
- package/src/util/is-loopback-address.ts +27 -0
- package/src/velay/bridge-utils.ts +228 -0
- package/src/velay/client.test.ts +939 -0
- package/src/velay/client.ts +555 -0
- package/src/velay/http-bridge.test.ts +217 -0
- package/src/velay/http-bridge.ts +83 -0
- package/src/velay/protocol.ts +178 -0
- package/src/velay/test-fake-websocket.ts +69 -0
- package/src/velay/websocket-bridge.test.ts +367 -0
- package/src/velay/websocket-bridge.ts +324 -0
- package/src/verification/binding-helpers.ts +107 -0
- package/src/verification/code-parsing.ts +44 -0
- package/src/verification/contact-helpers.ts +342 -0
- package/src/verification/identity-match.ts +68 -0
- package/src/verification/identity.ts +61 -0
- package/src/verification/rate-limit-helpers.ts +205 -0
- package/src/verification/reply-delivery.ts +109 -0
- package/src/verification/session-helpers.ts +164 -0
- package/src/verification/text-verification.ts +372 -0
- package/src/version.ts +35 -0
- package/src/voice/verification.ts +456 -0
- package/src/webhook-pipeline.ts +4 -0
- package/src/__tests__/browser-relay-websocket.test.ts +0 -698
- package/src/__tests__/telegram-only-default.test.ts +0 -133
- package/src/auth/capability-tokens.ts +0 -248
- package/src/http/routes/browser-extension-pair.ts +0 -455
- package/src/http/routes/browser-relay-websocket.ts +0 -381
- package/src/http/routes/config-file-utils.ts +0 -73
- package/src/ipc/capability-token-handlers.ts +0 -30
- package/src/pairing/approved-devices-store.ts +0 -110
- package/src/pairing/pairing-routes.ts +0 -379
- package/src/pairing/pairing-store.ts +0 -218
|
@@ -46,6 +46,9 @@ const ipcCallAssistantMock = mock(() =>
|
|
|
46
46
|
endpoint: "apps/:appId/dist/:filename",
|
|
47
47
|
method: "GET",
|
|
48
48
|
},
|
|
49
|
+
// Policy-enforced routes (policies resolved gateway-side)
|
|
50
|
+
{ operationId: "settings_get", endpoint: "settings", method: "GET" },
|
|
51
|
+
{ operationId: "calls_start", endpoint: "calls/start", method: "POST" },
|
|
49
52
|
]),
|
|
50
53
|
);
|
|
51
54
|
|
|
@@ -70,13 +73,32 @@ const validateEdgeTokenMock = mock(
|
|
|
70
73
|
(
|
|
71
74
|
_token: string,
|
|
72
75
|
):
|
|
73
|
-
| { ok: true; claims: Record<string, string> }
|
|
76
|
+
| { ok: true; claims: Record<string, string | number> }
|
|
74
77
|
| { ok: false; reason: string } => ({
|
|
75
78
|
ok: true,
|
|
76
79
|
claims: { sub: "test", scope_profile: "test" },
|
|
77
80
|
}),
|
|
78
81
|
);
|
|
79
82
|
|
|
83
|
+
// Mock gateway-side policy registry
|
|
84
|
+
const testPolicies: Record<
|
|
85
|
+
string,
|
|
86
|
+
{ requiredScopes: string[]; allowedPrincipalTypes: string[] }
|
|
87
|
+
> = {
|
|
88
|
+
settings_get: {
|
|
89
|
+
requiredScopes: ["settings.read"],
|
|
90
|
+
allowedPrincipalTypes: ["actor", "svc_gateway"],
|
|
91
|
+
},
|
|
92
|
+
calls_start: {
|
|
93
|
+
requiredScopes: ["calls.write"],
|
|
94
|
+
allowedPrincipalTypes: ["actor"],
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
mock.module("../../auth/ipc-route-policy.js", () => ({
|
|
99
|
+
getIpcRoutePolicy: (operationId: string) => testPolicies[operationId],
|
|
100
|
+
}));
|
|
101
|
+
|
|
80
102
|
mock.module("../../auth/token-exchange.js", () => ({
|
|
81
103
|
validateEdgeToken: validateEdgeTokenMock,
|
|
82
104
|
}));
|
|
@@ -318,3 +340,177 @@ describe("tryIpcProxy", () => {
|
|
|
318
340
|
expect(params.queryParams).toEqual({ limit: "10", offset: "5" });
|
|
319
341
|
});
|
|
320
342
|
});
|
|
343
|
+
|
|
344
|
+
// ---------------------------------------------------------------------------
|
|
345
|
+
// Tests: policy enforcement
|
|
346
|
+
// ---------------------------------------------------------------------------
|
|
347
|
+
|
|
348
|
+
describe("policy enforcement", () => {
|
|
349
|
+
beforeEach(() => {
|
|
350
|
+
ipcCallAssistantStrictMock.mockReset();
|
|
351
|
+
ipcCallAssistantStrictMock.mockImplementation(() =>
|
|
352
|
+
Promise.resolve({ ok: true }),
|
|
353
|
+
);
|
|
354
|
+
validateEdgeTokenMock.mockReset();
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
test("allows request when token has required scope", async () => {
|
|
358
|
+
validateEdgeTokenMock.mockImplementation(() => ({
|
|
359
|
+
ok: true,
|
|
360
|
+
claims: {
|
|
361
|
+
iss: "vellum-auth",
|
|
362
|
+
aud: "vellum-gateway",
|
|
363
|
+
sub: "actor:asst_1:user_1",
|
|
364
|
+
scope_profile: "actor_client_v1",
|
|
365
|
+
exp: Math.floor(Date.now() / 1000) + 3600,
|
|
366
|
+
policy_epoch: 1,
|
|
367
|
+
},
|
|
368
|
+
}));
|
|
369
|
+
|
|
370
|
+
const config = makeConfig({ runtimeProxyRequireAuth: true });
|
|
371
|
+
const req = makeRequest("/v1/settings", {
|
|
372
|
+
headers: { authorization: "Bearer valid" },
|
|
373
|
+
});
|
|
374
|
+
const result = await tryIpcProxy(req, config);
|
|
375
|
+
expect(result!.status).toBe(200);
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
test("returns 403 when token is missing required scope", async () => {
|
|
379
|
+
// ui_page_v1 only has settings.read — not calls.write
|
|
380
|
+
validateEdgeTokenMock.mockImplementation(() => ({
|
|
381
|
+
ok: true,
|
|
382
|
+
claims: {
|
|
383
|
+
iss: "vellum-auth",
|
|
384
|
+
aud: "vellum-gateway",
|
|
385
|
+
sub: "actor:asst_1:user_1",
|
|
386
|
+
scope_profile: "ui_page_v1",
|
|
387
|
+
exp: Math.floor(Date.now() / 1000) + 3600,
|
|
388
|
+
policy_epoch: 1,
|
|
389
|
+
},
|
|
390
|
+
}));
|
|
391
|
+
|
|
392
|
+
const config = makeConfig({ runtimeProxyRequireAuth: true });
|
|
393
|
+
const req = makeRequest("/v1/calls/start", {
|
|
394
|
+
method: "POST",
|
|
395
|
+
headers: {
|
|
396
|
+
authorization: "Bearer valid",
|
|
397
|
+
"content-type": "application/json",
|
|
398
|
+
},
|
|
399
|
+
body: JSON.stringify({}),
|
|
400
|
+
});
|
|
401
|
+
const result = await tryIpcProxy(req, config);
|
|
402
|
+
expect(result!.status).toBe(403);
|
|
403
|
+
|
|
404
|
+
const body = (await result!.json()) as { error: { code: string } };
|
|
405
|
+
expect(body.error.code).toBe("FORBIDDEN");
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
test("returns 403 when principal type is not allowed", async () => {
|
|
409
|
+
// calls/start only allows "actor" — svc_daemon should be denied.
|
|
410
|
+
// Use actor_client_v1 so it has the required calls.write scope;
|
|
411
|
+
// the denial should come from the principal type check, not scope.
|
|
412
|
+
validateEdgeTokenMock.mockImplementation(() => ({
|
|
413
|
+
ok: true,
|
|
414
|
+
claims: {
|
|
415
|
+
iss: "vellum-auth",
|
|
416
|
+
aud: "vellum-gateway",
|
|
417
|
+
sub: "svc:daemon:asst_1",
|
|
418
|
+
scope_profile: "actor_client_v1",
|
|
419
|
+
exp: Math.floor(Date.now() / 1000) + 3600,
|
|
420
|
+
policy_epoch: 1,
|
|
421
|
+
},
|
|
422
|
+
}));
|
|
423
|
+
|
|
424
|
+
const config = makeConfig({ runtimeProxyRequireAuth: true });
|
|
425
|
+
const req = makeRequest("/v1/calls/start", {
|
|
426
|
+
method: "POST",
|
|
427
|
+
headers: {
|
|
428
|
+
authorization: "Bearer valid",
|
|
429
|
+
"content-type": "application/json",
|
|
430
|
+
},
|
|
431
|
+
body: JSON.stringify({}),
|
|
432
|
+
});
|
|
433
|
+
const result = await tryIpcProxy(req, config);
|
|
434
|
+
expect(result!.status).toBe(403);
|
|
435
|
+
|
|
436
|
+
const body = (await result!.json()) as { error: { message: string } };
|
|
437
|
+
expect(body.error.message).toContain("Principal type");
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
test("skips policy enforcement when auth is disabled", async () => {
|
|
441
|
+
// Policy-enforced route, no auth required → should pass
|
|
442
|
+
const config = makeConfig({ runtimeProxyRequireAuth: false });
|
|
443
|
+
const req = makeRequest("/v1/settings");
|
|
444
|
+
const result = await tryIpcProxy(req, config);
|
|
445
|
+
expect(result!.status).toBe(200);
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
test("allows policy-enforced route when principal type matches", async () => {
|
|
449
|
+
// settings allows both "actor" and "svc_gateway"
|
|
450
|
+
validateEdgeTokenMock.mockImplementation(() => ({
|
|
451
|
+
ok: true,
|
|
452
|
+
claims: {
|
|
453
|
+
iss: "vellum-auth",
|
|
454
|
+
aud: "vellum-gateway",
|
|
455
|
+
sub: "svc:gateway:asst_1",
|
|
456
|
+
scope_profile: "gateway_service_v1",
|
|
457
|
+
exp: Math.floor(Date.now() / 1000) + 3600,
|
|
458
|
+
policy_epoch: 1,
|
|
459
|
+
},
|
|
460
|
+
}));
|
|
461
|
+
|
|
462
|
+
const config = makeConfig({ runtimeProxyRequireAuth: true });
|
|
463
|
+
const req = makeRequest("/v1/settings", {
|
|
464
|
+
headers: { authorization: "Bearer valid" },
|
|
465
|
+
});
|
|
466
|
+
const result = await tryIpcProxy(req, config);
|
|
467
|
+
expect(result!.status).toBe(200);
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
test("no-policy routes are unaffected by auth context", async () => {
|
|
471
|
+
// health has no policy — should always pass when authed
|
|
472
|
+
validateEdgeTokenMock.mockImplementation(() => ({
|
|
473
|
+
ok: true,
|
|
474
|
+
claims: {
|
|
475
|
+
iss: "vellum-auth",
|
|
476
|
+
aud: "vellum-gateway",
|
|
477
|
+
sub: "actor:asst_1:user_1",
|
|
478
|
+
scope_profile: "ui_page_v1",
|
|
479
|
+
exp: Math.floor(Date.now() / 1000) + 3600,
|
|
480
|
+
policy_epoch: 1,
|
|
481
|
+
},
|
|
482
|
+
}));
|
|
483
|
+
|
|
484
|
+
const config = makeConfig({ runtimeProxyRequireAuth: true });
|
|
485
|
+
const req = makeRequest("/v1/health", {
|
|
486
|
+
headers: { authorization: "Bearer valid" },
|
|
487
|
+
});
|
|
488
|
+
const result = await tryIpcProxy(req, config);
|
|
489
|
+
expect(result!.status).toBe(200);
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
test("returns 403 when sub claim is malformed", async () => {
|
|
493
|
+
// A valid JWT with a garbage sub should be denied, not silently bypass
|
|
494
|
+
validateEdgeTokenMock.mockImplementation(() => ({
|
|
495
|
+
ok: true,
|
|
496
|
+
claims: {
|
|
497
|
+
iss: "vellum-auth",
|
|
498
|
+
aud: "vellum-gateway",
|
|
499
|
+
sub: "garbage",
|
|
500
|
+
scope_profile: "actor_client_v1",
|
|
501
|
+
exp: Math.floor(Date.now() / 1000) + 3600,
|
|
502
|
+
policy_epoch: 1,
|
|
503
|
+
},
|
|
504
|
+
}));
|
|
505
|
+
|
|
506
|
+
const config = makeConfig({ runtimeProxyRequireAuth: true });
|
|
507
|
+
const req = makeRequest("/v1/settings", {
|
|
508
|
+
headers: { authorization: "Bearer valid" },
|
|
509
|
+
});
|
|
510
|
+
const result = await tryIpcProxy(req, config);
|
|
511
|
+
expect(result!.status).toBe(403);
|
|
512
|
+
|
|
513
|
+
const body = (await result!.json()) as { error: { message: string } };
|
|
514
|
+
expect(body.error.message).toContain("Unable to determine principal type");
|
|
515
|
+
});
|
|
516
|
+
});
|
|
@@ -12,7 +12,14 @@
|
|
|
12
12
|
* IPC, and converts the result back into an HTTP Response.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
+
import {
|
|
16
|
+
getIpcRoutePolicy,
|
|
17
|
+
type IpcRoutePolicy,
|
|
18
|
+
} from "../../auth/ipc-route-policy.js";
|
|
19
|
+
import { resolveScopeProfile } from "../../auth/scopes.js";
|
|
20
|
+
import { parseSub } from "../../auth/subject.js";
|
|
15
21
|
import { validateEdgeToken } from "../../auth/token-exchange.js";
|
|
22
|
+
import type { TokenClaims } from "../../auth/types.js";
|
|
16
23
|
import type { GatewayConfig } from "../../config.js";
|
|
17
24
|
import {
|
|
18
25
|
ipcCallAssistantStrict,
|
|
@@ -47,6 +54,8 @@ export async function tryIpcProxy(
|
|
|
47
54
|
}
|
|
48
55
|
|
|
49
56
|
// --- Auth: replicate the gateway's JWT validation -----------------------
|
|
57
|
+
let claims: TokenClaims | undefined;
|
|
58
|
+
|
|
50
59
|
if (config.runtimeProxyRequireAuth && req.method !== "OPTIONS") {
|
|
51
60
|
const authHeader = req.headers.get("authorization");
|
|
52
61
|
if (!authHeader || !authHeader.toLowerCase().startsWith("bearer ")) {
|
|
@@ -65,6 +74,7 @@ export async function tryIpcProxy(
|
|
|
65
74
|
);
|
|
66
75
|
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
|
67
76
|
}
|
|
77
|
+
claims = result.claims;
|
|
68
78
|
}
|
|
69
79
|
|
|
70
80
|
// --- Route matching -----------------------------------------------------
|
|
@@ -87,6 +97,11 @@ export async function tryIpcProxy(
|
|
|
87
97
|
);
|
|
88
98
|
}
|
|
89
99
|
|
|
100
|
+
// --- Policy enforcement --------------------------------------------------
|
|
101
|
+
const policy = getIpcRoutePolicy(match.operationId);
|
|
102
|
+
const policyDenied = enforceRoutePolicy(policy, claims, pathname);
|
|
103
|
+
if (policyDenied) return policyDenied;
|
|
104
|
+
|
|
90
105
|
const start = performance.now();
|
|
91
106
|
|
|
92
107
|
// --- Build structured IPC params ----------------------------------------
|
|
@@ -197,3 +212,83 @@ export async function tryIpcProxy(
|
|
|
197
212
|
return Response.json({ error: "Internal Server Error" }, { status: 500 });
|
|
198
213
|
}
|
|
199
214
|
}
|
|
215
|
+
|
|
216
|
+
// ---------------------------------------------------------------------------
|
|
217
|
+
// Policy enforcement
|
|
218
|
+
// ---------------------------------------------------------------------------
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Enforce the route's scope/principal policy against the caller's token.
|
|
222
|
+
* Returns a 403 Response when denied, null when allowed.
|
|
223
|
+
*/
|
|
224
|
+
function enforceRoutePolicy(
|
|
225
|
+
policy: IpcRoutePolicy | undefined,
|
|
226
|
+
claims: TokenClaims | undefined,
|
|
227
|
+
path: string,
|
|
228
|
+
): Response | null {
|
|
229
|
+
if (!policy) return null;
|
|
230
|
+
|
|
231
|
+
// When auth is disabled (dev mode), no claims → skip enforcement.
|
|
232
|
+
if (!claims) return null;
|
|
233
|
+
|
|
234
|
+
// Check principal type.
|
|
235
|
+
if (policy.allowedPrincipalTypes.length > 0) {
|
|
236
|
+
const subResult = parseSub(claims.sub);
|
|
237
|
+
if (!subResult.ok) {
|
|
238
|
+
log.warn(
|
|
239
|
+
{ path, sub: claims.sub, reason: subResult.reason },
|
|
240
|
+
"IPC proxy policy denied: failed to parse sub claim",
|
|
241
|
+
);
|
|
242
|
+
return Response.json(
|
|
243
|
+
{
|
|
244
|
+
error: {
|
|
245
|
+
code: "FORBIDDEN",
|
|
246
|
+
message: "Unable to determine principal type",
|
|
247
|
+
},
|
|
248
|
+
},
|
|
249
|
+
{ status: 403 },
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
if (!policy.allowedPrincipalTypes.includes(subResult.principalType)) {
|
|
253
|
+
log.warn(
|
|
254
|
+
{
|
|
255
|
+
path,
|
|
256
|
+
principalType: subResult.principalType,
|
|
257
|
+
allowed: policy.allowedPrincipalTypes,
|
|
258
|
+
},
|
|
259
|
+
"IPC proxy policy denied: principal type not allowed",
|
|
260
|
+
);
|
|
261
|
+
return Response.json(
|
|
262
|
+
{
|
|
263
|
+
error: {
|
|
264
|
+
code: "FORBIDDEN",
|
|
265
|
+
message: "Principal type not permitted for this endpoint",
|
|
266
|
+
},
|
|
267
|
+
},
|
|
268
|
+
{ status: 403 },
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Check required scopes.
|
|
274
|
+
const scopes = resolveScopeProfile(claims.scope_profile);
|
|
275
|
+
for (const required of policy.requiredScopes) {
|
|
276
|
+
if (!scopes.has(required)) {
|
|
277
|
+
log.warn(
|
|
278
|
+
{ path, missingScope: required },
|
|
279
|
+
"IPC proxy policy denied: missing required scope",
|
|
280
|
+
);
|
|
281
|
+
return Response.json(
|
|
282
|
+
{
|
|
283
|
+
error: {
|
|
284
|
+
code: "FORBIDDEN",
|
|
285
|
+
message: `Missing required scope: ${required}`,
|
|
286
|
+
},
|
|
287
|
+
},
|
|
288
|
+
{ status: 403 },
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
return null;
|
|
294
|
+
}
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the gateway log tail handler.
|
|
3
|
+
*
|
|
4
|
+
* Verifies:
|
|
5
|
+
* - Returns empty result when logFile.dir is undefined
|
|
6
|
+
* - Returns empty result when dir path doesn't exist
|
|
7
|
+
* - Returns empty result when no files match the log pattern
|
|
8
|
+
* - Returns last N entries in chronological order
|
|
9
|
+
* - Filters by minimum log level
|
|
10
|
+
* - Filters by module
|
|
11
|
+
* - Skips malformed JSON lines
|
|
12
|
+
* - Sets truncated: true when more entries exist than n
|
|
13
|
+
* - Sets truncated: false when fewer entries exist than n
|
|
14
|
+
* - Spans multiple log files for multi-day queries
|
|
15
|
+
* - Clamps n=1001 to 1000
|
|
16
|
+
* - Returns 400 for invalid level param
|
|
17
|
+
* - Handles filesystem errors gracefully
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { describe, test, expect, afterEach, mock } from "bun:test";
|
|
21
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
22
|
+
import { tmpdir } from "node:os";
|
|
23
|
+
import { join } from "node:path";
|
|
24
|
+
|
|
25
|
+
import type { GatewayConfig } from "../../config.js";
|
|
26
|
+
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
// Mocks — must be registered before importing the handler
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
mock.module("../../logger.js", () => {
|
|
32
|
+
const noop = () => {};
|
|
33
|
+
const noopLogger = {
|
|
34
|
+
info: noop,
|
|
35
|
+
warn: noop,
|
|
36
|
+
error: noop,
|
|
37
|
+
debug: noop,
|
|
38
|
+
trace: noop,
|
|
39
|
+
fatal: noop,
|
|
40
|
+
child: () => noopLogger,
|
|
41
|
+
};
|
|
42
|
+
return {
|
|
43
|
+
getLogger: () => noopLogger,
|
|
44
|
+
initLogger: noop,
|
|
45
|
+
LOG_FILE_PATTERN: /^gateway-(\d{4}-\d{2}-\d{2})\.log$/,
|
|
46
|
+
};
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// Import handler after mocks
|
|
50
|
+
const { createLogTailHandler } = await import("./log-tail.js");
|
|
51
|
+
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
// Helpers
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
let tmpDir: string;
|
|
57
|
+
|
|
58
|
+
function makeReq(url: string): Request {
|
|
59
|
+
return new Request(url);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function makeConfig(dir: string | undefined): GatewayConfig {
|
|
63
|
+
return {
|
|
64
|
+
assistantRuntimeBaseUrl: "http://localhost:7821",
|
|
65
|
+
defaultAssistantId: undefined,
|
|
66
|
+
gatewayInternalBaseUrl: "http://127.0.0.1:7830",
|
|
67
|
+
logFile: { dir, retentionDays: 30 },
|
|
68
|
+
maxAttachmentBytes: {
|
|
69
|
+
telegram: 20 * 1024 * 1024,
|
|
70
|
+
slack: 100 * 1024 * 1024,
|
|
71
|
+
whatsapp: 16 * 1024 * 1024,
|
|
72
|
+
default: 100 * 1024 * 1024,
|
|
73
|
+
},
|
|
74
|
+
maxAttachmentConcurrency: 3,
|
|
75
|
+
maxWebhookPayloadBytes: 1024 * 1024,
|
|
76
|
+
port: 7830,
|
|
77
|
+
routingEntries: [],
|
|
78
|
+
runtimeInitialBackoffMs: 500,
|
|
79
|
+
runtimeMaxRetries: 2,
|
|
80
|
+
runtimeProxyRequireAuth: true,
|
|
81
|
+
runtimeTimeoutMs: 30000,
|
|
82
|
+
shutdownDrainMs: 5000,
|
|
83
|
+
unmappedPolicy: "default",
|
|
84
|
+
trustProxy: false,
|
|
85
|
+
} as GatewayConfig;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function makeLogLine(level: number, msg: string, extras?: Record<string, unknown>): string {
|
|
89
|
+
return JSON.stringify({ level, msg, time: Date.now(), ...extras });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
afterEach(() => {
|
|
93
|
+
if (tmpDir) {
|
|
94
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
// Tests
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
describe("log-tail handler", () => {
|
|
103
|
+
test("config.logFile.dir is undefined → returns { lines: [], truncated: false }", async () => {
|
|
104
|
+
const config = makeConfig(undefined);
|
|
105
|
+
const handler = createLogTailHandler(config);
|
|
106
|
+
const res = await handler(makeReq("http://localhost:7830/v1/logs/tail"));
|
|
107
|
+
const body = await res.json();
|
|
108
|
+
expect(body).toEqual({ lines: [], truncated: false });
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("dir path doesn't exist → returns { lines: [], truncated: false }", async () => {
|
|
112
|
+
const config = makeConfig("/nonexistent/path/that/does/not/exist");
|
|
113
|
+
const handler = createLogTailHandler(config);
|
|
114
|
+
const res = await handler(makeReq("http://localhost:7830/v1/logs/tail"));
|
|
115
|
+
const body = await res.json();
|
|
116
|
+
expect(body).toEqual({ lines: [], truncated: false });
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("dir exists but no files matching pattern → returns { lines: [], truncated: false }", async () => {
|
|
120
|
+
tmpDir = mkdtempSync(join(tmpdir(), "gw-log-tail-test-"));
|
|
121
|
+
writeFileSync(join(tmpDir, "other.log"), "some content\n");
|
|
122
|
+
|
|
123
|
+
const config = makeConfig(tmpDir);
|
|
124
|
+
const handler = createLogTailHandler(config);
|
|
125
|
+
const res = await handler(makeReq("http://localhost:7830/v1/logs/tail"));
|
|
126
|
+
const body = await res.json();
|
|
127
|
+
expect(body).toEqual({ lines: [], truncated: false });
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("single file with 5 entries, n=3 → returns last 3 in chronological order, truncated: true", async () => {
|
|
131
|
+
tmpDir = mkdtempSync(join(tmpdir(), "gw-log-tail-test-"));
|
|
132
|
+
|
|
133
|
+
const lines = [
|
|
134
|
+
makeLogLine(30, "entry 1"),
|
|
135
|
+
makeLogLine(30, "entry 2"),
|
|
136
|
+
makeLogLine(30, "entry 3"),
|
|
137
|
+
makeLogLine(30, "entry 4"),
|
|
138
|
+
makeLogLine(30, "entry 5"),
|
|
139
|
+
];
|
|
140
|
+
writeFileSync(join(tmpDir, "gateway-2026-05-04.log"), lines.join("\n"));
|
|
141
|
+
|
|
142
|
+
const config = makeConfig(tmpDir);
|
|
143
|
+
const handler = createLogTailHandler(config);
|
|
144
|
+
const res = await handler(makeReq("http://localhost:7830/v1/logs/tail?n=3"));
|
|
145
|
+
const body = await res.json() as { lines: { msg: string }[]; truncated: boolean };
|
|
146
|
+
|
|
147
|
+
expect(body.truncated).toBe(true);
|
|
148
|
+
expect(body.lines).toHaveLength(3);
|
|
149
|
+
// Chronological order: entries 3, 4, 5
|
|
150
|
+
expect(body.lines[0].msg).toBe("entry 3");
|
|
151
|
+
expect(body.lines[1].msg).toBe("entry 4");
|
|
152
|
+
expect(body.lines[2].msg).toBe("entry 5");
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("level=error query param filters to levels >= 50", async () => {
|
|
156
|
+
tmpDir = mkdtempSync(join(tmpdir(), "gw-log-tail-test-"));
|
|
157
|
+
|
|
158
|
+
const lines = [
|
|
159
|
+
makeLogLine(20, "debug msg"),
|
|
160
|
+
makeLogLine(30, "info msg"),
|
|
161
|
+
makeLogLine(50, "error msg"),
|
|
162
|
+
makeLogLine(60, "fatal msg"),
|
|
163
|
+
];
|
|
164
|
+
writeFileSync(join(tmpDir, "gateway-2026-05-04.log"), lines.join("\n"));
|
|
165
|
+
|
|
166
|
+
const config = makeConfig(tmpDir);
|
|
167
|
+
const handler = createLogTailHandler(config);
|
|
168
|
+
const res = await handler(makeReq("http://localhost:7830/v1/logs/tail?level=error"));
|
|
169
|
+
const body = await res.json() as { lines: { msg: string }[]; truncated: boolean };
|
|
170
|
+
|
|
171
|
+
expect(body.lines).toHaveLength(2);
|
|
172
|
+
const msgs = body.lines.map((l) => l.msg);
|
|
173
|
+
expect(msgs).toContain("error msg");
|
|
174
|
+
expect(msgs).toContain("fatal msg");
|
|
175
|
+
expect(msgs).not.toContain("debug msg");
|
|
176
|
+
expect(msgs).not.toContain("info msg");
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("module=mcp query param: only mcp entries returned", async () => {
|
|
180
|
+
tmpDir = mkdtempSync(join(tmpdir(), "gw-log-tail-test-"));
|
|
181
|
+
|
|
182
|
+
const lines = [
|
|
183
|
+
makeLogLine(30, "mcp message", { module: "mcp" }),
|
|
184
|
+
makeLogLine(30, "trust message", { module: "trust" }),
|
|
185
|
+
makeLogLine(30, "another mcp", { module: "mcp" }),
|
|
186
|
+
];
|
|
187
|
+
writeFileSync(join(tmpDir, "gateway-2026-05-04.log"), lines.join("\n"));
|
|
188
|
+
|
|
189
|
+
const config = makeConfig(tmpDir);
|
|
190
|
+
const handler = createLogTailHandler(config);
|
|
191
|
+
const res = await handler(makeReq("http://localhost:7830/v1/logs/tail?module=mcp"));
|
|
192
|
+
const body = await res.json() as { lines: { msg: string; module: string }[]; truncated: boolean };
|
|
193
|
+
|
|
194
|
+
expect(body.lines).toHaveLength(2);
|
|
195
|
+
for (const line of body.lines) {
|
|
196
|
+
expect(line.module).toBe("mcp");
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("malformed JSON lines are silently skipped", async () => {
|
|
201
|
+
tmpDir = mkdtempSync(join(tmpdir(), "gw-log-tail-test-"));
|
|
202
|
+
|
|
203
|
+
const lines = [
|
|
204
|
+
"not json",
|
|
205
|
+
makeLogLine(30, "valid entry"),
|
|
206
|
+
"{broken json",
|
|
207
|
+
];
|
|
208
|
+
writeFileSync(join(tmpDir, "gateway-2026-05-04.log"), lines.join("\n"));
|
|
209
|
+
|
|
210
|
+
const config = makeConfig(tmpDir);
|
|
211
|
+
const handler = createLogTailHandler(config);
|
|
212
|
+
const res = await handler(makeReq("http://localhost:7830/v1/logs/tail"));
|
|
213
|
+
const body = await res.json() as { lines: { msg: string }[]; truncated: boolean };
|
|
214
|
+
|
|
215
|
+
expect(body.lines).toHaveLength(1);
|
|
216
|
+
expect(body.lines[0].msg).toBe("valid entry");
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test("n=2 with 4 matching entries → truncated: true", async () => {
|
|
220
|
+
tmpDir = mkdtempSync(join(tmpdir(), "gw-log-tail-test-"));
|
|
221
|
+
|
|
222
|
+
const lines = [
|
|
223
|
+
makeLogLine(30, "entry 1"),
|
|
224
|
+
makeLogLine(30, "entry 2"),
|
|
225
|
+
makeLogLine(30, "entry 3"),
|
|
226
|
+
makeLogLine(30, "entry 4"),
|
|
227
|
+
];
|
|
228
|
+
writeFileSync(join(tmpDir, "gateway-2026-05-04.log"), lines.join("\n"));
|
|
229
|
+
|
|
230
|
+
const config = makeConfig(tmpDir);
|
|
231
|
+
const handler = createLogTailHandler(config);
|
|
232
|
+
const res = await handler(makeReq("http://localhost:7830/v1/logs/tail?n=2"));
|
|
233
|
+
const body = await res.json() as { lines: unknown[]; truncated: boolean };
|
|
234
|
+
|
|
235
|
+
expect(body.lines).toHaveLength(2);
|
|
236
|
+
expect(body.truncated).toBe(true);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test("n=10 with 3 matching entries → truncated: false", async () => {
|
|
240
|
+
tmpDir = mkdtempSync(join(tmpdir(), "gw-log-tail-test-"));
|
|
241
|
+
|
|
242
|
+
const lines = [
|
|
243
|
+
makeLogLine(30, "entry 1"),
|
|
244
|
+
makeLogLine(30, "entry 2"),
|
|
245
|
+
makeLogLine(30, "entry 3"),
|
|
246
|
+
];
|
|
247
|
+
writeFileSync(join(tmpDir, "gateway-2026-05-04.log"), lines.join("\n"));
|
|
248
|
+
|
|
249
|
+
const config = makeConfig(tmpDir);
|
|
250
|
+
const handler = createLogTailHandler(config);
|
|
251
|
+
const res = await handler(makeReq("http://localhost:7830/v1/logs/tail?n=10"));
|
|
252
|
+
const body = await res.json() as { lines: unknown[]; truncated: boolean };
|
|
253
|
+
|
|
254
|
+
expect(body.lines).toHaveLength(3);
|
|
255
|
+
expect(body.truncated).toBe(false);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test("multi-day: spans two files, returns 3 in chronological order", async () => {
|
|
259
|
+
tmpDir = mkdtempSync(join(tmpdir(), "gw-log-tail-test-"));
|
|
260
|
+
|
|
261
|
+
// Yesterday file: 3 entries
|
|
262
|
+
const yesterdayLines = [
|
|
263
|
+
makeLogLine(30, "yesterday 1"),
|
|
264
|
+
makeLogLine(30, "yesterday 2"),
|
|
265
|
+
makeLogLine(30, "yesterday 3"),
|
|
266
|
+
];
|
|
267
|
+
writeFileSync(join(tmpDir, "gateway-2026-05-03.log"), yesterdayLines.join("\n"));
|
|
268
|
+
|
|
269
|
+
// Today file: 1 entry
|
|
270
|
+
const todayLines = [makeLogLine(30, "today 1")];
|
|
271
|
+
writeFileSync(join(tmpDir, "gateway-2026-05-04.log"), todayLines.join("\n"));
|
|
272
|
+
|
|
273
|
+
const config = makeConfig(tmpDir);
|
|
274
|
+
const handler = createLogTailHandler(config);
|
|
275
|
+
const res = await handler(makeReq("http://localhost:7830/v1/logs/tail?n=3"));
|
|
276
|
+
const body = await res.json() as { lines: { msg: string }[]; truncated: boolean };
|
|
277
|
+
|
|
278
|
+
expect(body.lines).toHaveLength(3);
|
|
279
|
+
// Should span both files — newest-first collection, then reversed to chronological
|
|
280
|
+
// With n=3, we want the 3 most recent: yesterday 3, yesterday 3... wait:
|
|
281
|
+
// Files sorted newest-first: today (1 entry), yesterday (3 entries)
|
|
282
|
+
// Collect reverse order: today 1, yesterday 3, yesterday 2 → stop at n+1=4
|
|
283
|
+
// Actually we collect up to n+1=4 entries before stopping
|
|
284
|
+
// today has 1 entry, then yesterday has 3 entries → total 4 → break after 4
|
|
285
|
+
// truncated = 4 > 3 = true? Let's check the logic...
|
|
286
|
+
// Actually we collect until collected.length >= n+1, so we need n+1=4 entries
|
|
287
|
+
// today: 1 entry (total=1), yesterday: yesterday3(total=2), yesterday2(total=3), yesterday1(total=4) → break
|
|
288
|
+
// truncated = 4 > 3 = true
|
|
289
|
+
// lines = collected.slice(0,3) = [today1, yesterday3, yesterday2].reverse() = [yesterday2, yesterday3, today1]
|
|
290
|
+
// Chronological: yesterday 2, yesterday 3, today 1
|
|
291
|
+
expect(body.lines[0].msg).toBe("yesterday 2");
|
|
292
|
+
expect(body.lines[1].msg).toBe("yesterday 3");
|
|
293
|
+
expect(body.lines[2].msg).toBe("today 1");
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
test("n=1001 in querystring → returns successfully (clamped to 1000)", async () => {
|
|
297
|
+
tmpDir = mkdtempSync(join(tmpdir(), "gw-log-tail-test-"));
|
|
298
|
+
|
|
299
|
+
writeFileSync(join(tmpDir, "gateway-2026-05-04.log"), makeLogLine(30, "entry"));
|
|
300
|
+
|
|
301
|
+
const config = makeConfig(tmpDir);
|
|
302
|
+
const handler = createLogTailHandler(config);
|
|
303
|
+
const res = await handler(makeReq("http://localhost:7830/v1/logs/tail?n=1001"));
|
|
304
|
+
|
|
305
|
+
expect(res.status).toBe(200);
|
|
306
|
+
const body = await res.json() as { lines: unknown[]; truncated: boolean };
|
|
307
|
+
expect(body.lines).toHaveLength(1);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
test("invalid level=INVALID → response status 400", async () => {
|
|
311
|
+
tmpDir = mkdtempSync(join(tmpdir(), "gw-log-tail-test-"));
|
|
312
|
+
|
|
313
|
+
const config = makeConfig(tmpDir);
|
|
314
|
+
const handler = createLogTailHandler(config);
|
|
315
|
+
const res = await handler(makeReq("http://localhost:7830/v1/logs/tail?level=INVALID"));
|
|
316
|
+
|
|
317
|
+
expect(res.status).toBe(400);
|
|
318
|
+
const body = await res.json() as { error: string };
|
|
319
|
+
expect(body.error).toBe("Invalid level");
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
test("filesystem error: config.logFile.dir is a file path not a dir → returns gracefully", async () => {
|
|
323
|
+
tmpDir = mkdtempSync(join(tmpdir(), "gw-log-tail-test-"));
|
|
324
|
+
const filePath = join(tmpDir, "not-a-directory.txt");
|
|
325
|
+
writeFileSync(filePath, "I am a file");
|
|
326
|
+
|
|
327
|
+
// Point dir at a file path — readdirSync will throw ENOTDIR
|
|
328
|
+
const config = makeConfig(filePath);
|
|
329
|
+
const handler = createLogTailHandler(config);
|
|
330
|
+
const res = await handler(makeReq("http://localhost:7830/v1/logs/tail"));
|
|
331
|
+
|
|
332
|
+
expect(res.status).toBe(200);
|
|
333
|
+
const body = await res.json();
|
|
334
|
+
expect(body).toEqual({ lines: [], truncated: false });
|
|
335
|
+
});
|
|
336
|
+
});
|