@vellumai/vellum-gateway 0.7.1 → 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 (96) hide show
  1. package/AGENTS.md +4 -0
  2. package/ARCHITECTURE.md +62 -20
  3. package/Dockerfile +1 -0
  4. package/README.md +46 -5
  5. package/bun.lock +9 -2
  6. package/knip.json +2 -1
  7. package/package.json +2 -1
  8. package/src/__tests__/auto-approve-thresholds.test.ts +49 -22
  9. package/src/__tests__/config-file-watcher.test.ts +181 -0
  10. package/src/__tests__/credential-watcher.test.ts +20 -2
  11. package/src/__tests__/feature-flags-route.test.ts +3 -3
  12. package/src/__tests__/guardian-init-lockfile.test.ts +24 -0
  13. package/src/__tests__/pair-origin-allowlist.test.ts +155 -0
  14. package/src/__tests__/rate-limit-loopback.test.ts +1 -1
  15. package/src/__tests__/remote-feature-flag-sync.test.ts +47 -7
  16. package/src/__tests__/route-schema-guard.test.ts +42 -6
  17. package/src/__tests__/slack-socket-mode-catchup.test.ts +857 -0
  18. package/src/__tests__/slack-socket-mode-scopes.test.ts +52 -0
  19. package/src/__tests__/slack-socket-mode-thread-tracking.test.ts +24 -0
  20. package/src/__tests__/twilio-webhooks.test.ts +220 -2
  21. package/src/__tests__/upstream-transport.test.ts +0 -36
  22. package/src/auth/guardian-refresh.ts +4 -18
  23. package/src/backup/backup-key.ts +138 -0
  24. package/src/backup/backup-routes.ts +159 -0
  25. package/src/backup/backup-worker.ts +374 -0
  26. package/src/backup/list-snapshots.ts +97 -0
  27. package/src/backup/local-writer.ts +87 -0
  28. package/src/backup/offsite-writer.ts +182 -0
  29. package/src/backup/paths.ts +123 -0
  30. package/src/backup/stream-crypt.ts +258 -0
  31. package/src/chrome-extension-origins.ts +28 -0
  32. package/src/config-file-cache.ts +3 -19
  33. package/src/config-file-utils.ts +124 -0
  34. package/src/config-file-watcher.ts +57 -25
  35. package/src/config.ts +4 -0
  36. package/src/db/contact-store.ts +30 -1
  37. package/src/db/data-migrations/index.ts +2 -0
  38. package/src/db/data-migrations/m0003-recover-backup-key.ts +71 -0
  39. package/src/db/schema.ts +30 -0
  40. package/src/db/slack-store.ts +144 -11
  41. package/src/feature-flag-registry.json +21 -133
  42. package/src/handlers/handle-inbound.ts +90 -0
  43. package/src/http/middleware/auth.ts +1 -1
  44. package/src/http/middleware/cors.ts +84 -0
  45. package/src/http/middleware/rate-limit.ts +6 -8
  46. package/src/http/routes/auto-approve-thresholds.ts +17 -1
  47. package/src/http/routes/channel-verification-session-proxy.ts +17 -35
  48. package/src/http/routes/contact-prompt.ts +149 -0
  49. package/src/http/routes/log-tail.test.ts +336 -0
  50. package/src/http/routes/log-tail.ts +87 -0
  51. package/src/http/routes/pair.ts +322 -0
  52. package/src/http/routes/privacy-config.ts +65 -79
  53. package/src/http/routes/runtime-proxy.ts +3 -1
  54. package/src/http/routes/stt-stream-websocket.ts +2 -3
  55. package/src/http/routes/twilio-media-websocket.ts +5 -5
  56. package/src/http/routes/twilio-voice-verify-callback.ts +30 -2
  57. package/src/http/routes/twilio-voice-webhook.test.ts +60 -0
  58. package/src/http/routes/twilio-voice-webhook.ts +8 -0
  59. package/src/index.ts +327 -246
  60. package/src/ipc/contact-handlers.ts +88 -3
  61. package/src/ipc/threshold-handlers.ts +2 -0
  62. package/src/risk/bash-risk-classifier.test.ts +35 -3
  63. package/src/risk/bash-risk-classifier.ts +44 -14
  64. package/src/risk/command-registry/commands/assistant.ts +5 -0
  65. package/src/risk/risk-classifier-parity.test.ts +1 -1
  66. package/src/runtime/client.ts +58 -3
  67. package/src/schema.ts +220 -67
  68. package/src/slack/normalize.test.ts +24 -0
  69. package/src/slack/normalize.ts +8 -0
  70. package/src/slack/slack-web.ts +213 -0
  71. package/src/slack/socket-mode.ts +520 -20
  72. package/src/twilio/validate-webhook.ts +53 -14
  73. package/src/twilio/webhook-sync-trigger.ts +58 -0
  74. package/src/twilio/webhook-sync.test.ts +286 -0
  75. package/src/twilio/webhook-sync.ts +84 -0
  76. package/src/util/is-loopback-address.ts +27 -0
  77. package/src/velay/bridge-utils.ts +228 -0
  78. package/src/velay/client.test.ts +939 -0
  79. package/src/velay/client.ts +555 -0
  80. package/src/velay/http-bridge.test.ts +217 -0
  81. package/src/velay/http-bridge.ts +83 -0
  82. package/src/velay/protocol.ts +178 -0
  83. package/src/velay/test-fake-websocket.ts +69 -0
  84. package/src/velay/websocket-bridge.test.ts +367 -0
  85. package/src/velay/websocket-bridge.ts +324 -0
  86. package/src/verification/contact-helpers.ts +137 -0
  87. package/src/version.ts +35 -0
  88. package/src/__tests__/browser-relay-websocket.test.ts +0 -697
  89. package/src/auth/capability-tokens.ts +0 -248
  90. package/src/http/routes/browser-extension-pair.ts +0 -455
  91. package/src/http/routes/browser-relay-websocket.ts +0 -381
  92. package/src/http/routes/config-file-utils.ts +0 -73
  93. package/src/ipc/capability-token-handlers.ts +0 -30
  94. package/src/pairing/approved-devices-store.ts +0 -110
  95. package/src/pairing/pairing-routes.ts +0 -379
  96. package/src/pairing/pairing-store.ts +0 -218
