@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.
Files changed (162) hide show
  1. package/AGENTS.md +4 -0
  2. package/ARCHITECTURE.md +67 -25
  3. package/Dockerfile +2 -0
  4. package/README.md +50 -13
  5. package/bun.lock +16 -2
  6. package/knip.json +3 -1
  7. package/package.json +3 -1
  8. package/src/__tests__/auto-approve-thresholds.test.ts +49 -22
  9. package/src/__tests__/channel-verification-session-proxy.test.ts +0 -1
  10. package/src/__tests__/config-file-watcher.test.ts +181 -0
  11. package/src/__tests__/config.test.ts +0 -1
  12. package/src/__tests__/contacts-control-plane-proxy.test.ts +0 -1
  13. package/src/__tests__/credential-watcher-managed-bootstrap.test.ts +10 -2
  14. package/src/__tests__/credential-watcher.test.ts +30 -2
  15. package/src/__tests__/db-connection-isolation.test.ts +157 -0
  16. package/src/__tests__/fake-assistant-ipc.ts +39 -0
  17. package/src/__tests__/feature-flags-route.test.ts +8 -8
  18. package/src/__tests__/guardian-init-lockfile.test.ts +30 -4
  19. package/src/__tests__/ipc-feature-flag-routes.test.ts +1 -1
  20. package/src/__tests__/live-voice-websocket.test.ts +0 -1
  21. package/src/__tests__/load-guards.test.ts +0 -1
  22. package/src/__tests__/migration-teleport-gcs-proxy.test.ts +0 -1
  23. package/src/__tests__/oauth-callback.test.ts +0 -1
  24. package/src/__tests__/pair-origin-allowlist.test.ts +155 -0
  25. package/src/__tests__/rate-limit-loopback.test.ts +1 -1
  26. package/src/__tests__/remote-feature-flag-sync.test.ts +47 -7
  27. package/src/__tests__/resolve-assistant.test.ts +0 -1
  28. package/src/__tests__/route-schema-guard.test.ts +42 -6
  29. package/src/__tests__/runtime-client.test.ts +0 -1
  30. package/src/__tests__/runtime-health-proxy.test.ts +0 -1
  31. package/src/__tests__/runtime-proxy-auth.test.ts +0 -1
  32. package/src/__tests__/runtime-proxy.test.ts +0 -1
  33. package/src/__tests__/slack-control-plane-proxy.test.ts +0 -1
  34. package/src/__tests__/slack-display-name.test.ts +66 -1
  35. package/src/__tests__/slack-normalize.test.ts +158 -4
  36. package/src/__tests__/slack-reaction-normalize.test.ts +0 -1
  37. package/src/__tests__/slack-socket-mode-catchup.test.ts +857 -0
  38. package/src/__tests__/slack-socket-mode-scopes.test.ts +52 -0
  39. package/src/__tests__/slack-socket-mode-thread-tracking.test.ts +654 -0
  40. package/src/__tests__/stt-stream-websocket.test.ts +0 -1
  41. package/src/__tests__/telegram-control-plane-proxy.test.ts +0 -1
  42. package/src/__tests__/telegram-send-attachments.test.ts +0 -1
  43. package/src/__tests__/telegram-webhook-handler.test.ts +0 -1
  44. package/src/__tests__/text-verification-helpers.test.ts +136 -0
  45. package/src/__tests__/twilio-media-websocket.test.ts +0 -1
  46. package/src/__tests__/twilio-relay-websocket.test.ts +0 -1
  47. package/src/__tests__/twilio-webhooks.test.ts +220 -3
  48. package/src/__tests__/upstream-transport.test.ts +0 -36
  49. package/src/__tests__/whatsapp-download.test.ts +0 -1
  50. package/src/__tests__/whatsapp-webhook.test.ts +0 -1
  51. package/src/auth/guardian-refresh.ts +4 -18
  52. package/src/auth/ipc-route-policy.ts +217 -0
  53. package/src/backup/backup-key.ts +138 -0
  54. package/src/backup/backup-routes.ts +159 -0
  55. package/src/backup/backup-worker.ts +374 -0
  56. package/src/backup/list-snapshots.ts +97 -0
  57. package/src/backup/local-writer.ts +87 -0
  58. package/src/backup/offsite-writer.ts +182 -0
  59. package/src/backup/paths.ts +123 -0
  60. package/src/backup/stream-crypt.ts +258 -0
  61. package/src/chrome-extension-origins.ts +28 -0
  62. package/src/cli/enable-proxy.ts +0 -1
  63. package/src/config-file-cache.ts +3 -19
  64. package/src/config-file-utils.ts +124 -0
  65. package/src/config-file-watcher.ts +57 -25
  66. package/src/config.ts +4 -7
  67. package/src/db/connection.ts +65 -3
  68. package/src/db/contact-store.ts +30 -1
  69. package/src/db/data-migrations/index.ts +2 -0
  70. package/src/db/data-migrations/m0003-recover-backup-key.ts +71 -0
  71. package/src/db/schema.ts +92 -0
  72. package/src/db/slack-store.ts +144 -11
  73. package/src/feature-flag-registry.json +40 -152
  74. package/src/handlers/handle-inbound.ts +123 -0
  75. package/src/http/middleware/auth.ts +44 -1
  76. package/src/http/middleware/cors.ts +84 -0
  77. package/src/http/middleware/rate-limit.ts +6 -8
  78. package/src/http/routes/auto-approve-thresholds.ts +17 -1
  79. package/src/http/routes/brain-graph-proxy.ts +1 -1
  80. package/src/http/routes/channel-readiness-proxy.ts +2 -2
  81. package/src/http/routes/channel-verification-session-proxy.ts +19 -37
  82. package/src/http/routes/contact-prompt.ts +149 -0
  83. package/src/http/routes/contacts-control-plane-proxy.ts +2 -2
  84. package/src/http/routes/email-webhook.test.ts +0 -1
  85. package/src/http/routes/ipc-runtime-proxy.test.ts +197 -1
  86. package/src/http/routes/ipc-runtime-proxy.ts +95 -0
  87. package/src/http/routes/log-export.test.ts +0 -1
  88. package/src/http/routes/log-tail.test.ts +336 -0
  89. package/src/http/routes/log-tail.ts +87 -0
  90. package/src/http/routes/migration-proxy.ts +1 -2
  91. package/src/http/routes/oauth-apps-proxy.ts +2 -2
  92. package/src/http/routes/oauth-providers-proxy.ts +2 -2
  93. package/src/http/routes/pair.ts +322 -0
  94. package/src/http/routes/privacy-config.ts +65 -79
  95. package/src/http/routes/runtime-health-proxy.ts +2 -2
  96. package/src/http/routes/runtime-proxy.ts +3 -1
  97. package/src/http/routes/slack-control-plane-proxy.ts +3 -20
  98. package/src/http/routes/stt-stream-websocket.ts +2 -3
  99. package/src/http/routes/telegram-control-plane-proxy.ts +2 -2
  100. package/src/http/routes/telegram-webhook.test.ts +0 -1
  101. package/src/http/routes/telegram-webhook.ts +6 -0
  102. package/src/http/routes/trust-rules.suggest.test.ts +25 -0
  103. package/src/http/routes/trust-rules.ts +7 -0
  104. package/src/http/routes/twilio-control-plane-proxy.ts +2 -2
  105. package/src/http/routes/twilio-media-websocket.ts +5 -5
  106. package/src/http/routes/twilio-voice-verify-callback.ts +310 -0
  107. package/src/http/routes/twilio-voice-webhook.test.ts +65 -1
  108. package/src/http/routes/twilio-voice-webhook.ts +45 -1
  109. package/src/http/routes/whatsapp-webhook.test.ts +0 -1
  110. package/src/index.ts +357 -278
  111. package/src/ipc/assistant-client.ts +8 -4
  112. package/src/ipc/contact-handlers.ts +88 -3
  113. package/src/ipc/threshold-handlers.ts +2 -0
  114. package/src/post-assistant-ready.ts +5 -3
  115. package/src/risk/bash-risk-classifier.test.ts +35 -27
  116. package/src/risk/bash-risk-classifier.ts +44 -14
  117. package/src/risk/command-registry/commands/assistant.ts +8 -19
  118. package/src/risk/command-registry.test.ts +0 -15
  119. package/src/risk/risk-classifier-parity.test.ts +1 -3
  120. package/src/runtime/client.ts +58 -3
  121. package/src/schema.ts +277 -104
  122. package/src/slack/normalize.test.ts +98 -0
  123. package/src/slack/normalize.ts +107 -32
  124. package/src/slack/slack-web.ts +213 -0
  125. package/src/slack/socket-mode.ts +701 -39
  126. package/src/telegram/send.test.ts +0 -1
  127. package/src/twilio/validate-webhook.ts +53 -14
  128. package/src/twilio/webhook-sync-trigger.ts +58 -0
  129. package/src/twilio/webhook-sync.test.ts +286 -0
  130. package/src/twilio/webhook-sync.ts +84 -0
  131. package/src/util/is-loopback-address.ts +27 -0
  132. package/src/velay/bridge-utils.ts +228 -0
  133. package/src/velay/client.test.ts +939 -0
  134. package/src/velay/client.ts +555 -0
  135. package/src/velay/http-bridge.test.ts +217 -0
  136. package/src/velay/http-bridge.ts +83 -0
  137. package/src/velay/protocol.ts +178 -0
  138. package/src/velay/test-fake-websocket.ts +69 -0
  139. package/src/velay/websocket-bridge.test.ts +367 -0
  140. package/src/velay/websocket-bridge.ts +324 -0
  141. package/src/verification/binding-helpers.ts +107 -0
  142. package/src/verification/code-parsing.ts +44 -0
  143. package/src/verification/contact-helpers.ts +342 -0
  144. package/src/verification/identity-match.ts +68 -0
  145. package/src/verification/identity.ts +61 -0
  146. package/src/verification/rate-limit-helpers.ts +205 -0
  147. package/src/verification/reply-delivery.ts +109 -0
  148. package/src/verification/session-helpers.ts +164 -0
  149. package/src/verification/text-verification.ts +372 -0
  150. package/src/version.ts +35 -0
  151. package/src/voice/verification.ts +456 -0
  152. package/src/webhook-pipeline.ts +4 -0
  153. package/src/__tests__/browser-relay-websocket.test.ts +0 -698
  154. package/src/__tests__/telegram-only-default.test.ts +0 -133
  155. package/src/auth/capability-tokens.ts +0 -248
  156. package/src/http/routes/browser-extension-pair.ts +0 -455
  157. package/src/http/routes/browser-relay-websocket.ts +0 -381
  158. package/src/http/routes/config-file-utils.ts +0 -73
  159. package/src/ipc/capability-token-handlers.ts +0 -30
  160. package/src/pairing/approved-devices-store.ts +0 -110
  161. package/src/pairing/pairing-routes.ts +0 -379
  162. 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
+ }
@@ -110,7 +110,6 @@ const baseConfig: GatewayConfig = {
110
110
  routingEntries: [],
111
111
  runtimeInitialBackoffMs: 500,
112
112
  runtimeMaxRetries: 2,
113
- runtimeProxyEnabled: false,
114
113
  runtimeProxyRequireAuth: true,
115
114
  runtimeTimeoutMs: 30000,
116
115
  shutdownDrainMs: 5000,
@@ -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
+ });