@vellumai/vellum-gateway 0.5.16 → 0.6.0
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/package.json +1 -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 +86 -6
- package/src/__tests__/telegram-webhook-manager.test.ts +189 -0
- package/src/credential-watcher.ts +30 -3
- package/src/feature-flag-registry.json +21 -124
- 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/runtime-proxy.ts +7 -1
- package/src/index.ts +23 -32
- package/src/remote-feature-flag-sync.ts +21 -8
- package/src/schema.ts +1 -1
- package/src/slack/normalize.test.ts +97 -0
- package/src/slack/normalize.ts +4 -2
- package/src/telegram/webhook-manager.ts +33 -13
- package/src/util/is-loopback-address.ts +21 -0
package/package.json
CHANGED
|
@@ -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,9 +114,12 @@ 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
|
+
|
|
98
120
|
const creds = defaultCredentials();
|
|
99
121
|
delete creds["credential/vellum/platform_base_url"];
|
|
122
|
+
delete process.env.VELLUM_PLATFORM_URL;
|
|
100
123
|
|
|
101
124
|
const sync = new RemoteFeatureFlagSync({
|
|
102
125
|
credentials: fakeCredentialCache(creds),
|
|
@@ -104,10 +127,31 @@ describe("RemoteFeatureFlagSync", () => {
|
|
|
104
127
|
await sync.start();
|
|
105
128
|
sync.stop();
|
|
106
129
|
|
|
130
|
+
// No fetch calls — sync is skipped when platform URL is unavailable
|
|
107
131
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
108
132
|
});
|
|
109
133
|
|
|
110
|
-
test("
|
|
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
|
+
|
|
138
|
+
const creds = defaultCredentials();
|
|
139
|
+
delete creds["credential/vellum/platform_base_url"];
|
|
140
|
+
|
|
141
|
+
const sync = new RemoteFeatureFlagSync({
|
|
142
|
+
credentials: fakeCredentialCache(creds),
|
|
143
|
+
});
|
|
144
|
+
await sync.start();
|
|
145
|
+
sync.stop();
|
|
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 () => {
|
|
111
155
|
const creds = defaultCredentials();
|
|
112
156
|
delete creds["credential/vellum/assistant_api_key"];
|
|
113
157
|
|
|
@@ -120,7 +164,25 @@ describe("RemoteFeatureFlagSync", () => {
|
|
|
120
164
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
121
165
|
});
|
|
122
166
|
|
|
123
|
-
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
|
+
|
|
171
|
+
const creds = defaultCredentials();
|
|
172
|
+
delete creds["credential/vellum/assistant_api_key"];
|
|
173
|
+
|
|
174
|
+
const sync = new RemoteFeatureFlagSync({
|
|
175
|
+
credentials: fakeCredentialCache(creds),
|
|
176
|
+
});
|
|
177
|
+
await sync.start();
|
|
178
|
+
sync.stop();
|
|
179
|
+
|
|
180
|
+
// PLATFORM_INTERNAL_API_KEY is only for internal gateway endpoints —
|
|
181
|
+
// feature flag sync requires assistant_api_key (Api-Key auth).
|
|
182
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
183
|
+
});
|
|
184
|
+
|
|
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,7 +424,7 @@ 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
|
|
|
@@ -365,7 +445,7 @@ describe("RemoteFeatureFlagSync", () => {
|
|
|
365
445
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
366
446
|
const [url, init] = fetchMock.mock.calls[0];
|
|
367
447
|
expect(url).toBe(
|
|
368
|
-
"https://platform.example.com/v1/
|
|
448
|
+
"https://platform.example.com/v1/feature-flags/assistant-flag-values/",
|
|
369
449
|
);
|
|
370
450
|
const headers = init?.headers as Record<string, string>;
|
|
371
451
|
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
|
|
|
@@ -30,6 +30,7 @@ const log = getLogger("credential-watcher");
|
|
|
30
30
|
const DEBOUNCE_MS = 500;
|
|
31
31
|
const MANAGED_BOOTSTRAP_POLL_MS = 1_000;
|
|
32
32
|
const MANAGED_BOOTSTRAP_TIMEOUT_MS = 1_000;
|
|
33
|
+
const MANAGED_BOOTSTRAP_STEADY_POLL_MS = 30_000;
|
|
33
34
|
|
|
34
35
|
export type CredentialChangeEvent = {
|
|
35
36
|
/** Map from service name to resolved credentials (null if unavailable) */
|
|
@@ -45,6 +46,7 @@ export class CredentialWatcher {
|
|
|
45
46
|
private debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
46
47
|
private managedBootstrapTimer: ReturnType<typeof setInterval> | null = null;
|
|
47
48
|
private managedBootstrapPollInFlight = false;
|
|
49
|
+
private managedBootstrapInSteadyState = false;
|
|
48
50
|
private lastConfiguredServices = new Set<string>();
|
|
49
51
|
private lastReadyServices = new Set<string>();
|
|
50
52
|
private lastSerialized: Map<string, string> = new Map();
|
|
@@ -120,6 +122,7 @@ export class CredentialWatcher {
|
|
|
120
122
|
this.managedBootstrapTimer = null;
|
|
121
123
|
}
|
|
122
124
|
this.managedBootstrapPollInFlight = false;
|
|
125
|
+
this.managedBootstrapInSteadyState = false;
|
|
123
126
|
this.pendingPoll = false;
|
|
124
127
|
for (const watcher of this.watchers) {
|
|
125
128
|
watcher.close();
|
|
@@ -173,9 +176,33 @@ export class CredentialWatcher {
|
|
|
173
176
|
|
|
174
177
|
await this.pollOnce();
|
|
175
178
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
this.
|
|
179
|
+
const ready =
|
|
180
|
+
this.lastConfiguredServices.size > 0 &&
|
|
181
|
+
this.allConfiguredServicesReady();
|
|
182
|
+
|
|
183
|
+
if (ready && !this.managedBootstrapInSteadyState) {
|
|
184
|
+
// All configured channel services have their credentials loaded.
|
|
185
|
+
// Switch to a slower steady-state poll as a resilient fallback for
|
|
186
|
+
// environments where fs.watch() doesn't propagate across containers.
|
|
187
|
+
if (this.managedBootstrapTimer) {
|
|
188
|
+
clearInterval(this.managedBootstrapTimer);
|
|
189
|
+
this.managedBootstrapTimer = setInterval(() => {
|
|
190
|
+
void this.pollManagedBootstrap(baseUrl, serviceToken);
|
|
191
|
+
}, MANAGED_BOOTSTRAP_STEADY_POLL_MS);
|
|
192
|
+
this.managedBootstrapTimer.unref?.();
|
|
193
|
+
}
|
|
194
|
+
this.managedBootstrapInSteadyState = true;
|
|
195
|
+
} else if (!ready && this.managedBootstrapInSteadyState) {
|
|
196
|
+
// A configured service lost its credentials — revert to fast polling
|
|
197
|
+
// so we pick up restored credentials quickly.
|
|
198
|
+
if (this.managedBootstrapTimer) {
|
|
199
|
+
clearInterval(this.managedBootstrapTimer);
|
|
200
|
+
this.managedBootstrapTimer = setInterval(() => {
|
|
201
|
+
void this.pollManagedBootstrap(baseUrl, serviceToken);
|
|
202
|
+
}, MANAGED_BOOTSTRAP_POLL_MS);
|
|
203
|
+
this.managedBootstrapTimer.unref?.();
|
|
204
|
+
}
|
|
205
|
+
this.managedBootstrapInSteadyState = false;
|
|
179
206
|
}
|
|
180
207
|
} catch {
|
|
181
208
|
// CES isn't reachable yet. Keep retrying until the sidecar is ready.
|