@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.
Files changed (41) hide show
  1. package/Dockerfile +1 -1
  2. package/package.json +1 -1
  3. package/src/__tests__/config.test.ts +2 -1
  4. package/src/__tests__/credential-watcher-managed-bootstrap.test.ts +54 -0
  5. package/src/__tests__/feature-flags-route.test.ts +57 -29
  6. package/src/__tests__/remote-feature-flag-sync.test.ts +123 -6
  7. package/src/__tests__/telegram-webhook-manager.test.ts +189 -0
  8. package/src/channels/inbound-event.ts +4 -2
  9. package/src/channels/transport-hints.ts +18 -0
  10. package/src/config.ts +4 -1
  11. package/src/credential-watcher.ts +30 -3
  12. package/src/download-validation.test.ts +96 -0
  13. package/src/download-validation.ts +92 -0
  14. package/src/email/normalize.test.ts +129 -0
  15. package/src/email/normalize.ts +94 -0
  16. package/src/email/verify.test.ts +96 -0
  17. package/src/email/verify.ts +41 -0
  18. package/src/feature-flag-registry.json +38 -125
  19. package/src/http/routes/browser-relay-websocket.ts +1 -22
  20. package/src/http/routes/channel-verification-session-proxy.ts +18 -2
  21. package/src/http/routes/email-webhook.test.ts +393 -0
  22. package/src/http/routes/email-webhook.ts +243 -0
  23. package/src/http/routes/log-export.test.ts +530 -0
  24. package/src/http/routes/log-export.ts +494 -0
  25. package/src/http/routes/runtime-proxy.ts +7 -1
  26. package/src/http/routes/telegram-webhook.ts +21 -1
  27. package/src/http/routes/whatsapp-webhook.ts +28 -1
  28. package/src/index.ts +60 -33
  29. package/src/logger.ts +21 -6
  30. package/src/remote-feature-flag-sync.ts +36 -11
  31. package/src/schema.ts +150 -1
  32. package/src/slack/download.test.ts +81 -10
  33. package/src/slack/download.ts +23 -1
  34. package/src/slack/normalize.test.ts +97 -0
  35. package/src/slack/normalize.ts +4 -2
  36. package/src/slack/socket-mode.ts +10 -0
  37. package/src/telegram/download.ts +3 -0
  38. package/src/telegram/webhook-manager.ts +33 -13
  39. package/src/types.ts +1 -0
  40. package/src/util/is-loopback-address.ts +21 -0
  41. 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.21@sha256:5a2011bf09364b9af658ac1e66f60d08092f4291aeefbff448d58b027734fdd0 AS bun
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/vellum-gateway",
3
- "version": "0.5.16",
3
+ "version": "0.6.1",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -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).toEqual({ dir: undefined, retentionDays: 30 });
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: "contacts",
42
+ id: "email-channel",
43
43
  scope: "assistant",
44
- key: "contacts",
45
- label: "Contacts",
46
- description: "Contacts management",
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 contacts enabled (overriding registry default of false)
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
- contacts: true,
271
+ "email-channel": true,
272
272
  },
273
273
  }),
274
274
  );
275
275
  clearRemoteFeatureFlagStoreCache();
276
276
 
277
- // No local override for contacts
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 contactsFlag = body.flags.find(
292
- (f: { key: string }) => f.key === "contacts",
291
+ const emailFlag = body.flags.find(
292
+ (f: { key: string }) => f.key === "email-channel",
293
293
  );
294
- expect(contactsFlag).toBeDefined();
294
+ expect(emailFlag).toBeDefined();
295
295
  // Remote value (true) overrides registry default (false)
296
- expect(contactsFlag.enabled).toBe(true);
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
- contacts: true,
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
- contacts: false,
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 contactsFlag = body.flags.find(
333
- (f: { key: string }) => f.key === "contacts",
332
+ const emailFlag = body.flags.find(
333
+ (f: { key: string }) => f.key === "email-channel",
334
334
  );
335
- expect(contactsFlag).toBeDefined();
335
+ expect(emailFlag).toBeDefined();
336
336
  // Local override (false) takes precedence over remote (true)
337
- expect(contactsFlag.enabled).toBe(false);
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
- // contacts has defaultEnabled: false in registry
362
- const contactsFlag = body.flags.find(
363
- (f: { key: string }) => f.key === "contacts",
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(contactsFlag).toBeDefined();
366
- expect(contactsFlag.enabled).toBe(false);
367
- expect(contactsFlag.defaultEnabled).toBe(false);
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
- contacts: true,
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["contacts"]).toBe(true);
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", "contacts"];
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: { contacts: true },
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["contacts"]).toBe(true);
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", "contacts"];
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 platform_base_url is missing", async () => {
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("skips sync when assistant_api_key is missing", async () => {
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/assistants/asst-abc-999/feature-flags/",
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/assistants/asst-123/feature-flags/",
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/assistants/asst-trimmed/feature-flags/",
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