@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.
- package/AGENTS.md +4 -0
- package/ARCHITECTURE.md +62 -20
- package/Dockerfile +1 -0
- package/README.md +46 -5
- package/bun.lock +9 -2
- package/knip.json +2 -1
- package/package.json +2 -1
- package/src/__tests__/auto-approve-thresholds.test.ts +49 -22
- package/src/__tests__/config-file-watcher.test.ts +181 -0
- package/src/__tests__/credential-watcher.test.ts +20 -2
- package/src/__tests__/feature-flags-route.test.ts +3 -3
- package/src/__tests__/guardian-init-lockfile.test.ts +24 -0
- 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__/route-schema-guard.test.ts +42 -6
- 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 +24 -0
- package/src/__tests__/twilio-webhooks.test.ts +220 -2
- package/src/__tests__/upstream-transport.test.ts +0 -36
- package/src/auth/guardian-refresh.ts +4 -18
- 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/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 -0
- 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 +30 -0
- package/src/db/slack-store.ts +144 -11
- package/src/feature-flag-registry.json +21 -133
- package/src/handlers/handle-inbound.ts +90 -0
- package/src/http/middleware/auth.ts +1 -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/channel-verification-session-proxy.ts +17 -35
- package/src/http/routes/contact-prompt.ts +149 -0
- package/src/http/routes/log-tail.test.ts +336 -0
- package/src/http/routes/log-tail.ts +87 -0
- package/src/http/routes/pair.ts +322 -0
- package/src/http/routes/privacy-config.ts +65 -79
- package/src/http/routes/runtime-proxy.ts +3 -1
- package/src/http/routes/stt-stream-websocket.ts +2 -3
- package/src/http/routes/twilio-media-websocket.ts +5 -5
- package/src/http/routes/twilio-voice-verify-callback.ts +30 -2
- package/src/http/routes/twilio-voice-webhook.test.ts +60 -0
- package/src/http/routes/twilio-voice-webhook.ts +8 -0
- package/src/index.ts +327 -246
- package/src/ipc/contact-handlers.ts +88 -3
- package/src/ipc/threshold-handlers.ts +2 -0
- package/src/risk/bash-risk-classifier.test.ts +35 -3
- package/src/risk/bash-risk-classifier.ts +44 -14
- package/src/risk/command-registry/commands/assistant.ts +5 -0
- package/src/risk/risk-classifier-parity.test.ts +1 -1
- package/src/runtime/client.ts +58 -3
- package/src/schema.ts +220 -67
- package/src/slack/normalize.test.ts +24 -0
- package/src/slack/normalize.ts +8 -0
- package/src/slack/slack-web.ts +213 -0
- package/src/slack/socket-mode.ts +520 -20
- 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/contact-helpers.ts +137 -0
- package/src/version.ts +35 -0
- package/src/__tests__/browser-relay-websocket.test.ts +0 -697
- 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
|
@@ -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
|
|
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 =
|
|
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/
|
|
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
|
-
"
|
|
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["
|
|
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/
|
|
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
|
-
"
|
|
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
|
-
|
|
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
|
-
//
|
|
450
|
-
expect(cached["
|
|
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
|
-
//
|
|
454
|
-
expect(cached
|
|
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 ──
|