@vellumai/vellum-gateway 0.5.16 → 0.6.1
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/Dockerfile +1 -1
- package/package.json +1 -1
- package/src/__tests__/config.test.ts +2 -1
- package/src/__tests__/credential-watcher-managed-bootstrap.test.ts +54 -0
- package/src/__tests__/feature-flags-route.test.ts +57 -29
- package/src/__tests__/remote-feature-flag-sync.test.ts +123 -6
- package/src/__tests__/telegram-webhook-manager.test.ts +189 -0
- package/src/channels/inbound-event.ts +4 -2
- package/src/channels/transport-hints.ts +18 -0
- package/src/config.ts +4 -1
- package/src/credential-watcher.ts +30 -3
- package/src/download-validation.test.ts +96 -0
- package/src/download-validation.ts +92 -0
- package/src/email/normalize.test.ts +129 -0
- package/src/email/normalize.ts +94 -0
- package/src/email/verify.test.ts +96 -0
- package/src/email/verify.ts +41 -0
- package/src/feature-flag-registry.json +38 -125
- package/src/http/routes/browser-relay-websocket.ts +1 -22
- package/src/http/routes/channel-verification-session-proxy.ts +18 -2
- package/src/http/routes/email-webhook.test.ts +393 -0
- package/src/http/routes/email-webhook.ts +243 -0
- package/src/http/routes/log-export.test.ts +530 -0
- package/src/http/routes/log-export.ts +494 -0
- package/src/http/routes/runtime-proxy.ts +7 -1
- package/src/http/routes/telegram-webhook.ts +21 -1
- package/src/http/routes/whatsapp-webhook.ts +28 -1
- package/src/index.ts +60 -33
- package/src/logger.ts +21 -6
- package/src/remote-feature-flag-sync.ts +36 -11
- package/src/schema.ts +150 -1
- package/src/slack/download.test.ts +81 -10
- package/src/slack/download.ts +23 -1
- package/src/slack/normalize.test.ts +97 -0
- package/src/slack/normalize.ts +4 -2
- package/src/slack/socket-mode.ts +10 -0
- package/src/telegram/download.ts +3 -0
- package/src/telegram/webhook-manager.ts +33 -13
- package/src/types.ts +1 -0
- package/src/util/is-loopback-address.ts +21 -0
- package/src/whatsapp/download.ts +3 -0
package/Dockerfile
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
# Bun binary source (pinned to SHA digest for immutable reference)
|
|
2
|
-
FROM oven/bun:1.
|
|
2
|
+
FROM oven/bun:1.3.11@sha256:0733e50325078969732ebe3b15ce4c4be5082f18c4ac1a0f0ca4839c2e4e42a7 AS bun
|
|
3
3
|
|
|
4
4
|
# Build stage
|
|
5
5
|
FROM debian:trixie@sha256:3615a749858a1cba49b408fb49c37093db813321355a9ab7c1f9f4836341e9db AS builder
|
package/package.json
CHANGED
|
@@ -23,7 +23,8 @@ describe("config: hardcoded defaults", () => {
|
|
|
23
23
|
expect(config.unmappedPolicy).toBe("reject");
|
|
24
24
|
expect(config.routingEntries).toEqual([]);
|
|
25
25
|
expect(config.defaultAssistantId).toBeUndefined();
|
|
26
|
-
expect(config.logFile).
|
|
26
|
+
expect(config.logFile.dir).toMatch(/\.vellum\/logs$/);
|
|
27
|
+
expect(config.logFile.retentionDays).toBe(30);
|
|
27
28
|
});
|
|
28
29
|
|
|
29
30
|
test("GATEWAY_PORT defaults to 7830", () => {
|
|
@@ -221,4 +221,58 @@ describe("gateway managed credential bootstrap retry", () => {
|
|
|
221
221
|
|
|
222
222
|
expect(status).toBe(401);
|
|
223
223
|
}, 20_000);
|
|
224
|
+
|
|
225
|
+
test("detects new Slack credentials when metadata is written after startup with no initial channel services", async () => {
|
|
226
|
+
// Start with NO channel service metadata — only non-channel credentials
|
|
227
|
+
// (simulates a fresh assistant that has platform credentials but no channels).
|
|
228
|
+
mkdirSync(testDir, { recursive: true });
|
|
229
|
+
writeCredentialMetadata([
|
|
230
|
+
metadataRecord("api-key-1", "vellum", "assistant_api_key"),
|
|
231
|
+
]);
|
|
232
|
+
|
|
233
|
+
startFakeCes({
|
|
234
|
+
credentials: {
|
|
235
|
+
"credential/vellum/assistant_api_key": "fake-api-key",
|
|
236
|
+
"credential/slack_channel/bot_token": "xoxb-fake-bot-token",
|
|
237
|
+
"credential/slack_channel/app_token": "xapp-fake-app-token",
|
|
238
|
+
},
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
await startGateway();
|
|
242
|
+
|
|
243
|
+
// Slack deliver should be 503 since no Slack credentials are configured.
|
|
244
|
+
const base = `http://localhost:${gatewayPort}`;
|
|
245
|
+
const before = await fetch(`${base}/deliver/slack`, {
|
|
246
|
+
method: "POST",
|
|
247
|
+
headers: { "content-type": "application/json" },
|
|
248
|
+
});
|
|
249
|
+
expect(before.status).toBe(503);
|
|
250
|
+
|
|
251
|
+
// Now write Slack channel metadata (simulating the daemon storing
|
|
252
|
+
// credentials after the user completes the Slack setup flow).
|
|
253
|
+
writeCredentialMetadata([
|
|
254
|
+
metadataRecord("api-key-1", "vellum", "assistant_api_key"),
|
|
255
|
+
metadataRecord("bt-1", "slack_channel", "bot_token"),
|
|
256
|
+
metadataRecord("at-1", "slack_channel", "app_token"),
|
|
257
|
+
]);
|
|
258
|
+
|
|
259
|
+
// The managed bootstrap retry should still be polling (since no channel
|
|
260
|
+
// services were configured at startup), so the new metadata should be
|
|
261
|
+
// detected within a few seconds even without fs.watch() working.
|
|
262
|
+
const deadline = Date.now() + 5_000;
|
|
263
|
+
let status = before.status;
|
|
264
|
+
while (Date.now() < deadline) {
|
|
265
|
+
const resp = await fetch(`${base}/deliver/slack`, {
|
|
266
|
+
method: "POST",
|
|
267
|
+
headers: { "content-type": "application/json" },
|
|
268
|
+
});
|
|
269
|
+
status = resp.status;
|
|
270
|
+
// 401 means Slack is configured but the deliver auth check failed
|
|
271
|
+
// (which is expected since we didn't provide a valid bearer token).
|
|
272
|
+
if (status === 401) break;
|
|
273
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
expect(status).toBe(401);
|
|
277
|
+
}, 20_000);
|
|
224
278
|
});
|
|
@@ -39,11 +39,11 @@ const TEST_REGISTRY = {
|
|
|
39
39
|
defaultEnabled: true,
|
|
40
40
|
},
|
|
41
41
|
{
|
|
42
|
-
id: "
|
|
42
|
+
id: "email-channel",
|
|
43
43
|
scope: "assistant",
|
|
44
|
-
key: "
|
|
45
|
-
label: "
|
|
46
|
-
description: "
|
|
44
|
+
key: "email-channel",
|
|
45
|
+
label: "Email Channel",
|
|
46
|
+
description: "Email channel integration",
|
|
47
47
|
defaultEnabled: false,
|
|
48
48
|
},
|
|
49
49
|
{
|
|
@@ -262,19 +262,19 @@ describe("GET /v1/feature-flags handler", () => {
|
|
|
262
262
|
});
|
|
263
263
|
|
|
264
264
|
test("remote values fill in when no local override exists", async () => {
|
|
265
|
-
// Write a remote store with
|
|
265
|
+
// Write a remote store with email-channel enabled (overriding registry default of false)
|
|
266
266
|
writeFileSync(
|
|
267
267
|
remoteFeatureFlagStorePath,
|
|
268
268
|
JSON.stringify({
|
|
269
269
|
version: 1,
|
|
270
270
|
values: {
|
|
271
|
-
|
|
271
|
+
"email-channel": true,
|
|
272
272
|
},
|
|
273
273
|
}),
|
|
274
274
|
);
|
|
275
275
|
clearRemoteFeatureFlagStoreCache();
|
|
276
276
|
|
|
277
|
-
// No local override for
|
|
277
|
+
// No local override for email-channel
|
|
278
278
|
if (existsSync(featureFlagStorePath)) {
|
|
279
279
|
rmSync(featureFlagStorePath);
|
|
280
280
|
}
|
|
@@ -288,12 +288,12 @@ describe("GET /v1/feature-flags handler", () => {
|
|
|
288
288
|
expect(res.status).toBe(200);
|
|
289
289
|
const body = await res.json();
|
|
290
290
|
|
|
291
|
-
const
|
|
292
|
-
(f: { key: string }) => f.key === "
|
|
291
|
+
const emailFlag = body.flags.find(
|
|
292
|
+
(f: { key: string }) => f.key === "email-channel",
|
|
293
293
|
);
|
|
294
|
-
expect(
|
|
294
|
+
expect(emailFlag).toBeDefined();
|
|
295
295
|
// Remote value (true) overrides registry default (false)
|
|
296
|
-
expect(
|
|
296
|
+
expect(emailFlag.enabled).toBe(true);
|
|
297
297
|
});
|
|
298
298
|
|
|
299
299
|
test("local overrides take precedence over remote values", async () => {
|
|
@@ -303,7 +303,7 @@ describe("GET /v1/feature-flags handler", () => {
|
|
|
303
303
|
JSON.stringify({
|
|
304
304
|
version: 1,
|
|
305
305
|
values: {
|
|
306
|
-
|
|
306
|
+
"email-channel": true,
|
|
307
307
|
},
|
|
308
308
|
}),
|
|
309
309
|
);
|
|
@@ -315,7 +315,7 @@ describe("GET /v1/feature-flags handler", () => {
|
|
|
315
315
|
JSON.stringify({
|
|
316
316
|
version: 1,
|
|
317
317
|
values: {
|
|
318
|
-
|
|
318
|
+
"email-channel": false,
|
|
319
319
|
},
|
|
320
320
|
}),
|
|
321
321
|
);
|
|
@@ -329,12 +329,12 @@ describe("GET /v1/feature-flags handler", () => {
|
|
|
329
329
|
expect(res.status).toBe(200);
|
|
330
330
|
const body = await res.json();
|
|
331
331
|
|
|
332
|
-
const
|
|
333
|
-
(f: { key: string }) => f.key === "
|
|
332
|
+
const emailFlag = body.flags.find(
|
|
333
|
+
(f: { key: string }) => f.key === "email-channel",
|
|
334
334
|
);
|
|
335
|
-
expect(
|
|
335
|
+
expect(emailFlag).toBeDefined();
|
|
336
336
|
// Local override (false) takes precedence over remote (true)
|
|
337
|
-
expect(
|
|
337
|
+
expect(emailFlag.enabled).toBe(false);
|
|
338
338
|
});
|
|
339
339
|
|
|
340
340
|
test("registry default used when neither local nor remote is set", async () => {
|
|
@@ -358,13 +358,13 @@ describe("GET /v1/feature-flags handler", () => {
|
|
|
358
358
|
expect(res.status).toBe(200);
|
|
359
359
|
const body = await res.json();
|
|
360
360
|
|
|
361
|
-
//
|
|
362
|
-
const
|
|
363
|
-
(f: { key: string }) => f.key === "
|
|
361
|
+
// email-channel has defaultEnabled: false in registry
|
|
362
|
+
const emailFlag = body.flags.find(
|
|
363
|
+
(f: { key: string }) => f.key === "email-channel",
|
|
364
364
|
);
|
|
365
|
-
expect(
|
|
366
|
-
expect(
|
|
367
|
-
expect(
|
|
365
|
+
expect(emailFlag).toBeDefined();
|
|
366
|
+
expect(emailFlag.enabled).toBe(false);
|
|
367
|
+
expect(emailFlag.defaultEnabled).toBe(false);
|
|
368
368
|
|
|
369
369
|
// browser has defaultEnabled: true in registry
|
|
370
370
|
const browserFlag = body.flags.find(
|
|
@@ -374,6 +374,34 @@ describe("GET /v1/feature-flags handler", () => {
|
|
|
374
374
|
expect(browserFlag.enabled).toBe(true);
|
|
375
375
|
expect(browserFlag.defaultEnabled).toBe(true);
|
|
376
376
|
});
|
|
377
|
+
|
|
378
|
+
test("returns flags when invoked via assistants path without trailing slash", async () => {
|
|
379
|
+
// The macOS client sends GET /v1/assistants/<id>/feature-flags (no trailing slash).
|
|
380
|
+
// The gateway route regex must accept this path.
|
|
381
|
+
const handler = createFeatureFlagsGetHandler();
|
|
382
|
+
const res = await handler(
|
|
383
|
+
new Request(
|
|
384
|
+
"http://gateway.test/v1/assistants/some-assistant-id/feature-flags",
|
|
385
|
+
),
|
|
386
|
+
);
|
|
387
|
+
|
|
388
|
+
expect(res.status).toBe(200);
|
|
389
|
+
const body = await res.json();
|
|
390
|
+
|
|
391
|
+
// Should return all assistant-scope flags with expected shape
|
|
392
|
+
expect(body.flags.length).toBeGreaterThan(0);
|
|
393
|
+
for (const flag of body.flags) {
|
|
394
|
+
expect(typeof flag.key).toBe("string");
|
|
395
|
+
expect(typeof flag.enabled).toBe("boolean");
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// Verify a known flag is present
|
|
399
|
+
const browserFlag = body.flags.find(
|
|
400
|
+
(f: { key: string }) => f.key === "browser",
|
|
401
|
+
);
|
|
402
|
+
expect(browserFlag).toBeDefined();
|
|
403
|
+
expect(browserFlag.enabled).toBe(true);
|
|
404
|
+
});
|
|
377
405
|
});
|
|
378
406
|
|
|
379
407
|
describe("PATCH /v1/feature-flags/:flagKey handler", () => {
|
|
@@ -408,7 +436,7 @@ describe("PATCH /v1/feature-flags/:flagKey handler", () => {
|
|
|
408
436
|
JSON.stringify({
|
|
409
437
|
version: 1,
|
|
410
438
|
values: {
|
|
411
|
-
|
|
439
|
+
"email-channel": true,
|
|
412
440
|
},
|
|
413
441
|
}),
|
|
414
442
|
);
|
|
@@ -427,7 +455,7 @@ describe("PATCH /v1/feature-flags/:flagKey handler", () => {
|
|
|
427
455
|
// Both old and new values should be persisted
|
|
428
456
|
clearFeatureFlagStoreCache();
|
|
429
457
|
const persisted = readPersistedFeatureFlags();
|
|
430
|
-
expect(persisted["
|
|
458
|
+
expect(persisted["email-channel"]).toBe(true);
|
|
431
459
|
expect(persisted["browser"]).toBe(true);
|
|
432
460
|
});
|
|
433
461
|
|
|
@@ -543,7 +571,7 @@ describe("PATCH /v1/feature-flags/:flagKey handler", () => {
|
|
|
543
571
|
test("accepts valid declared kebab-case key formats", async () => {
|
|
544
572
|
const handler = createFeatureFlagsPatchHandler();
|
|
545
573
|
|
|
546
|
-
const validKeys = ["browser", "
|
|
574
|
+
const validKeys = ["browser", "email-channel"];
|
|
547
575
|
|
|
548
576
|
for (const key of validKeys) {
|
|
549
577
|
clearFeatureFlagStoreCache();
|
|
@@ -614,7 +642,7 @@ describe("PATCH /v1/feature-flags/:flagKey handler", () => {
|
|
|
614
642
|
featureFlagStorePath,
|
|
615
643
|
JSON.stringify({
|
|
616
644
|
version: 1,
|
|
617
|
-
values: {
|
|
645
|
+
values: { "email-channel": true },
|
|
618
646
|
}),
|
|
619
647
|
);
|
|
620
648
|
clearFeatureFlagStoreCache();
|
|
@@ -633,7 +661,7 @@ describe("PATCH /v1/feature-flags/:flagKey handler", () => {
|
|
|
633
661
|
const raw = readFileSync(featureFlagStorePath, "utf-8");
|
|
634
662
|
const data = JSON.parse(raw);
|
|
635
663
|
expect(data.version).toBe(1);
|
|
636
|
-
expect(data.values["
|
|
664
|
+
expect(data.values["email-channel"]).toBe(true);
|
|
637
665
|
expect(data.values["browser"]).toBe(false);
|
|
638
666
|
|
|
639
667
|
// Verify no temp files left behind
|
|
@@ -647,7 +675,7 @@ describe("PATCH /v1/feature-flags/:flagKey handler", () => {
|
|
|
647
675
|
const handler = createFeatureFlagsPatchHandler();
|
|
648
676
|
|
|
649
677
|
// Fire multiple concurrent PATCH requests at the same time
|
|
650
|
-
const flagKeys = ["browser", "
|
|
678
|
+
const flagKeys = ["browser", "email-channel"];
|
|
651
679
|
|
|
652
680
|
const results = await Promise.all(
|
|
653
681
|
flagKeys.map((key) =>
|
|
@@ -69,8 +69,17 @@ function defaultCredentials(): Record<string, string> {
|
|
|
69
69
|
// ---------------------------------------------------------------------------
|
|
70
70
|
// Setup / teardown
|
|
71
71
|
// ---------------------------------------------------------------------------
|
|
72
|
+
const savedVellumPlatformUrl = process.env.VELLUM_PLATFORM_URL;
|
|
73
|
+
const savedPlatformAssistantId = process.env.PLATFORM_ASSISTANT_ID;
|
|
74
|
+
const savedPlatformInternalApiKey = process.env.PLATFORM_INTERNAL_API_KEY;
|
|
75
|
+
|
|
72
76
|
beforeEach(() => {
|
|
73
77
|
process.env.BASE_DATA_DIR = testDir;
|
|
78
|
+
// Clear env vars that the production code falls back to, so tests remain
|
|
79
|
+
// deterministic unless they explicitly set them.
|
|
80
|
+
delete process.env.VELLUM_PLATFORM_URL;
|
|
81
|
+
delete process.env.PLATFORM_ASSISTANT_ID;
|
|
82
|
+
delete process.env.PLATFORM_INTERNAL_API_KEY;
|
|
74
83
|
mkdirSync(protectedDir, { recursive: true });
|
|
75
84
|
clearRemoteFeatureFlagStoreCache();
|
|
76
85
|
fetchMock = mock(async () => new Response());
|
|
@@ -82,6 +91,17 @@ afterEach(() => {
|
|
|
82
91
|
} else {
|
|
83
92
|
process.env.BASE_DATA_DIR = savedBaseDataDir;
|
|
84
93
|
}
|
|
94
|
+
// Restore env vars
|
|
95
|
+
const restoreEnv = (key: string, saved: string | undefined): void => {
|
|
96
|
+
if (saved === undefined) {
|
|
97
|
+
delete process.env[key];
|
|
98
|
+
} else {
|
|
99
|
+
process.env[key] = saved;
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
restoreEnv("VELLUM_PLATFORM_URL", savedVellumPlatformUrl);
|
|
103
|
+
restoreEnv("PLATFORM_ASSISTANT_ID", savedPlatformAssistantId);
|
|
104
|
+
restoreEnv("PLATFORM_INTERNAL_API_KEY", savedPlatformInternalApiKey);
|
|
85
105
|
try {
|
|
86
106
|
rmSync(testDir, { recursive: true, force: true });
|
|
87
107
|
} catch {
|
|
@@ -94,7 +114,27 @@ afterEach(() => {
|
|
|
94
114
|
// Tests
|
|
95
115
|
// ---------------------------------------------------------------------------
|
|
96
116
|
describe("RemoteFeatureFlagSync", () => {
|
|
97
|
-
test("skips sync when
|
|
117
|
+
test("skips sync when no platform URL is available from cache or env", async () => {
|
|
118
|
+
fetchMock = mock(async () => Response.json({ flags: { ff1: true } }));
|
|
119
|
+
|
|
120
|
+
const creds = defaultCredentials();
|
|
121
|
+
delete creds["credential/vellum/platform_base_url"];
|
|
122
|
+
delete process.env.VELLUM_PLATFORM_URL;
|
|
123
|
+
|
|
124
|
+
const sync = new RemoteFeatureFlagSync({
|
|
125
|
+
credentials: fakeCredentialCache(creds),
|
|
126
|
+
});
|
|
127
|
+
await sync.start();
|
|
128
|
+
sync.stop();
|
|
129
|
+
|
|
130
|
+
// No fetch calls — sync is skipped when platform URL is unavailable
|
|
131
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("falls back to VELLUM_PLATFORM_URL env var when platform_base_url is missing", async () => {
|
|
135
|
+
fetchMock = mock(async () => Response.json({ flags: {} }));
|
|
136
|
+
process.env.VELLUM_PLATFORM_URL = "https://env-platform.example.com";
|
|
137
|
+
|
|
98
138
|
const creds = defaultCredentials();
|
|
99
139
|
delete creds["credential/vellum/platform_base_url"];
|
|
100
140
|
|
|
@@ -104,10 +144,30 @@ describe("RemoteFeatureFlagSync", () => {
|
|
|
104
144
|
await sync.start();
|
|
105
145
|
sync.stop();
|
|
106
146
|
|
|
147
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
148
|
+
const [url] = fetchMock.mock.calls[0];
|
|
149
|
+
expect(url).toBe(
|
|
150
|
+
"https://env-platform.example.com/v1/feature-flags/assistant-flag-values/",
|
|
151
|
+
);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("skips sync when assistant_api_key is missing and no PLATFORM_INTERNAL_API_KEY", async () => {
|
|
155
|
+
const creds = defaultCredentials();
|
|
156
|
+
delete creds["credential/vellum/assistant_api_key"];
|
|
157
|
+
|
|
158
|
+
const sync = new RemoteFeatureFlagSync({
|
|
159
|
+
credentials: fakeCredentialCache(creds),
|
|
160
|
+
});
|
|
161
|
+
await sync.start();
|
|
162
|
+
sync.stop();
|
|
163
|
+
|
|
107
164
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
108
165
|
});
|
|
109
166
|
|
|
110
|
-
test("
|
|
167
|
+
test("does not use PLATFORM_INTERNAL_API_KEY when assistant_api_key is missing", async () => {
|
|
168
|
+
fetchMock = mock(async () => Response.json({ flags: {} }));
|
|
169
|
+
process.env.PLATFORM_INTERNAL_API_KEY = "internal-key-123";
|
|
170
|
+
|
|
111
171
|
const creds = defaultCredentials();
|
|
112
172
|
delete creds["credential/vellum/assistant_api_key"];
|
|
113
173
|
|
|
@@ -117,10 +177,12 @@ describe("RemoteFeatureFlagSync", () => {
|
|
|
117
177
|
await sync.start();
|
|
118
178
|
sync.stop();
|
|
119
179
|
|
|
180
|
+
// PLATFORM_INTERNAL_API_KEY is only for internal gateway endpoints —
|
|
181
|
+
// feature flag sync requires assistant_api_key (Api-Key auth).
|
|
120
182
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
121
183
|
});
|
|
122
184
|
|
|
123
|
-
test("skips sync when platform_assistant_id is missing", async () => {
|
|
185
|
+
test("skips sync when platform_assistant_id is missing and no PLATFORM_ASSISTANT_ID", async () => {
|
|
124
186
|
const creds = defaultCredentials();
|
|
125
187
|
delete creds["credential/vellum/platform_assistant_id"];
|
|
126
188
|
|
|
@@ -133,6 +195,24 @@ describe("RemoteFeatureFlagSync", () => {
|
|
|
133
195
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
134
196
|
});
|
|
135
197
|
|
|
198
|
+
test("falls back to PLATFORM_ASSISTANT_ID env var when credential cache is empty", async () => {
|
|
199
|
+
fetchMock = mock(async () => Response.json({ flags: {} }));
|
|
200
|
+
process.env.PLATFORM_ASSISTANT_ID = "env-asst-456";
|
|
201
|
+
|
|
202
|
+
const creds = defaultCredentials();
|
|
203
|
+
delete creds["credential/vellum/platform_assistant_id"];
|
|
204
|
+
|
|
205
|
+
const sync = new RemoteFeatureFlagSync({
|
|
206
|
+
credentials: fakeCredentialCache(creds),
|
|
207
|
+
});
|
|
208
|
+
await sync.start();
|
|
209
|
+
sync.stop();
|
|
210
|
+
|
|
211
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
212
|
+
const [url] = fetchMock.mock.calls[0];
|
|
213
|
+
expect(url).toContain("/v1/feature-flags/assistant-flag-values/");
|
|
214
|
+
});
|
|
215
|
+
|
|
136
216
|
test("fetches and caches flags on successful response", async () => {
|
|
137
217
|
fetchMock = mock(async () =>
|
|
138
218
|
Response.json({
|
|
@@ -265,7 +345,7 @@ describe("RemoteFeatureFlagSync", () => {
|
|
|
265
345
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
266
346
|
const [url] = fetchMock.mock.calls[0];
|
|
267
347
|
expect(url).toBe(
|
|
268
|
-
"https://platform.example.com/v1/
|
|
348
|
+
"https://platform.example.com/v1/feature-flags/assistant-flag-values/",
|
|
269
349
|
);
|
|
270
350
|
});
|
|
271
351
|
|
|
@@ -344,10 +424,47 @@ describe("RemoteFeatureFlagSync", () => {
|
|
|
344
424
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
345
425
|
const [url] = fetchMock.mock.calls[0];
|
|
346
426
|
expect(url).toBe(
|
|
347
|
-
"https://platform.example.com/v1/
|
|
427
|
+
"https://platform.example.com/v1/feature-flags/assistant-flag-values/",
|
|
348
428
|
);
|
|
349
429
|
});
|
|
350
430
|
|
|
431
|
+
test("ignores remote false for GA flags (defaultEnabled: true in registry)", async () => {
|
|
432
|
+
// The platform sends false for all flags it knows about (blanket-deny).
|
|
433
|
+
// GA flags (defaultEnabled: true in the registry) should not be disabled
|
|
434
|
+
// by remote overrides — only local persisted overrides can do that.
|
|
435
|
+
fetchMock = mock(async () =>
|
|
436
|
+
Response.json({
|
|
437
|
+
flags: {
|
|
438
|
+
// GA flag (defaultEnabled: true) — remote false should be dropped
|
|
439
|
+
"conversation-starters": false,
|
|
440
|
+
// Gated flag (defaultEnabled: false) — remote false is kept
|
|
441
|
+
"email-channel": false,
|
|
442
|
+
// GA flag set to true — should be kept (redundant but harmless)
|
|
443
|
+
browser: true,
|
|
444
|
+
// Unknown flag — remote false is kept (not in registry)
|
|
445
|
+
"unknown-flag": false,
|
|
446
|
+
},
|
|
447
|
+
}),
|
|
448
|
+
);
|
|
449
|
+
|
|
450
|
+
const sync = new RemoteFeatureFlagSync({
|
|
451
|
+
credentials: fakeCredentialCache(defaultCredentials()),
|
|
452
|
+
});
|
|
453
|
+
await sync.start();
|
|
454
|
+
sync.stop();
|
|
455
|
+
|
|
456
|
+
clearRemoteFeatureFlagStoreCache();
|
|
457
|
+
const cached = readRemoteFeatureFlags();
|
|
458
|
+
// conversation-starters (GA, remote false) should be absent
|
|
459
|
+
expect(cached["conversation-starters"]).toBeUndefined();
|
|
460
|
+
// email-channel (gated, remote false) should be present
|
|
461
|
+
expect(cached["email-channel"]).toBe(false);
|
|
462
|
+
// browser (GA, remote true) should be present
|
|
463
|
+
expect(cached.browser).toBe(true);
|
|
464
|
+
// unknown-flag (not in registry, remote false) should be present
|
|
465
|
+
expect(cached["unknown-flag"]).toBe(false);
|
|
466
|
+
});
|
|
467
|
+
|
|
351
468
|
test("trims whitespace from credential values", async () => {
|
|
352
469
|
fetchMock = mock(async () => Response.json({ flags: {} }));
|
|
353
470
|
|
|
@@ -365,7 +482,7 @@ describe("RemoteFeatureFlagSync", () => {
|
|
|
365
482
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
366
483
|
const [url, init] = fetchMock.mock.calls[0];
|
|
367
484
|
expect(url).toBe(
|
|
368
|
-
"https://platform.example.com/v1/
|
|
485
|
+
"https://platform.example.com/v1/feature-flags/assistant-flag-values/",
|
|
369
486
|
);
|
|
370
487
|
const headers = init?.headers as Record<string, string>;
|
|
371
488
|
expect(headers.Authorization).toBe("Api-Key trimmed-key");
|
|
@@ -293,6 +293,195 @@ describe("reconcileTelegramWebhook", () => {
|
|
|
293
293
|
delete process.env.IS_CONTAINERIZED;
|
|
294
294
|
});
|
|
295
295
|
|
|
296
|
+
test("registers via env vars when credential cache has no platform values", async () => {
|
|
297
|
+
const calls: {
|
|
298
|
+
method: string;
|
|
299
|
+
body: unknown;
|
|
300
|
+
headers?: Record<string, string>;
|
|
301
|
+
}[] = [];
|
|
302
|
+
process.env.IS_CONTAINERIZED = "true";
|
|
303
|
+
process.env.VELLUM_PLATFORM_URL = "https://env-platform.example.com";
|
|
304
|
+
process.env.PLATFORM_ASSISTANT_ID = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee";
|
|
305
|
+
process.env.PLATFORM_INTERNAL_API_KEY = "internal-key-from-env";
|
|
306
|
+
|
|
307
|
+
const caches = makeCaches({
|
|
308
|
+
ingressUrl: undefined,
|
|
309
|
+
platformBaseUrl: undefined,
|
|
310
|
+
assistantApiKey: undefined,
|
|
311
|
+
platformAssistantId: undefined,
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
fetchMock = mock(
|
|
315
|
+
async (input: string | URL | Request, init?: RequestInit) => {
|
|
316
|
+
const url =
|
|
317
|
+
typeof input === "string"
|
|
318
|
+
? input
|
|
319
|
+
: input instanceof URL
|
|
320
|
+
? input.toString()
|
|
321
|
+
: input.url;
|
|
322
|
+
if (url.includes("/callback-routes/register/")) {
|
|
323
|
+
const body = init?.body ? JSON.parse(init.body as string) : null;
|
|
324
|
+
const headers = init?.headers as Record<string, string>;
|
|
325
|
+
calls.push({ method: "registerCallbackRoute", body, headers });
|
|
326
|
+
return new Response(
|
|
327
|
+
JSON.stringify({
|
|
328
|
+
callback_url:
|
|
329
|
+
"https://env-platform.example.com/v1/gateway/callbacks/aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee/webhooks/telegram/",
|
|
330
|
+
}),
|
|
331
|
+
{
|
|
332
|
+
status: 201,
|
|
333
|
+
headers: { "content-type": "application/json" },
|
|
334
|
+
},
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
if (url.includes("/getWebhookInfo")) {
|
|
338
|
+
calls.push({ method: "getWebhookInfo", body: null });
|
|
339
|
+
return makeTelegramResponse({
|
|
340
|
+
url: "",
|
|
341
|
+
has_custom_certificate: false,
|
|
342
|
+
pending_update_count: 0,
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
if (url.includes("/setWebhook")) {
|
|
346
|
+
const body = init?.body ? JSON.parse(init.body as string) : null;
|
|
347
|
+
calls.push({ method: "setWebhook", body });
|
|
348
|
+
return makeTelegramResponse(true);
|
|
349
|
+
}
|
|
350
|
+
return new Response("Not found", { status: 404 });
|
|
351
|
+
},
|
|
352
|
+
);
|
|
353
|
+
|
|
354
|
+
await reconcileTelegramWebhook(caches);
|
|
355
|
+
|
|
356
|
+
expect(calls).toHaveLength(3);
|
|
357
|
+
expect(calls[0].method).toBe("registerCallbackRoute");
|
|
358
|
+
expect(calls[0].body).toEqual({
|
|
359
|
+
assistant_id: "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee",
|
|
360
|
+
callback_path: "webhooks/telegram",
|
|
361
|
+
type: "telegram",
|
|
362
|
+
});
|
|
363
|
+
// PLATFORM_INTERNAL_API_KEY should use Bearer auth scheme
|
|
364
|
+
expect(calls[0].headers?.Authorization).toBe(
|
|
365
|
+
"Bearer internal-key-from-env",
|
|
366
|
+
);
|
|
367
|
+
expect(calls[2].method).toBe("setWebhook");
|
|
368
|
+
expect((calls[2].body as any).url).toBe(
|
|
369
|
+
"https://env-platform.example.com/v1/gateway/callbacks/aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee/webhooks/telegram/",
|
|
370
|
+
);
|
|
371
|
+
|
|
372
|
+
delete process.env.IS_CONTAINERIZED;
|
|
373
|
+
delete process.env.VELLUM_PLATFORM_URL;
|
|
374
|
+
delete process.env.PLATFORM_ASSISTANT_ID;
|
|
375
|
+
delete process.env.PLATFORM_INTERNAL_API_KEY;
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
test("env vars take precedence for auth key and assistant ID, cache for base URL", async () => {
|
|
379
|
+
const calls: {
|
|
380
|
+
method: string;
|
|
381
|
+
body: unknown;
|
|
382
|
+
headers?: Record<string, string>;
|
|
383
|
+
}[] = [];
|
|
384
|
+
process.env.IS_CONTAINERIZED = "true";
|
|
385
|
+
process.env.VELLUM_PLATFORM_URL = "https://env-platform.example.com";
|
|
386
|
+
process.env.PLATFORM_ASSISTANT_ID = "env-assistant-id";
|
|
387
|
+
process.env.PLATFORM_INTERNAL_API_KEY = "env-internal-key";
|
|
388
|
+
|
|
389
|
+
const caches = makeCaches({
|
|
390
|
+
ingressUrl: undefined,
|
|
391
|
+
platformBaseUrl: "https://cache-platform.example.com",
|
|
392
|
+
assistantApiKey: "cache-api-key",
|
|
393
|
+
platformAssistantId: "cache-assistant-id",
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
fetchMock = mock(
|
|
397
|
+
async (input: string | URL | Request, init?: RequestInit) => {
|
|
398
|
+
const url =
|
|
399
|
+
typeof input === "string"
|
|
400
|
+
? input
|
|
401
|
+
: input instanceof URL
|
|
402
|
+
? input.toString()
|
|
403
|
+
: input.url;
|
|
404
|
+
if (url.includes("/callback-routes/register/")) {
|
|
405
|
+
const body = init?.body ? JSON.parse(init.body as string) : null;
|
|
406
|
+
const headers = init?.headers as Record<string, string>;
|
|
407
|
+
calls.push({ method: "registerCallbackRoute", body, headers });
|
|
408
|
+
return new Response(
|
|
409
|
+
JSON.stringify({
|
|
410
|
+
callback_url:
|
|
411
|
+
"https://cache-platform.example.com/v1/gateway/callbacks/env-assistant-id/webhooks/telegram/",
|
|
412
|
+
}),
|
|
413
|
+
{
|
|
414
|
+
status: 201,
|
|
415
|
+
headers: { "content-type": "application/json" },
|
|
416
|
+
},
|
|
417
|
+
);
|
|
418
|
+
}
|
|
419
|
+
if (url.includes("/getWebhookInfo")) {
|
|
420
|
+
calls.push({ method: "getWebhookInfo", body: null });
|
|
421
|
+
return makeTelegramResponse({
|
|
422
|
+
url: "",
|
|
423
|
+
has_custom_certificate: false,
|
|
424
|
+
pending_update_count: 0,
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
if (url.includes("/setWebhook")) {
|
|
428
|
+
const body = init?.body ? JSON.parse(init.body as string) : null;
|
|
429
|
+
calls.push({ method: "setWebhook", body });
|
|
430
|
+
return makeTelegramResponse(true);
|
|
431
|
+
}
|
|
432
|
+
return new Response("Not found", { status: 404 });
|
|
433
|
+
},
|
|
434
|
+
);
|
|
435
|
+
|
|
436
|
+
await reconcileTelegramWebhook(caches);
|
|
437
|
+
|
|
438
|
+
expect(calls).toHaveLength(3);
|
|
439
|
+
expect(calls[0].method).toBe("registerCallbackRoute");
|
|
440
|
+
// platform_base_url: credential cache takes precedence over env var
|
|
441
|
+
// PLATFORM_ASSISTANT_ID and PLATFORM_INTERNAL_API_KEY: env var takes
|
|
442
|
+
// precedence, matching the daemon's resolvePlatformCallbackRegistrationContext().
|
|
443
|
+
expect(calls[0].body).toEqual({
|
|
444
|
+
assistant_id: "env-assistant-id",
|
|
445
|
+
callback_path: "webhooks/telegram",
|
|
446
|
+
type: "telegram",
|
|
447
|
+
});
|
|
448
|
+
expect(calls[0].headers?.Authorization).toBe("Bearer env-internal-key");
|
|
449
|
+
// Registration URL should use cache platform URL
|
|
450
|
+
expect((calls[2].body as any).url).toBe(
|
|
451
|
+
"https://cache-platform.example.com/v1/gateway/callbacks/env-assistant-id/webhooks/telegram/",
|
|
452
|
+
);
|
|
453
|
+
|
|
454
|
+
delete process.env.IS_CONTAINERIZED;
|
|
455
|
+
delete process.env.VELLUM_PLATFORM_URL;
|
|
456
|
+
delete process.env.PLATFORM_ASSISTANT_ID;
|
|
457
|
+
delete process.env.PLATFORM_INTERNAL_API_KEY;
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
test("skips registration when no platform URL is available from cache or env", async () => {
|
|
461
|
+
process.env.IS_CONTAINERIZED = "true";
|
|
462
|
+
process.env.PLATFORM_ASSISTANT_ID = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee";
|
|
463
|
+
process.env.PLATFORM_INTERNAL_API_KEY = "internal-key-from-env";
|
|
464
|
+
delete process.env.VELLUM_PLATFORM_URL;
|
|
465
|
+
|
|
466
|
+
const caches = makeCaches({
|
|
467
|
+
ingressUrl: undefined,
|
|
468
|
+
platformBaseUrl: undefined,
|
|
469
|
+
assistantApiKey: undefined,
|
|
470
|
+
platformAssistantId: undefined,
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
fetchMock = mock(async () => new Response("", { status: 200 }));
|
|
474
|
+
|
|
475
|
+
await reconcileTelegramWebhook(caches);
|
|
476
|
+
|
|
477
|
+
// No fetch calls should be made — registration is skipped
|
|
478
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
479
|
+
|
|
480
|
+
delete process.env.IS_CONTAINERIZED;
|
|
481
|
+
delete process.env.PLATFORM_ASSISTANT_ID;
|
|
482
|
+
delete process.env.PLATFORM_INTERNAL_API_KEY;
|
|
483
|
+
});
|
|
484
|
+
|
|
296
485
|
test("calls setWebhook when current URL is empty", async () => {
|
|
297
486
|
const calls: string[] = [];
|
|
298
487
|
|