@vellumai/vellum-gateway 0.5.15 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/vellum-gateway",
3
- "version": "0.5.15",
3
+ "version": "0.6.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -76,7 +76,7 @@ async function startGateway(): Promise<void> {
76
76
  stdio: ["ignore", "pipe", "pipe"],
77
77
  });
78
78
 
79
- const deadline = Date.now() + 5_000;
79
+ const deadline = Date.now() + 10_000;
80
80
  while (Date.now() < deadline) {
81
81
  try {
82
82
  const res = await fetch(`http://localhost:${gatewayPort}/healthz`);
@@ -86,7 +86,7 @@ async function startGateway(): Promise<void> {
86
86
  }
87
87
  await new Promise((resolve) => setTimeout(resolve, 100));
88
88
  }
89
- throw new Error("Gateway failed to start within 5 seconds");
89
+ throw new Error("Gateway failed to start within 10 seconds");
90
90
  }
91
91
 
92
92
  function startFakeCes(opts: {
@@ -139,7 +139,7 @@ afterEach(() => {
139
139
  cesPort = 0;
140
140
 
141
141
  if (gatewayProc) {
142
- gatewayProc.kill();
142
+ gatewayProc.kill("SIGKILL");
143
143
  gatewayProc = null;
144
144
  }
145
145
 
@@ -176,7 +176,7 @@ describe("gateway managed credential bootstrap retry", () => {
176
176
  }
177
177
 
178
178
  expect(status).toBe(401);
179
- }, 15_000);
179
+ }, 20_000);
180
180
 
181
181
  test("keeps retrying until configured credential reads succeed after CES list is already available", async () => {
182
182
  mkdirSync(testDir, { recursive: true });
@@ -220,5 +220,59 @@ describe("gateway managed credential bootstrap retry", () => {
220
220
  }
221
221
 
222
222
  expect(status).toBe(401);
223
- }, 15_000);
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,9 +114,12 @@ 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
+
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("skips sync when assistant_api_key is missing", async () => {
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("skips sync when platform_assistant_id 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
+
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/assistants/asst-abc-999/feature-flags/",
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/assistants/asst-123/feature-flags/",
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/assistants/asst-trimmed/feature-flags/",
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");
@@ -326,6 +326,32 @@ describe("normalizeSlackMessageEdit", () => {
326
326
  expect(result!.threadTs).toBe("1700000000.000100");
327
327
  });
328
328
 
329
+ test("DM edit without thread_ts omits threadTs", () => {
330
+ const config = makeConfig();
331
+ const event = makeMessageChangedEvent({ channel_type: "im" });
332
+ const result = normalizeSlackMessageEdit(event, "evt-dm-edit-1", config);
333
+
334
+ expect(result).not.toBeNull();
335
+ expect(result!.threadTs).toBeUndefined();
336
+ });
337
+
338
+ test("DM edit with thread_ts preserves threadTs", () => {
339
+ const config = makeConfig();
340
+ const event = makeMessageChangedEvent({
341
+ channel_type: "im",
342
+ message: {
343
+ user: "U_USER123",
344
+ text: "edited hello world",
345
+ ts: "1700000000.000100",
346
+ thread_ts: "1700000000.000050",
347
+ },
348
+ });
349
+ const result = normalizeSlackMessageEdit(event, "evt-dm-edit-2", config);
350
+
351
+ expect(result).not.toBeNull();
352
+ expect(result!.threadTs).toBe("1700000000.000050");
353
+ });
354
+
329
355
  test("DM edits use default assistant when channel is not in routing table", () => {
330
356
  const config = makeConfig({
331
357
  unmappedPolicy: "reject",