@@ -0,0 +1,181 @@
1
+ import { existsSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { afterEach, describe, expect, test } from "bun:test";
4
+
5
+ import {
6
+ ConfigFileWatcher,
7
+ type ConfigChangeEvent,
8
+ } from "../config-file-watcher.js";
9
+ import {
10
+ isOnlyVelayPublicBaseUrlChange,
11
+ shouldSyncTwilioPhoneWebhooksAfterConfigChange,
12
+ } from "../twilio/webhook-sync-trigger.js";
13
+ import { testWorkspaceDir } from "./test-preload.js";
14
+
15
+ const configPath = join(testWorkspaceDir, "config.json");
16
+
17
+ function writeConfig(data: Record<string, unknown>): void {
18
+ writeFileSync(configPath, JSON.stringify(data), "utf-8");
19
+ }
20
+
21
+ function pollOnce(watcher: ConfigFileWatcher): void {
22
+ (
23
+ watcher as unknown as {
24
+ pollOnce: () => void;
25
+ }
26
+ ).pollOnce();
27
+ }
28
+
29
+ function makeEvent(
30
+ changedKeys: string[],
31
+ changedFields: Record<string, string[]>,
32
+ ): ConfigChangeEvent {
33
+ return {
34
+ data: {},
35
+ changedKeys: new Set(changedKeys),
36
+ changedFields: new Map(
37
+ Object.entries(changedFields).map(([section, fields]) => [
38
+ section,
39
+ new Set(fields),
40
+ ]),
41
+ ),
42
+ };
43
+ }
44
+
45
+ afterEach(() => {
46
+ try {
47
+ if (existsSync(configPath)) unlinkSync(configPath);
48
+ } catch {
49
+ // best-effort cleanup
50
+ }
51
+ });
52
+
53
+ describe("ConfigFileWatcher", () => {
54
+ test("reports shallow ingress fields changed by Velay-managed URL writes", () => {
55
+ writeConfig({
56
+ ingress: {
57
+ publicBaseUrl: "https://public.example.test",
58
+ },
59
+ });
60
+ const events: ConfigChangeEvent[] = [];
61
+ const watcher = new ConfigFileWatcher((event) => {
62
+ events.push(event);
63
+ });
64
+
65
+ pollOnce(watcher);
66
+ writeConfig({
67
+ ingress: {
68
+ publicBaseUrl: "https://velay.example.test",
69
+ publicBaseUrlManagedBy: "velay",
70
+ },
71
+ });
72
+ pollOnce(watcher);
73
+
74
+ expect(events).toHaveLength(2);
75
+ expect(events[1].changedKeys).toEqual(new Set(["ingress"]));
76
+ expect(events[1].changedFields.get("ingress")).toEqual(
77
+ new Set(["publicBaseUrl", "publicBaseUrlManagedBy"]),
78
+ );
79
+ });
80
+
81
+ test("reports Twilio-only fields when Velay creates ingress from scratch", () => {
82
+ writeConfig({
83
+ gateway: {
84
+ runtimeProxyRequireAuth: false,
85
+ },
86
+ });
87
+ const events: ConfigChangeEvent[] = [];
88
+ const watcher = new ConfigFileWatcher((event) => {
89
+ events.push(event);
90
+ });
91
+
92
+ pollOnce(watcher);
93
+ writeConfig({
94
+ gateway: {
95
+ runtimeProxyRequireAuth: false,
96
+ },
97
+ ingress: {
98
+ publicBaseUrl: "https://velay.example.test",
99
+ publicBaseUrlManagedBy: "velay",
100
+ },
101
+ });
102
+ pollOnce(watcher);
103
+
104
+ expect(events).toHaveLength(2);
105
+ expect(events[1].changedKeys).toEqual(new Set(["ingress"]));
106
+ expect(events[1].changedFields.get("ingress")).toEqual(
107
+ new Set(["publicBaseUrl", "publicBaseUrlManagedBy"]),
108
+ );
109
+ });
110
+
111
+ test("detects public base URL changes", () => {
112
+ writeConfig({
113
+ ingress: {
114
+ publicBaseUrl: "https://old-public.example.test",
115
+ publicBaseUrlManagedBy: "velay",
116
+ },
117
+ });
118
+ const events: ConfigChangeEvent[] = [];
119
+ const watcher = new ConfigFileWatcher((event) => {
120
+ events.push(event);
121
+ });
122
+
123
+ pollOnce(watcher);
124
+ writeConfig({
125
+ ingress: {
126
+ publicBaseUrl: "https://new-public.example.test",
127
+ publicBaseUrlManagedBy: "velay",
128
+ },
129
+ });
130
+ pollOnce(watcher);
131
+
132
+ expect(events).toHaveLength(2);
133
+ expect(events[1].changedFields.get("ingress")).toEqual(
134
+ new Set(["publicBaseUrl"]),
135
+ );
136
+ });
137
+ });
138
+
139
+ describe("Twilio webhook sync config-change triggers", () => {
140
+ test("syncs when generic public ingress changes without a Twilio override", () => {
141
+ const event = makeEvent(["ingress"], { ingress: ["publicBaseUrl"] });
142
+
143
+ expect(isOnlyVelayPublicBaseUrlChange(event)).toBe(false);
144
+ expect(shouldSyncTwilioPhoneWebhooksAfterConfigChange(event)).toBe(true);
145
+ });
146
+
147
+ test("syncs when the Twilio-specific public ingress changes", () => {
148
+ const event = makeEvent(["ingress"], {
149
+ ingress: ["publicBaseUrl", "publicBaseUrlManagedBy"],
150
+ });
151
+
152
+ expect(isOnlyVelayPublicBaseUrlChange(event)).toBe(true);
153
+ expect(shouldSyncTwilioPhoneWebhooksAfterConfigChange(event)).toBe(true);
154
+ });
155
+
156
+ test("does not sync when only the Velay manager marker changes", () => {
157
+ const event = makeEvent(["ingress"], {
158
+ ingress: ["publicBaseUrlManagedBy"],
159
+ });
160
+
161
+ expect(isOnlyVelayPublicBaseUrlChange(event)).toBe(true);
162
+ expect(shouldSyncTwilioPhoneWebhooksAfterConfigChange(event)).toBe(false);
163
+ });
164
+
165
+ test("syncs when Twilio phone configuration becomes available", () => {
166
+ const event = makeEvent(["twilio"], {
167
+ twilio: ["phoneNumber", "accountSid"],
168
+ });
169
+
170
+ expect(isOnlyVelayPublicBaseUrlChange(event)).toBe(false);
171
+ expect(shouldSyncTwilioPhoneWebhooksAfterConfigChange(event)).toBe(true);
172
+ });
173
+
174
+ test("does not sync when unrelated Twilio configuration changes", () => {
175
+ const event = makeEvent(["twilio"], {
176
+ twilio: ["assistantPhoneNumbers"],
177
+ });
178
+
179
+ expect(shouldSyncTwilioPhoneWebhooksAfterConfigChange(event)).toBe(false);
180
+ });
181
+ });
@@ -17,7 +17,7 @@ import {
17
17
  import { mkdirSync, renameSync, writeFileSync, rmSync } from "node:fs";
18
18
  import { hostname, tmpdir, userInfo } from "node:os";
19
19
  import { dirname, join } from "node:path";
20
- import type { Server } from "node:net";
20
+ import { createServer, type Server } from "node:net";
21
21
  import { fileURLToPath } from "node:url";
22
22
 
23
23
  import { startFakeAssistantIpc } from "./fake-assistant-ipc.js";
@@ -194,8 +194,26 @@ let gatewayProc: ChildProcess | null = null;
194
194
  let port = 0;
195
195
  let fakeAssistantIpc: Server | null = null;
196
196
 
197
+ /** Ask the OS for a free port by briefly binding to port 0. */
198
+ function getFreePort(): Promise<number> {
199
+ return new Promise((resolve, reject) => {
200
+ const srv = createServer();
201
+ srv.listen(0, "127.0.0.1", () => {
202
+ const addr = srv.address();
203
+ if (!addr || typeof addr === "string") {
204
+ srv.close();
205
+ reject(new Error("Failed to get free port"));
206
+ return;
207
+ }
208
+ const p = addr.port;
209
+ srv.close(() => resolve(p));
210
+ });
211
+ srv.on("error", reject);
212
+ });
213
+ }
214
+
197
215
  async function startGateway(): Promise<void> {
198
- port = 49152 + Math.floor(Math.random() * 16383);
216
+ port = await getFreePort();
199
217
 
200
218
  const workspaceDir = join(testDir, ".vellum", "workspace");
201
219
  fakeAssistantIpc = startFakeAssistantIpc(workspaceDir);
@@ -488,12 +488,12 @@ describe("PATCH /v1/feature-flags/:flagKey handler", () => {
488
488
 
489
489
  const handler = createFeatureFlagsPatchHandler();
490
490
  const res = await handler(
491
- new Request("http://gateway.test/v1/feature-flags/browser", {
491
+ new Request("http://gateway.test/v1/feature-flags/email-channel", {
492
492
  method: "PATCH",
493
493
  headers: { "content-type": "application/json" },
494
494
  body: JSON.stringify({ enabled: true }),
495
495
  }),
496
- "browser",
496
+ "email-channel",
497
497
  );
498
498
 
499
499
  expect(res.status).toBe(200);
@@ -501,7 +501,7 @@ describe("PATCH /v1/feature-flags/:flagKey handler", () => {
501
501
 
502
502
  clearFeatureFlagStoreCache();
503
503
  const persisted = readPersistedFeatureFlags();
504
- expect(persisted["browser"]).toBe(true);
504
+ expect(persisted["email-channel"]).toBe(true);
505
505
  });
506
506
 
507
507
  // Validation tests
@@ -583,6 +583,7 @@ describe("guardian/reset-bootstrap", () => {
583
583
  describe("guardian/init bare-metal loopback gating", () => {
584
584
  test("rejects non-loopback clients in bare-metal mode", async () => {
585
585
  delete process.env.GUARDIAN_BOOTSTRAP_SECRET;
586
+ delete process.env.IS_PLATFORM;
586
587
  const handler = createChannelVerificationSessionProxyHandler(makeConfig());
587
588
  const res = await handler.handleGuardianInit(
588
589
  new Request("http://localhost:7830/v1/guardian/init", {
@@ -600,6 +601,7 @@ describe("guardian/init bare-metal loopback gating", () => {
600
601
 
601
602
  test("allows loopback clients in bare-metal mode", async () => {
602
603
  delete process.env.GUARDIAN_BOOTSTRAP_SECRET;
604
+ delete process.env.IS_PLATFORM;
603
605
  const handler = createChannelVerificationSessionProxyHandler(makeConfig());
604
606
  const res = await handler.handleGuardianInit(
605
607
  new Request("http://localhost:7830/v1/guardian/init", {
@@ -634,6 +636,28 @@ describe("guardian/init bare-metal loopback gating", () => {
634
636
  const body = await res.json();
635
637
  expect(body.accessToken).toBeTruthy();
636
638
  });
639
+
640
+ test("skips loopback check in platform-managed mode (IS_PLATFORM=true)", async () => {
641
+ delete process.env.GUARDIAN_BOOTSTRAP_SECRET;
642
+ process.env.IS_PLATFORM = "true";
643
+ try {
644
+ const handler = createChannelVerificationSessionProxyHandler(makeConfig());
645
+ const res = await handler.handleGuardianInit(
646
+ new Request("http://localhost:7830/v1/guardian/init", {
647
+ method: "POST",
648
+ headers: { "Content-Type": "application/json" },
649
+ body: JSON.stringify({ platform: "web", deviceId: "platform-abc123" }),
650
+ }),
651
+ "::ffff:10.112.1.68",
652
+ );
653
+
654
+ expect(res.status).toBe(200);
655
+ const body = await res.json();
656
+ expect(body.accessToken).toBeTruthy();
657
+ } finally {
658
+ delete process.env.IS_PLATFORM;
659
+ }
660
+ });
637
661
  });
638
662
 
639
663
  describe("guardian/init request validation", () => {
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Tests for ATL-433: /v1/pair validates Origin against KNOWN_EXTENSION_ORIGINS,
3
+ * and resolveExtensionOrigin uses the same allowlist.
4
+ */
5
+ import { describe, test, expect, mock, beforeEach } from "bun:test";
6
+
7
+ import { initSigningKey } from "../auth/token-service.js";
8
+
9
+ // Must init signing key before any module that mints tokens is imported.
10
+ initSigningKey(Buffer.from("test-signing-key-at-least-32-bytes-long-xx"));
11
+
12
+ // Mock DB — pair.ts calls resolveLocalGuardianPrincipalId() which queries the DB.
13
+ const mockQuery = mock();
14
+ mock.module("../db/assistant-db-proxy.js", () => ({
15
+ assistantDbQuery: mockQuery,
16
+ assistantDbRun: mock(),
17
+ assistantDbExec: mock(),
18
+ }));
19
+
20
+ const { handlePair, resetPairRateLimiterForTests } = await import(
21
+ "../http/routes/pair.js"
22
+ );
23
+ const { resolveExtensionOrigin } = await import(
24
+ "../http/middleware/cors.js"
25
+ );
26
+ const { KNOWN_EXTENSION_ORIGINS } = await import(
27
+ "../chrome-extension-origins.js"
28
+ );
29
+
30
+ // Simulate a loopback peer IP as supplied by the gateway server to the handler.
31
+ const LOOPBACK_IP = "127.0.0.1";
32
+
33
+ // A valid Vellum extension origin (production).
34
+ const PROD_ORIGIN = "chrome-extension://hphbdmpffeigpcdjkckleobjmhhokpne";
35
+ // A non-Vellum extension origin.
36
+ const MALICIOUS_ORIGIN = "chrome-extension://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
37
+
38
+ function makePairRequest(overrides: {
39
+ method?: string;
40
+ origin?: string | null;
41
+ interfaceId?: string | null;
42
+ xForwardedFor?: string;
43
+ } = {}): Request {
44
+ const { method = "POST", origin, interfaceId = "chrome-extension", xForwardedFor } = overrides;
45
+ const headers: Record<string, string> = {
46
+ "host": "localhost:7830",
47
+ "content-type": "application/json",
48
+ };
49
+ if (origin !== null) {
50
+ headers["origin"] = origin ?? PROD_ORIGIN;
51
+ }
52
+ if (interfaceId !== null) {
53
+ headers["x-vellum-interface-id"] = interfaceId;
54
+ }
55
+ if (xForwardedFor) {
56
+ headers["x-forwarded-for"] = xForwardedFor;
57
+ }
58
+ return new Request("http://localhost:7830/v1/pair", { method, headers });
59
+ }
60
+
61
+ beforeEach(() => {
62
+ resetPairRateLimiterForTests();
63
+ mockQuery.mockResolvedValue([{ principalId: "guardian-001" }]);
64
+ });
65
+
66
+ // ---------------------------------------------------------------------------
67
+ // resolveExtensionOrigin — allowlist behaviour
68
+ // ---------------------------------------------------------------------------
69
+
70
+ describe("resolveExtensionOrigin", () => {
71
+ test("returns origin for every known Vellum extension ID", () => {
72
+ for (const origin of KNOWN_EXTENSION_ORIGINS) {
73
+ const req = new Request("http://localhost:7830/v1/events", {
74
+ headers: { origin },
75
+ });
76
+ expect(resolveExtensionOrigin(req)).toBe(origin);
77
+ }
78
+ });
79
+
80
+ test("returns null for an unknown chrome-extension:// origin", () => {
81
+ const req = new Request("http://localhost:7830/v1/events", {
82
+ headers: { origin: MALICIOUS_ORIGIN },
83
+ });
84
+ expect(resolveExtensionOrigin(req)).toBeNull();
85
+ });
86
+
87
+ test("returns null when Origin header is absent", () => {
88
+ const req = new Request("http://localhost:7830/v1/events");
89
+ expect(resolveExtensionOrigin(req)).toBeNull();
90
+ });
91
+
92
+ test("returns null for a non-extension origin (web page)", () => {
93
+ const req = new Request("http://localhost:7830/v1/events", {
94
+ headers: { origin: "https://evil.example.com" },
95
+ });
96
+ expect(resolveExtensionOrigin(req)).toBeNull();
97
+ });
98
+ });
99
+
100
+ // ---------------------------------------------------------------------------
101
+ // /v1/pair — Origin check for chrome-extension interface
102
+ // ---------------------------------------------------------------------------
103
+
104
+ describe("handlePair — Origin allowlist", () => {
105
+ test("pairs successfully with a known prod extension origin", async () => {
106
+ const req = makePairRequest({ origin: PROD_ORIGIN });
107
+ const res = await handlePair(req, LOOPBACK_IP);
108
+ expect(res.status).toBe(200);
109
+ const body = await res.json() as Record<string, unknown>;
110
+ expect(typeof body.token).toBe("string");
111
+ });
112
+
113
+ test("pairs successfully with each known extension origin", async () => {
114
+ for (const origin of KNOWN_EXTENSION_ORIGINS) {
115
+ resetPairRateLimiterForTests();
116
+ const req = makePairRequest({ origin });
117
+ const res = await handlePair(req, LOOPBACK_IP);
118
+ expect(res.status).toBe(200);
119
+ }
120
+ });
121
+
122
+ test("rejects a request from an unknown extension origin with 403", async () => {
123
+ const req = makePairRequest({ origin: MALICIOUS_ORIGIN });
124
+ const res = await handlePair(req, LOOPBACK_IP);
125
+ expect(res.status).toBe(403);
126
+ const body = await res.json() as Record<string, unknown>;
127
+ expect((body.error as Record<string, unknown>).code).toBe("FORBIDDEN");
128
+ });
129
+
130
+ test("rejects a request with no Origin header with 403", async () => {
131
+ const req = makePairRequest({ origin: null });
132
+ const res = await handlePair(req, LOOPBACK_IP);
133
+ expect(res.status).toBe(403);
134
+ });
135
+
136
+ test("still rejects non-loopback callers regardless of origin", async () => {
137
+ const req = makePairRequest({ origin: PROD_ORIGIN });
138
+ const res = await handlePair(req, "8.8.8.8");
139
+ expect(res.status).toBe(403);
140
+ const body = await res.json() as Record<string, unknown>;
141
+ expect((body.error as Record<string, unknown>).code).toBe("FORBIDDEN");
142
+ });
143
+
144
+ test("still rejects requests with X-Forwarded-For regardless of origin", async () => {
145
+ const req = makePairRequest({ origin: PROD_ORIGIN, xForwardedFor: "1.2.3.4" });
146
+ const res = await handlePair(req, LOOPBACK_IP);
147
+ expect(res.status).toBe(403);
148
+ });
149
+
150
+ test("still rejects unknown interface IDs (unrelated to origin check)", async () => {
151
+ const req = makePairRequest({ origin: PROD_ORIGIN, interfaceId: "unknown-client" });
152
+ const res = await handlePair(req, LOOPBACK_IP);
153
+ expect(res.status).toBe(400);
154
+ });
155
+ });
@@ -49,7 +49,7 @@ describe("checkAuthRateLimit loopback exemption", () => {
49
49
  const ip = "203.0.113.5";
50
50
  const limiter = blockedLimiter(ip);
51
51
  const res = checkAuthRateLimit(
52
- new URL("http://local/v1/browser-relay"),
52
+ new URL("http://local/health"),
53
53
  limiter,
54
54
  ip,
55
55
  );
@@ -1,5 +1,6 @@
1
1
  import { describe, test, expect, mock, beforeEach, afterEach } from "bun:test";
2
- import { mkdirSync, rmSync } from "node:fs";
2
+ import { mkdirSync, rmSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
3
4
 
4
5
  import type { CredentialCache } from "../credential-cache.js";
5
6
 
@@ -32,6 +33,37 @@ const { RemoteFeatureFlagSync } =
32
33
  await import("../remote-feature-flag-sync.js");
33
34
  const { readRemoteFeatureFlags, clearRemoteFeatureFlagStoreCache } =
34
35
  await import("../feature-flag-remote-store.js");
36
+ const { resetFeatureFlagDefaultsCache, _setRegistryCandidateOverrides } =
37
+ await import("../feature-flag-defaults.js");
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // Test-local registry with a GA flag (defaultEnabled: true) for the
41
+ // "ignores remote false for GA flags" test. Written to an isolated temp path
42
+ // so we never touch the committed registry file.
43
+ // ---------------------------------------------------------------------------
44
+ const testRegistryPath = join(protectedDir, "feature-flag-registry.json");
45
+
46
+ const TEST_REGISTRY = {
47
+ version: 1,
48
+ flags: [
49
+ {
50
+ id: "test-ga-flag",
51
+ scope: "assistant",
52
+ key: "test-ga-flag",
53
+ label: "Test GA Flag",
54
+ description: "A test flag that is GA (defaultEnabled: true)",
55
+ defaultEnabled: true,
56
+ },
57
+ {
58
+ id: "email-channel",
59
+ scope: "assistant",
60
+ key: "email-channel",
61
+ label: "Email Channel",
62
+ description: "Email channel integration",
63
+ defaultEnabled: false,
64
+ },
65
+ ],
66
+ };
35
67
 
36
68
  // ---------------------------------------------------------------------------
37
69
  // Helpers
@@ -92,6 +124,10 @@ beforeEach(() => {
92
124
  delete process.env.VELLUM_PLATFORM_URL;
93
125
  delete process.env.PLATFORM_INTERNAL_API_KEY;
94
126
  mkdirSync(protectedDir, { recursive: true });
127
+ // Write the test registry and point resolution at it
128
+ writeFileSync(testRegistryPath, JSON.stringify(TEST_REGISTRY, null, 2));
129
+ _setRegistryCandidateOverrides([testRegistryPath]);
130
+ resetFeatureFlagDefaultsCache();
95
131
  clearRemoteFeatureFlagStoreCache();
96
132
  fetchMock = mock(async () => new Response());
97
133
  });
@@ -113,6 +149,8 @@ afterEach(() => {
113
149
  } catch {
114
150
  // best effort cleanup
115
151
  }
152
+ _setRegistryCandidateOverrides(null);
153
+ resetFeatureFlagDefaultsCache();
116
154
  clearRemoteFeatureFlagStoreCache();
117
155
  });
118
156
 
@@ -423,15 +461,17 @@ describe("RemoteFeatureFlagSync", () => {
423
461
  // The platform sends false for all flags it knows about (blanket-deny).
424
462
  // GA flags (defaultEnabled: true in the registry) should not be disabled
425
463
  // by remote overrides — only local persisted overrides can do that.
464
+ // Uses the test-local registry which defines test-ga-flag as GA
465
+ // (defaultEnabled: true) and email-channel as gated (defaultEnabled: false).
426
466
  fetchMock = mock(async () =>
427
467
  Response.json({
428
468
  flags: {
429
469
  // GA flag (defaultEnabled: true) — remote false should be dropped
430
- "conversation-starters": false,
470
+ "test-ga-flag": false,
431
471
  // Gated flag (defaultEnabled: false) — remote false is kept
432
472
  "email-channel": false,
433
473
  // GA flag set to true — should be kept (redundant but harmless)
434
- browser: true,
474
+ "test-ga-flag-true": true,
435
475
  // Unknown flag — remote false is kept (not in registry)
436
476
  "unknown-flag": false,
437
477
  },
@@ -446,12 +486,12 @@ describe("RemoteFeatureFlagSync", () => {
446
486
 
447
487
  clearRemoteFeatureFlagStoreCache();
448
488
  const cached = readRemoteFeatureFlags();
449
- // conversation-starters (GA, remote false) should be absent
450
- expect(cached["conversation-starters"]).toBeUndefined();
489
+ // test-ga-flag (GA, remote false) should be absent
490
+ expect(cached["test-ga-flag"]).toBeUndefined();
451
491
  // email-channel (gated, remote false) should be present
452
492
  expect(cached["email-channel"]).toBe(false);
453
- // browser (GA, remote true) should be present
454
- expect(cached.browser).toBe(true);
493
+ // test-ga-flag-true (unknown but true) should be present
494
+ expect(cached["test-ga-flag-true"]).toBe(true);
455
495
  // unknown-flag (not in registry, remote false) should be present
456
496
  expect(cached["unknown-flag"]).toBe(false);
457
497
  });
@@ -1,6 +1,13 @@
1
1
  import { describe, test, expect } from "bun:test";
2
2
  import { readFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
+ import {
5
+ TWILIO_CONNECT_ACTION_WEBHOOK_PATH,
6
+ TWILIO_MEDIA_STREAM_WEBHOOK_PATH,
7
+ TWILIO_RELAY_WEBHOOK_PATH,
8
+ TWILIO_STATUS_WEBHOOK_PATH,
9
+ TWILIO_VOICE_WEBHOOK_PATH,
10
+ } from "@vellumai/service-contracts/twilio-ingress";
4
11
  import { buildSchema } from "../schema.js";
5
12
 
6
13
  /** A route extracted from source: path + optional HTTP method. */
@@ -9,6 +16,14 @@ interface ExtractedRoute {
9
16
  method: string | null; // null means "any method"
10
17
  }
11
18
 
19
+ const ROUTE_PATH_CONSTANTS: Record<string, string> = {
20
+ TWILIO_CONNECT_ACTION_WEBHOOK_PATH,
21
+ TWILIO_MEDIA_STREAM_WEBHOOK_PATH,
22
+ TWILIO_RELAY_WEBHOOK_PATH,
23
+ TWILIO_STATUS_WEBHOOK_PATH,
24
+ TWILIO_VOICE_WEBHOOK_PATH,
25
+ };
26
+
12
27
  /**
13
28
  * Extracts route paths from the gateway index.ts source code.
14
29
  *
@@ -40,6 +55,17 @@ function extractRoutesFromSource(): ExtractedRoute[] {
40
55
  continue;
41
56
  }
42
57
 
58
+ // Match shared path constants: `path: SOME_WEBHOOK_PATH`
59
+ const constantMatch = line.match(/path:\s*([A-Z0-9_]+)\b/);
60
+ const constantPath = constantMatch
61
+ ? ROUTE_PATH_CONSTANTS[constantMatch[1]]
62
+ : undefined;
63
+ if (constantPath) {
64
+ const method = findMethodNearPath(lines, i);
65
+ routes.push({ path: constantPath, method });
66
+ continue;
67
+ }
68
+
43
69
  // Match regex paths: `path: /^\/v1\/contacts\/([^/]+)$/`
44
70
  const regexMatch = line.match(/path:\s*\/\^(.*?)\$\//);
45
71
  if (regexMatch) {
@@ -57,6 +83,20 @@ function extractRoutesFromSource(): ExtractedRoute[] {
57
83
  seenPreRouterPaths.add(preRouterMatch[1]);
58
84
  routes.push({ path: preRouterMatch[1], method: null });
59
85
  }
86
+
87
+ const preRouterConstantMatch = line.match(
88
+ /url\.pathname\s*===\s*([A-Z0-9_]+)\b/,
89
+ );
90
+ const preRouterConstantPath = preRouterConstantMatch
91
+ ? ROUTE_PATH_CONSTANTS[preRouterConstantMatch[1]]
92
+ : undefined;
93
+ if (
94
+ preRouterConstantPath &&
95
+ !seenPreRouterPaths.has(preRouterConstantPath)
96
+ ) {
97
+ seenPreRouterPaths.add(preRouterConstantPath);
98
+ routes.push({ path: preRouterConstantPath, method: null });
99
+ }
60
100
  }
61
101
 
62
102
  return routes;
@@ -125,14 +165,10 @@ function regexToOpenApiPath(escaped: string): string | null {
125
165
  // ── Routes that are intentionally undocumented in the OpenAPI schema ──
126
166
  // Each entry must have a comment explaining why it's excluded.
127
167
  const EXCLUDED_FROM_SCHEMA = new Set([
128
- // Browser relay WebSocket upgrade — handled pre-router, not a REST endpoint
129
- "/v1/browser-relay",
130
-
131
- // Browser extension pairing — localhost-only, no external consumers
132
- "/v1/browser-extension-pair",
133
-
134
168
  // Runtime proxy catch-all — documented as /{path} in the schema
135
169
  "catch-all",
170
+ // Loopback-only pairing endpoint — not part of the public gateway API
171
+ "/v1/pair",
136
172
  ]);
137
173
 
138
174
  // ── Schema paths that don't map to a discrete route definition ──