@vellumai/vellum-gateway 0.5.6 → 0.5.7
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/ARCHITECTURE.md +9 -8
- package/bun.lock +0 -3
- package/package.json +2 -2
- package/src/__tests__/feature-flags-route.test.ts +76 -72
- package/src/__tests__/probes.test.ts +29 -1
- package/src/__tests__/route-schema-guard.test.ts +0 -2
- package/src/auth/token-service.ts +17 -2
- package/src/feature-flag-registry.json +48 -8
- package/src/feature-flag-store.ts +120 -0
- package/src/feature-flag-watcher.ts +79 -0
- package/src/http/routes/audio-proxy.ts +63 -0
- package/src/http/routes/feature-flags.ts +14 -82
- package/src/http/routes/migration-proxy.ts +164 -0
- package/src/http/routes/migration-rollback-proxy.ts +93 -0
- package/src/http/routes/vercel-control-plane-proxy.ts +112 -0
- package/src/http/routes/workspace-commit-proxy.ts +88 -0
- package/src/index.ts +111 -13
- package/src/schema.ts +217 -0
- package/workspace/config.json +1 -5
- package/src/__tests__/signing-key-bootstrap.test.ts +0 -143
- package/src/http/routes/signing-key-bootstrap.ts +0 -59
package/ARCHITECTURE.md
CHANGED
|
@@ -40,16 +40,16 @@ The gateway exposes a REST API for reading and mutating assistant feature flags.
|
|
|
40
40
|
|
|
41
41
|
**Endpoints (GET/PATCH contract):**
|
|
42
42
|
|
|
43
|
-
| Method | Path | Description
|
|
44
|
-
| ------ | ------------------------ |
|
|
45
|
-
| GET | `/v1/feature-flags` | List all declared assistant feature flags from the defaults registry, merged with persisted values from
|
|
46
|
-
| PATCH | `/v1/feature-flags/:key` | Set a single assistant feature flag. Body: `{ "enabled": true\|false }`. Key must match `feature_flags.<flagId>.enabled` and be declared in the defaults registry. Writes to
|
|
43
|
+
| Method | Path | Description |
|
|
44
|
+
| ------ | ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
45
|
+
| GET | `/v1/feature-flags` | List all declared assistant feature flags from the defaults registry, merged with persisted values from the feature flag store. Returns `{ flags: FeatureFlagEntry[] }` where each entry has `key`, `enabled`, `defaultEnabled`, and `description`. |
|
|
46
|
+
| PATCH | `/v1/feature-flags/:key` | Set a single assistant feature flag. Body: `{ "enabled": true\|false }`. Key must match `feature_flags.<flagId>.enabled` and be declared in the defaults registry. Writes to `~/.vellum/protected/feature-flags.json`. |
|
|
47
47
|
|
|
48
48
|
**Unified registry:** All declared feature flags and their default values are defined in the unified registry at `meta/feature-flags/feature-flag-registry.json` (bundled copy at `gateway/src/feature-flag-registry.json`). The gateway loads this registry on startup via `gateway/src/feature-flag-defaults.ts`, filtering to `scope: "assistant"` flags. Labels come from the registry. The GET endpoint merges persisted overrides with registry defaults to produce the full flag list. The PATCH endpoint validates that the target flag key exists in the registry before accepting a write. Only declared keys are exposed by this API.
|
|
49
49
|
|
|
50
|
-
**Flag key format:** The canonical key format is `feature_flags.<flagId>.enabled`. Only keys matching this pattern are accepted by the PATCH endpoint; other patterns are rejected with 400. All writes use the canonical format and are stored in the
|
|
50
|
+
**Flag key format:** The canonical key format is `feature_flags.<flagId>.enabled`. Only keys matching this pattern are accepted by the PATCH endpoint; other patterns are rejected with 400. All writes use the canonical format and are stored in the protected feature flag store (`~/.vellum/protected/feature-flags.json`).
|
|
51
51
|
|
|
52
|
-
**Storage:** Flag overrides are persisted in `~/.vellum/
|
|
52
|
+
**Storage:** Flag overrides are persisted in `~/.vellum/protected/feature-flags.json` (local) or `GATEWAY_SECURITY_DIR/feature-flags.json` (Docker). The store uses a versioned JSON format (`{ version: 1, values: Record<string, boolean> }`). The GET endpoint reads from the feature flag store and merges with registry defaults. The gateway writes atomically (temp file + rename, 0o600 permissions). The daemon's config watcher monitors the protected directory and hot-reloads changes, so flag mutations take effect on the next session or tool resolution without a restart.
|
|
53
53
|
|
|
54
54
|
**Token separation (authentication boundary):**
|
|
55
55
|
|
|
@@ -62,13 +62,14 @@ The assistant feature flags API uses a dedicated feature-flag token stored at `~
|
|
|
62
62
|
|
|
63
63
|
The feature-flag token is auto-generated on first gateway startup if the file does not exist. The gateway watches the token file for changes and hot-reloads without restart.
|
|
64
64
|
|
|
65
|
-
|
|
65
|
+
**Protected feature flag store:** The canonical storage for assistant feature flag overrides is `~/.vellum/protected/feature-flags.json` (local) or `GATEWAY_SECURITY_DIR/feature-flags.json` (Docker). The store is managed by `gateway/src/feature-flag-store.ts` and uses a versioned JSON format with `Record<string, boolean>` values keyed by canonical flag keys (`feature_flags.<id>.enabled`). The gateway's PATCH handler writes exclusively to this store. The daemon's resolver reads it with highest priority, falling back to the defaults registry. Undeclared keys are ignored by the resolver.
|
|
66
66
|
|
|
67
67
|
**Key source files:**
|
|
68
68
|
|
|
69
69
|
| File | Purpose |
|
|
70
70
|
| ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
|
|
71
|
-
| `gateway/src/http/routes/feature-flags.ts` | GET and PATCH handlers;
|
|
71
|
+
| `gateway/src/http/routes/feature-flags.ts` | GET and PATCH handlers; key format validation; delegates to feature-flag-store for persistence |
|
|
72
|
+
| `gateway/src/feature-flag-store.ts` | File-backed persistence: `readPersistedFeatureFlags()`, `writeFeatureFlag()`, atomic writes to protected directory |
|
|
72
73
|
| `gateway/src/feature-flag-defaults.ts` | `loadFeatureFlagDefaults()` — loads the shared defaults registry; `isFlagDeclared()` — validates flag keys |
|
|
73
74
|
| `gateway/src/config.ts` | `readOrGenerateFeatureFlagToken()` — token provisioning; `featureFlagToken` config field |
|
|
74
75
|
| `gateway/src/index.ts` | Route registration, auth enforcement (dual-token for GET, flag-token-only for PATCH), token file watcher |
|
package/bun.lock
CHANGED
|
@@ -13,7 +13,6 @@
|
|
|
13
13
|
},
|
|
14
14
|
"devDependencies": {
|
|
15
15
|
"@types/bun": "^1.2.4",
|
|
16
|
-
"@types/uuid": "^11.0.0",
|
|
17
16
|
"eslint": "^10.0.0",
|
|
18
17
|
"knip": "^5.83.1",
|
|
19
18
|
"prettier": "^3.8.1",
|
|
@@ -121,8 +120,6 @@
|
|
|
121
120
|
|
|
122
121
|
"@types/node": ["@types/node@25.2.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ=="],
|
|
123
122
|
|
|
124
|
-
"@types/uuid": ["@types/uuid@11.0.0", "", { "dependencies": { "uuid": "*" } }, "sha512-HVyk8nj2m+jcFRNazzqyVKiZezyhDKrGUA3jlEcg/nZ6Ms+qHwocba1Y/AaVaznJTAM9xpdFSh+ptbNrhOGvZA=="],
|
|
125
|
-
|
|
126
123
|
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.56.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.56.0", "@typescript-eslint/type-utils": "8.56.0", "@typescript-eslint/utils": "8.56.0", "@typescript-eslint/visitor-keys": "8.56.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.56.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-lRyPDLzNCuae71A3t9NEINBiTn7swyOhvUj3MyUOxb8x6g6vPEFoOU+ZRmGMusNC3X3YMhqMIX7i8ShqhT74Pw=="],
|
|
127
124
|
|
|
128
125
|
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.56.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.56.0", "@typescript-eslint/types": "8.56.0", "@typescript-eslint/typescript-estree": "8.56.0", "@typescript-eslint/visitor-keys": "8.56.0", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg=="],
|
package/package.json
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vellumai/vellum-gateway",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.7",
|
|
4
|
+
"license": "MIT",
|
|
4
5
|
"type": "module",
|
|
5
6
|
"exports": {
|
|
6
7
|
"./twilio/verify": "./src/twilio/verify.ts",
|
|
@@ -30,7 +31,6 @@
|
|
|
30
31
|
},
|
|
31
32
|
"devDependencies": {
|
|
32
33
|
"@types/bun": "^1.2.4",
|
|
33
|
-
"@types/uuid": "^11.0.0",
|
|
34
34
|
"eslint": "^10.0.0",
|
|
35
35
|
"knip": "^5.83.1",
|
|
36
36
|
"prettier": "^3.8.1",
|
|
@@ -16,8 +16,8 @@ const testDir = join(
|
|
|
16
16
|
`vellum-ff-test-${randomBytes(6).toString("hex")}`,
|
|
17
17
|
);
|
|
18
18
|
const vellumRoot = join(testDir, ".vellum");
|
|
19
|
-
const
|
|
20
|
-
const
|
|
19
|
+
const protectedDir = join(vellumRoot, "protected");
|
|
20
|
+
const featureFlagStorePath = join(protectedDir, "feature-flags.json");
|
|
21
21
|
|
|
22
22
|
// Write the test registry to an isolated temp path so we never touch
|
|
23
23
|
// the committed gateway/src/feature-flag-registry.json file.
|
|
@@ -57,11 +57,12 @@ const savedBaseDataDir = process.env.BASE_DATA_DIR;
|
|
|
57
57
|
|
|
58
58
|
beforeEach(() => {
|
|
59
59
|
process.env.BASE_DATA_DIR = testDir;
|
|
60
|
-
mkdirSync(
|
|
60
|
+
mkdirSync(protectedDir, { recursive: true });
|
|
61
61
|
writeFileSync(defaultsPath, JSON.stringify(TEST_REGISTRY, null, 2));
|
|
62
62
|
// Point registry resolution at the isolated test file first
|
|
63
63
|
_setRegistryCandidateOverrides([defaultsPath]);
|
|
64
64
|
resetFeatureFlagDefaultsCache();
|
|
65
|
+
clearFeatureFlagStoreCache();
|
|
65
66
|
});
|
|
66
67
|
|
|
67
68
|
afterEach(() => {
|
|
@@ -78,6 +79,7 @@ afterEach(() => {
|
|
|
78
79
|
// Clear the test-only candidate override and reset the defaults cache
|
|
79
80
|
_setRegistryCandidateOverrides(null);
|
|
80
81
|
resetFeatureFlagDefaultsCache();
|
|
82
|
+
clearFeatureFlagStoreCache();
|
|
81
83
|
});
|
|
82
84
|
|
|
83
85
|
const { createFeatureFlagsGetHandler, createFeatureFlagsPatchHandler } =
|
|
@@ -87,12 +89,14 @@ const {
|
|
|
87
89
|
resetFeatureFlagDefaultsCache,
|
|
88
90
|
_setRegistryCandidateOverrides,
|
|
89
91
|
} = await import("../feature-flag-defaults.js");
|
|
92
|
+
const { clearFeatureFlagStoreCache, readPersistedFeatureFlags } =
|
|
93
|
+
await import("../feature-flag-store.js");
|
|
90
94
|
|
|
91
95
|
describe("GET /v1/feature-flags handler", () => {
|
|
92
|
-
test("returns all declared assistant-scope flags with defaults when
|
|
93
|
-
// Don't create the
|
|
94
|
-
if (existsSync(
|
|
95
|
-
rmSync(
|
|
96
|
+
test("returns all declared assistant-scope flags with defaults when no persisted file exists", async () => {
|
|
97
|
+
// Don't create the feature-flags.json file
|
|
98
|
+
if (existsSync(featureFlagStorePath)) {
|
|
99
|
+
rmSync(featureFlagStorePath);
|
|
96
100
|
}
|
|
97
101
|
|
|
98
102
|
const handler = createFeatureFlagsGetHandler();
|
|
@@ -170,11 +174,13 @@ describe("GET /v1/feature-flags handler", () => {
|
|
|
170
174
|
expect(macosFlag).toBeUndefined();
|
|
171
175
|
});
|
|
172
176
|
|
|
173
|
-
test("returns all declared flags even when
|
|
177
|
+
test("returns all declared flags even when store has no persisted values", async () => {
|
|
178
|
+
// Write an empty feature-flags.json store
|
|
174
179
|
writeFileSync(
|
|
175
|
-
|
|
176
|
-
JSON.stringify({
|
|
180
|
+
featureFlagStorePath,
|
|
181
|
+
JSON.stringify({ version: 1, values: {} }),
|
|
177
182
|
);
|
|
183
|
+
clearFeatureFlagStoreCache();
|
|
178
184
|
|
|
179
185
|
const handler = createFeatureFlagsGetHandler();
|
|
180
186
|
const res = await handler(
|
|
@@ -189,15 +195,17 @@ describe("GET /v1/feature-flags handler", () => {
|
|
|
189
195
|
expect(body.flags.length).toBe(declaredKeys.length);
|
|
190
196
|
});
|
|
191
197
|
|
|
192
|
-
test("merges persisted values from
|
|
198
|
+
test("merges persisted values from feature-flags.json with defaults", async () => {
|
|
193
199
|
writeFileSync(
|
|
194
|
-
|
|
200
|
+
featureFlagStorePath,
|
|
195
201
|
JSON.stringify({
|
|
196
|
-
|
|
202
|
+
version: 1,
|
|
203
|
+
values: {
|
|
197
204
|
"feature_flags.browser.enabled": false,
|
|
198
205
|
},
|
|
199
206
|
}),
|
|
200
207
|
);
|
|
208
|
+
clearFeatureFlagStoreCache();
|
|
201
209
|
|
|
202
210
|
const handler = createFeatureFlagsGetHandler();
|
|
203
211
|
const res = await handler(
|
|
@@ -215,15 +223,18 @@ describe("GET /v1/feature-flags handler", () => {
|
|
|
215
223
|
expect(browserFlag.defaultEnabled).toBe(true);
|
|
216
224
|
});
|
|
217
225
|
|
|
218
|
-
test("ignores non-boolean values in
|
|
226
|
+
test("ignores non-boolean values in persisted feature flags", async () => {
|
|
227
|
+
// Write a feature-flags.json with an invalid non-boolean value manually
|
|
219
228
|
writeFileSync(
|
|
220
|
-
|
|
229
|
+
featureFlagStorePath,
|
|
221
230
|
JSON.stringify({
|
|
222
|
-
|
|
231
|
+
version: 1,
|
|
232
|
+
values: {
|
|
223
233
|
"feature_flags.browser.enabled": "no",
|
|
224
234
|
},
|
|
225
235
|
}),
|
|
226
236
|
);
|
|
237
|
+
clearFeatureFlagStoreCache();
|
|
227
238
|
|
|
228
239
|
const handler = createFeatureFlagsGetHandler();
|
|
229
240
|
const res = await handler(
|
|
@@ -233,19 +244,20 @@ describe("GET /v1/feature-flags handler", () => {
|
|
|
233
244
|
expect(res.status).toBe(200);
|
|
234
245
|
const body = await res.json();
|
|
235
246
|
|
|
236
|
-
//
|
|
247
|
+
// readPersistedFeatureFlags filters out non-boolean values, so the
|
|
248
|
+
// invalid "no" string is dropped and the flag falls back to its
|
|
249
|
+
// registry default (true).
|
|
237
250
|
const browserFlag = body.flags.find(
|
|
238
251
|
(f: { key: string }) => f.key === "feature_flags.browser.enabled",
|
|
239
252
|
);
|
|
240
253
|
expect(browserFlag).toBeDefined();
|
|
241
|
-
expect(browserFlag.enabled).toBe(
|
|
254
|
+
expect(browserFlag.enabled).toBe(true);
|
|
255
|
+
expect(browserFlag.defaultEnabled).toBe(true);
|
|
242
256
|
});
|
|
243
257
|
});
|
|
244
258
|
|
|
245
259
|
describe("PATCH /v1/feature-flags/:flagKey handler", () => {
|
|
246
|
-
test("writes to
|
|
247
|
-
writeFileSync(configPath, JSON.stringify({}));
|
|
248
|
-
|
|
260
|
+
test("writes to feature-flags.json store", async () => {
|
|
249
261
|
const handler = createFeatureFlagsPatchHandler();
|
|
250
262
|
const res = await handler(
|
|
251
263
|
new Request(
|
|
@@ -266,24 +278,24 @@ describe("PATCH /v1/feature-flags/:flagKey handler", () => {
|
|
|
266
278
|
enabled: false,
|
|
267
279
|
});
|
|
268
280
|
|
|
269
|
-
// Verify persistence to the
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
).toBe(false);
|
|
281
|
+
// Verify persistence to the feature-flags.json store
|
|
282
|
+
clearFeatureFlagStoreCache();
|
|
283
|
+
const persisted = readPersistedFeatureFlags();
|
|
284
|
+
expect(persisted["feature_flags.browser.enabled"]).toBe(false);
|
|
274
285
|
});
|
|
275
286
|
|
|
276
|
-
test("preserves existing
|
|
287
|
+
test("preserves existing persisted flags when writing", async () => {
|
|
288
|
+
// Pre-seed a flag value
|
|
277
289
|
writeFileSync(
|
|
278
|
-
|
|
290
|
+
featureFlagStorePath,
|
|
279
291
|
JSON.stringify({
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
assistantFeatureFlagValues: {
|
|
292
|
+
version: 1,
|
|
293
|
+
values: {
|
|
283
294
|
"feature_flags.contacts.enabled": true,
|
|
284
295
|
},
|
|
285
296
|
}),
|
|
286
297
|
);
|
|
298
|
+
clearFeatureFlagStoreCache();
|
|
287
299
|
|
|
288
300
|
const handler = createFeatureFlagsPatchHandler();
|
|
289
301
|
await handler(
|
|
@@ -298,21 +310,16 @@ describe("PATCH /v1/feature-flags/:flagKey handler", () => {
|
|
|
298
310
|
"feature_flags.browser.enabled",
|
|
299
311
|
);
|
|
300
312
|
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
expect(
|
|
306
|
-
config.assistantFeatureFlagValues["feature_flags.contacts.enabled"],
|
|
307
|
-
).toBe(true);
|
|
308
|
-
expect(
|
|
309
|
-
config.assistantFeatureFlagValues["feature_flags.browser.enabled"],
|
|
310
|
-
).toBe(true);
|
|
313
|
+
// Both old and new values should be persisted
|
|
314
|
+
clearFeatureFlagStoreCache();
|
|
315
|
+
const persisted = readPersistedFeatureFlags();
|
|
316
|
+
expect(persisted["feature_flags.contacts.enabled"]).toBe(true);
|
|
317
|
+
expect(persisted["feature_flags.browser.enabled"]).toBe(true);
|
|
311
318
|
});
|
|
312
319
|
|
|
313
|
-
test("creates
|
|
314
|
-
// Remove the
|
|
315
|
-
rmSync(
|
|
320
|
+
test("creates feature-flags.json and directories when they do not exist", async () => {
|
|
321
|
+
// Remove the protected dir to test directory creation
|
|
322
|
+
rmSync(protectedDir, { recursive: true, force: true });
|
|
316
323
|
|
|
317
324
|
const handler = createFeatureFlagsPatchHandler();
|
|
318
325
|
const res = await handler(
|
|
@@ -328,12 +335,11 @@ describe("PATCH /v1/feature-flags/:flagKey handler", () => {
|
|
|
328
335
|
);
|
|
329
336
|
|
|
330
337
|
expect(res.status).toBe(200);
|
|
331
|
-
expect(existsSync(
|
|
338
|
+
expect(existsSync(featureFlagStorePath)).toBe(true);
|
|
332
339
|
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
).toBe(true);
|
|
340
|
+
clearFeatureFlagStoreCache();
|
|
341
|
+
const persisted = readPersistedFeatureFlags();
|
|
342
|
+
expect(persisted["feature_flags.browser.enabled"]).toBe(true);
|
|
337
343
|
});
|
|
338
344
|
|
|
339
345
|
// Validation tests
|
|
@@ -408,7 +414,6 @@ describe("PATCH /v1/feature-flags/:flagKey handler", () => {
|
|
|
408
414
|
});
|
|
409
415
|
|
|
410
416
|
test("rejects undeclared keys (not in defaults registry)", async () => {
|
|
411
|
-
writeFileSync(configPath, JSON.stringify({}));
|
|
412
417
|
const handler = createFeatureFlagsPatchHandler();
|
|
413
418
|
|
|
414
419
|
const res = await handler(
|
|
@@ -429,7 +434,6 @@ describe("PATCH /v1/feature-flags/:flagKey handler", () => {
|
|
|
429
434
|
});
|
|
430
435
|
|
|
431
436
|
test("accepts valid declared feature_flags.* key formats", async () => {
|
|
432
|
-
writeFileSync(configPath, JSON.stringify({}));
|
|
433
437
|
const handler = createFeatureFlagsPatchHandler();
|
|
434
438
|
|
|
435
439
|
const validKeys = [
|
|
@@ -438,6 +442,7 @@ describe("PATCH /v1/feature-flags/:flagKey handler", () => {
|
|
|
438
442
|
];
|
|
439
443
|
|
|
440
444
|
for (const key of validKeys) {
|
|
445
|
+
clearFeatureFlagStoreCache();
|
|
441
446
|
const res = await handler(
|
|
442
447
|
new Request(`http://gateway.test/v1/feature-flags/${key}`, {
|
|
443
448
|
method: "PATCH",
|
|
@@ -508,13 +513,16 @@ describe("PATCH /v1/feature-flags/:flagKey handler", () => {
|
|
|
508
513
|
expect(res.status).toBe(400);
|
|
509
514
|
});
|
|
510
515
|
|
|
511
|
-
test("atomic write does not corrupt
|
|
512
|
-
//
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
516
|
+
test("atomic write does not corrupt store on successful write", async () => {
|
|
517
|
+
// Pre-seed the store
|
|
518
|
+
writeFileSync(
|
|
519
|
+
featureFlagStorePath,
|
|
520
|
+
JSON.stringify({
|
|
521
|
+
version: 1,
|
|
522
|
+
values: { "feature_flags.contacts.enabled": true },
|
|
523
|
+
}),
|
|
524
|
+
);
|
|
525
|
+
clearFeatureFlagStoreCache();
|
|
518
526
|
|
|
519
527
|
const handler = createFeatureFlagsPatchHandler();
|
|
520
528
|
await handler(
|
|
@@ -530,25 +538,20 @@ describe("PATCH /v1/feature-flags/:flagKey handler", () => {
|
|
|
530
538
|
);
|
|
531
539
|
|
|
532
540
|
// Verify the file is valid JSON and contains all expected data
|
|
533
|
-
const raw = readFileSync(
|
|
534
|
-
const
|
|
535
|
-
expect(
|
|
536
|
-
expect(
|
|
537
|
-
|
|
538
|
-
).toBe(true);
|
|
539
|
-
expect(
|
|
540
|
-
config.assistantFeatureFlagValues["feature_flags.browser.enabled"],
|
|
541
|
-
).toBe(false);
|
|
541
|
+
const raw = readFileSync(featureFlagStorePath, "utf-8");
|
|
542
|
+
const data = JSON.parse(raw);
|
|
543
|
+
expect(data.version).toBe(1);
|
|
544
|
+
expect(data.values["feature_flags.contacts.enabled"]).toBe(true);
|
|
545
|
+
expect(data.values["feature_flags.browser.enabled"]).toBe(false);
|
|
542
546
|
|
|
543
547
|
// Verify no temp files left behind
|
|
544
548
|
const { readdirSync } = await import("node:fs");
|
|
545
|
-
const files = readdirSync(
|
|
546
|
-
const tmpFiles = files.filter((f: string) => f.
|
|
549
|
+
const files = readdirSync(protectedDir);
|
|
550
|
+
const tmpFiles = files.filter((f: string) => f.includes(".tmp"));
|
|
547
551
|
expect(tmpFiles.length).toBe(0);
|
|
548
552
|
});
|
|
549
553
|
|
|
550
554
|
test("concurrent writes are serialized and no flag change is lost", async () => {
|
|
551
|
-
writeFileSync(configPath, JSON.stringify({}));
|
|
552
555
|
const handler = createFeatureFlagsPatchHandler();
|
|
553
556
|
|
|
554
557
|
// Fire multiple concurrent PATCH requests at the same time
|
|
@@ -576,9 +579,10 @@ describe("PATCH /v1/feature-flags/:flagKey handler", () => {
|
|
|
576
579
|
}
|
|
577
580
|
|
|
578
581
|
// All flags should be persisted — none should be lost to a race
|
|
579
|
-
|
|
582
|
+
clearFeatureFlagStoreCache();
|
|
583
|
+
const persisted = readPersistedFeatureFlags();
|
|
580
584
|
for (const key of flagKeys) {
|
|
581
|
-
expect(
|
|
585
|
+
expect(persisted[key]).toBe(false);
|
|
582
586
|
}
|
|
583
587
|
});
|
|
584
588
|
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, test, expect, afterAll } from "bun:test";
|
|
1
|
+
import { describe, test, expect, afterAll, spyOn, afterEach } from "bun:test";
|
|
2
2
|
|
|
3
3
|
const env: Record<string, string> = {
|
|
4
4
|
TELEGRAM_BOT_TOKEN: "test-tok",
|
|
@@ -81,3 +81,31 @@ describe("/readyz", () => {
|
|
|
81
81
|
}
|
|
82
82
|
});
|
|
83
83
|
});
|
|
84
|
+
|
|
85
|
+
describe("/readyz upstream probe", () => {
|
|
86
|
+
afterEach(() => {
|
|
87
|
+
// Restore any spies after each test
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("probes upstream /readyz (not /healthz)", async () => {
|
|
91
|
+
const fetchSpy = spyOn(globalThis, "fetch").mockResolvedValueOnce(
|
|
92
|
+
Response.json({ status: "ok" }),
|
|
93
|
+
);
|
|
94
|
+
try {
|
|
95
|
+
// Simulate the real /readyz handler from gateway/src/index.ts:
|
|
96
|
+
// it fetches `${config.assistantRuntimeBaseUrl}/readyz` with a 3s timeout.
|
|
97
|
+
const upstream = await fetch(`${config.assistantRuntimeBaseUrl}/readyz`, {
|
|
98
|
+
signal: AbortSignal.timeout(3000),
|
|
99
|
+
});
|
|
100
|
+
expect(upstream.ok).toBe(true);
|
|
101
|
+
|
|
102
|
+
// Verify the URL probed is /readyz, not /healthz
|
|
103
|
+
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
|
104
|
+
const calledUrl = fetchSpy.mock.calls[0][0] as string;
|
|
105
|
+
expect(calledUrl).toContain("/readyz");
|
|
106
|
+
expect(calledUrl).not.toContain("/healthz");
|
|
107
|
+
} finally {
|
|
108
|
+
fetchSpy.mockRestore();
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
});
|
|
@@ -131,8 +131,6 @@ const EXCLUDED_FROM_SCHEMA = new Set([
|
|
|
131
131
|
// Runtime proxy catch-all — documented as /{path} in the schema
|
|
132
132
|
"catch-all",
|
|
133
133
|
|
|
134
|
-
// Internal Docker bootstrap endpoint — not a public API
|
|
135
|
-
"/internal/signing-key-bootstrap",
|
|
136
134
|
]);
|
|
137
135
|
|
|
138
136
|
// ── Schema paths that don't map to a discrete route definition ──
|
|
@@ -41,10 +41,25 @@ export function getSigningKeyPath(): string {
|
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
/**
|
|
44
|
-
*
|
|
45
|
-
*
|
|
44
|
+
* Resolve the signing key for the gateway.
|
|
45
|
+
*
|
|
46
|
+
* Resolution order:
|
|
47
|
+
* 1. ACTOR_TOKEN_SIGNING_KEY env var (hex-encoded, set by CLI for Docker)
|
|
48
|
+
* 2. Load from disk (GATEWAY_SECURITY_DIR/actor-token-signing-key)
|
|
49
|
+
* 3. Generate a new key and persist to disk (local mode)
|
|
46
50
|
*/
|
|
47
51
|
export function loadOrCreateSigningKey(): Buffer {
|
|
52
|
+
const envKey = process.env.ACTOR_TOKEN_SIGNING_KEY;
|
|
53
|
+
if (envKey) {
|
|
54
|
+
if (!/^[0-9a-f]{64}$/i.test(envKey)) {
|
|
55
|
+
throw new Error(
|
|
56
|
+
`Invalid ACTOR_TOKEN_SIGNING_KEY: expected 64 hex characters, got ${envKey.length} chars`,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
log.info("Signing key loaded from ACTOR_TOKEN_SIGNING_KEY env var");
|
|
60
|
+
return Buffer.from(envKey, "hex");
|
|
61
|
+
}
|
|
62
|
+
|
|
48
63
|
const keyPath = getSigningKeyPath();
|
|
49
64
|
|
|
50
65
|
if (existsSync(keyPath)) {
|
|
@@ -17,6 +17,14 @@
|
|
|
17
17
|
"description": "Enable user-hosted onboarding flow",
|
|
18
18
|
"defaultEnabled": false
|
|
19
19
|
},
|
|
20
|
+
{
|
|
21
|
+
"id": "platform-hosted-enabled",
|
|
22
|
+
"scope": "macos",
|
|
23
|
+
"key": "platform_hosted_enabled",
|
|
24
|
+
"label": "Platform Hosted Assistants",
|
|
25
|
+
"description": "Enable the Vellum Cloud hosting option on the Hosting screen",
|
|
26
|
+
"defaultEnabled": false
|
|
27
|
+
},
|
|
20
28
|
{
|
|
21
29
|
"id": "contacts",
|
|
22
30
|
"scope": "assistant",
|
|
@@ -65,14 +73,6 @@
|
|
|
65
73
|
"description": "Show Component Gallery and Replay Onboarding in the menu bar",
|
|
66
74
|
"defaultEnabled": false
|
|
67
75
|
},
|
|
68
|
-
{
|
|
69
|
-
"id": "logfire",
|
|
70
|
-
"scope": "assistant",
|
|
71
|
-
"key": "feature_flags.logfire.enabled",
|
|
72
|
-
"label": "Logfire LLM Observability",
|
|
73
|
-
"description": "Enable Logfire tracing for LLM request/response telemetry when LOGFIRE_TOKEN is set",
|
|
74
|
-
"defaultEnabled": false
|
|
75
|
-
},
|
|
76
76
|
{
|
|
77
77
|
"id": "ces-tools",
|
|
78
78
|
"scope": "assistant",
|
|
@@ -113,6 +113,14 @@
|
|
|
113
113
|
"description": "Use managed sidecar transport for CES communication when running in a containerized environment",
|
|
114
114
|
"defaultEnabled": false
|
|
115
115
|
},
|
|
116
|
+
{
|
|
117
|
+
"id": "ces-credential-backend",
|
|
118
|
+
"scope": "assistant",
|
|
119
|
+
"key": "feature_flags.ces-credential-backend.enabled",
|
|
120
|
+
"label": "CES Credential Backend",
|
|
121
|
+
"description": "Route credential reads and writes through the CES process instead of accessing the encrypted store directly",
|
|
122
|
+
"defaultEnabled": true
|
|
123
|
+
},
|
|
116
124
|
{
|
|
117
125
|
"id": "settings-billing",
|
|
118
126
|
"scope": "macos",
|
|
@@ -257,6 +265,14 @@
|
|
|
257
265
|
"description": "Show the Embedding service card in Models & Services settings",
|
|
258
266
|
"defaultEnabled": false
|
|
259
267
|
},
|
|
268
|
+
{
|
|
269
|
+
"id": "settings-schedules",
|
|
270
|
+
"scope": "assistant",
|
|
271
|
+
"key": "feature_flags.settings-schedules.enabled",
|
|
272
|
+
"label": "Schedules Settings Tab",
|
|
273
|
+
"description": "Show the Schedules tab in Settings for viewing and managing schedules",
|
|
274
|
+
"defaultEnabled": false
|
|
275
|
+
},
|
|
260
276
|
{
|
|
261
277
|
"id": "quick-input",
|
|
262
278
|
"scope": "macos",
|
|
@@ -288,6 +304,30 @@
|
|
|
288
304
|
"label": "Channel Voice Transcription",
|
|
289
305
|
"description": "Auto-transcribe voice/audio messages received from channels (Telegram, WhatsApp) before processing",
|
|
290
306
|
"defaultEnabled": true
|
|
307
|
+
},
|
|
308
|
+
{
|
|
309
|
+
"id": "sounds",
|
|
310
|
+
"scope": "assistant",
|
|
311
|
+
"key": "feature_flags.sounds.enabled",
|
|
312
|
+
"label": "Sounds",
|
|
313
|
+
"description": "Enable the Sounds tab in Settings and all app sound playback (event sounds, random ambient sounds)",
|
|
314
|
+
"defaultEnabled": true
|
|
315
|
+
},
|
|
316
|
+
{
|
|
317
|
+
"id": "message-tts",
|
|
318
|
+
"scope": "assistant",
|
|
319
|
+
"key": "feature_flags.message-tts.enabled",
|
|
320
|
+
"label": "Message Text-to-Speech",
|
|
321
|
+
"description": "Show a speaker button on assistant messages to generate and play the message as audio via Fish Audio TTS",
|
|
322
|
+
"defaultEnabled": false
|
|
323
|
+
},
|
|
324
|
+
{
|
|
325
|
+
"id": "backward-releases",
|
|
326
|
+
"scope": "assistant",
|
|
327
|
+
"key": "feature_flags.backward-releases.enabled",
|
|
328
|
+
"label": "Backward Releases",
|
|
329
|
+
"description": "Show older versions in the version picker, allowing rollback to previous releases",
|
|
330
|
+
"defaultEnabled": true
|
|
291
331
|
}
|
|
292
332
|
]
|
|
293
333
|
}
|