@vellumai/vellum-gateway 0.8.5 → 0.8.6

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.8.5",
3
+ "version": "0.8.6",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -526,14 +526,30 @@ describe("guardian/reset-bootstrap", () => {
526
526
  expect(body.error).toBe("Loopback-only endpoint");
527
527
  });
528
528
 
529
- test("rejects in Docker mode (bootstrap secret set)", async () => {
529
+ test("rejects without valid bootstrap secret when secrets are configured", async () => {
530
530
  process.env.GUARDIAN_BOOTSTRAP_SECRET = "some-secret";
531
531
  const handler = createChannelVerificationSessionProxyHandler(makeConfig());
532
532
 
533
533
  const res = await handler.handleResetBootstrap("127.0.0.1");
534
534
  expect(res.status).toBe(403);
535
535
  const body = await res.json();
536
- expect(body.error).toBe("Reset not available in containerized mode");
536
+ expect(body.error).toBe("Invalid bootstrap secret");
537
+ });
538
+
539
+ test("allows reset with valid bootstrap secret", async () => {
540
+ process.env.GUARDIAN_BOOTSTRAP_SECRET = "some-secret";
541
+ writeFileSync(lockPath(), new Date().toISOString(), { mode: 0o600 });
542
+ writeFileSync(consumedPath(), JSON.stringify([0]) + "\n", { mode: 0o600 });
543
+ const handler = createChannelVerificationSessionProxyHandler(makeConfig());
544
+
545
+ const req = new Request("http://localhost:7830/v1/guardian/reset-bootstrap", {
546
+ method: "POST",
547
+ headers: { "x-bootstrap-secret": "some-secret" },
548
+ });
549
+ const res = await handler.handleResetBootstrap("127.0.0.1", req);
550
+ expect(res.status).toBe(200);
551
+ expect(lockFileExists()).toBe(false);
552
+ expect(consumedSecrets()).toEqual([]);
537
553
  });
538
554
 
539
555
  test("resets in-flight flag so init can proceed", async () => {
@@ -62,6 +62,14 @@ const TEST_REGISTRY = {
62
62
  description: "Email channel integration",
63
63
  defaultEnabled: false,
64
64
  },
65
+ {
66
+ id: "platform-features-in-local-mode",
67
+ scope: "assistant",
68
+ key: "platform-features-in-local-mode",
69
+ label: "Platform Features in Local Mode",
70
+ description: "Gate platform API calls in local mode",
71
+ defaultEnabled: true,
72
+ },
65
73
  ],
66
74
  };
67
75
 
@@ -175,6 +175,8 @@ const EXCLUDED_FROM_SCHEMA = new Set([
175
175
  "/.well-known/agent-card.json",
176
176
  // Internal-only: reachable only via vembda's trusted gateway-query proxy
177
177
  "/v1/contacts/guardian/channel",
178
+ // BFF token auth — loopback-only, not part of the public gateway API
179
+ "/auth/token",
178
180
  ]);
179
181
 
180
182
  // ── Schema paths that don't map to a discrete route definition ──
@@ -24,6 +24,7 @@ import {
24
24
  } from "../db/schema.js";
25
25
  import { readCredential } from "../credential-reader.js";
26
26
  import { credentialKey } from "../credential-key.js";
27
+ import { arePlatformFeaturesEnabled } from "../feature-flag-resolver.js";
27
28
  import { getLogger } from "../logger.js";
28
29
 
29
30
  import { CURRENT_POLICY_EPOCH } from "./policy.js";
@@ -544,6 +545,13 @@ function mintRefreshToken(
544
545
  * callers fall back to a generated principal ID in that case.
545
546
  */
546
547
  async function fetchPlatformOwnerDisplayName(): Promise<string | null> {
548
+ if (!arePlatformFeaturesEnabled()) {
549
+ log.debug(
550
+ "platform-features-in-local-mode is disabled — skipping platform owner display name fetch",
551
+ );
552
+ return null;
553
+ }
554
+
547
555
  const isPlatform =
548
556
  process.env.IS_PLATFORM?.trim().toLowerCase() === "true" ||
549
557
  process.env.IS_PLATFORM?.trim() === "1";
@@ -2,6 +2,7 @@ import type { ConfigFileCache } from "../config-file-cache.js";
2
2
  import type { CredentialCache } from "../credential-cache.js";
3
3
  import { credentialKey } from "../credential-key.js";
4
4
  import { fetchImpl } from "../fetch.js";
5
+ import { arePlatformFeaturesEnabled } from "../feature-flag-resolver.js";
5
6
  import { getLogger } from "../logger.js";
6
7
 
7
8
  const log = getLogger("email-callback");
@@ -37,6 +38,13 @@ export async function registerEmailCallbackRoute(caches?: {
37
38
  credentials?: CredentialCache;
38
39
  configFile?: ConfigFileCache;
39
40
  }): Promise<string | undefined> {
41
+ if (!arePlatformFeaturesEnabled()) {
42
+ log.debug(
43
+ "platform-features-in-local-mode is disabled — skipping email callback registration",
44
+ );
45
+ return undefined;
46
+ }
47
+
40
48
  const [platformBaseUrlRaw, assistantApiKeyRaw, assistantIdRaw] =
41
49
  caches?.credentials
42
50
  ? await Promise.all([
@@ -25,6 +25,14 @@
25
25
  "description": "Enable user-hosted onboarding flow",
26
26
  "defaultEnabled": false
27
27
  },
28
+ {
29
+ "id": "prechat-onboarding-condensed-flow",
30
+ "scope": "client",
31
+ "key": "prechat-onboarding-condensed-flow",
32
+ "label": "Condensed Pre-chat Onboarding",
33
+ "description": "Enable the condensed pre-chat onboarding flow for a standard LaunchDarkly percentage rollout.",
34
+ "defaultEnabled": false
35
+ },
28
36
  {
29
37
  "id": "local-docker-enabled",
30
38
  "scope": "client",
@@ -190,7 +198,7 @@
190
198
  "scope": "assistant",
191
199
  "key": "fast-mode",
192
200
  "label": "Fast Mode",
193
- "description": "Enable Anthropic fast mode for Opus models (4.6, 4.7), delivering up to 2.5x higher output tokens per second at premium pricing",
201
+ "description": "Enable Anthropic fast mode for Opus models (4.6, 4.7, 4.8), delivering up to 2.5x higher output tokens per second at premium pricing",
194
202
  "defaultEnabled": false
195
203
  },
196
204
  {
@@ -281,14 +289,6 @@
281
289
  "description": "Expose the developer-only Compaction Playground tab in macOS Settings and enable the /playground/* HTTP endpoints for exercising compaction conditions. Dev-only; default off.",
282
290
  "defaultEnabled": false
283
291
  },
284
- {
285
- "id": "safe-storage-limits",
286
- "scope": "assistant",
287
- "key": "safe-storage-limits",
288
- "label": "Safe Storage Limits",
289
- "description": "Enable disk pressure protection flows that block background work and remote actors while storage is critically low.",
290
- "defaultEnabled": false
291
- },
292
292
  {
293
293
  "id": "account-deletion",
294
294
  "scope": "assistant",
@@ -313,14 +313,6 @@
313
313
  "description": "Show the 'Analyze' / 'Analyze conversation' option in conversation context menus and the conversation title actions dropdown.",
314
314
  "defaultEnabled": false
315
315
  },
316
- {
317
- "id": "pro-plan-adjust",
318
- "scope": "client",
319
- "key": "pro-plan-adjust",
320
- "label": "Pro Plan Adjust",
321
- "description": "Show the rich Plan card (current plan, features, Manage/Upgrade CTA) at the top of the macOS Settings \u2192 Billing tab.",
322
- "defaultEnabled": false
323
- },
324
316
  {
325
317
  "id": "external-plugins",
326
318
  "scope": "assistant",
@@ -440,6 +432,14 @@
440
432
  "label": "Memory Router Playground",
441
433
  "description": "Expose the developer-only Memory Router Playground tab in macOS Settings and the /assistant/memory-router-playground web page for dry-running v4 router config overrides against the live page index. Dev-only; default off.",
442
434
  "defaultEnabled": false
435
+ },
436
+ {
437
+ "id": "platform-features-in-local-mode",
438
+ "scope": "assistant",
439
+ "key": "platform-features-in-local-mode",
440
+ "label": "Platform Features in Local Mode",
441
+ "description": "When enabled, the assistant can call the Vellum platform API from local mode. When disabled, all platform API clients in the daemon, gateway, CES, and web UI no-op with a debug log instead of making outbound requests.",
442
+ "defaultEnabled": true
443
443
  }
444
444
  ]
445
445
  }
@@ -0,0 +1,36 @@
1
+ import { loadFeatureFlagDefaults } from "./feature-flag-defaults.js";
2
+ import {
3
+ hasRemoteFeatureFlagSnapshot,
4
+ readRemoteFeatureFlags,
5
+ } from "./feature-flag-remote-store.js";
6
+ import { readPersistedFeatureFlags } from "./feature-flag-store.js";
7
+
8
+ /**
9
+ * Resolve the effective enabled/disabled state for a feature flag.
10
+ *
11
+ * Priority: persisted (user-toggled) > remote (platform-pushed) > registry default.
12
+ * Undeclared keys with no override return `false` (fail closed).
13
+ */
14
+ export function isFeatureFlagEnabled(key: string): boolean {
15
+ const persisted = readPersistedFeatureFlags();
16
+ const persistedValue = persisted[key];
17
+ if (persistedValue !== undefined) return persistedValue;
18
+
19
+ if (hasRemoteFeatureFlagSnapshot()) {
20
+ const remote = readRemoteFeatureFlags();
21
+ return remote[key] ?? false;
22
+ }
23
+
24
+ const defaults = loadFeatureFlagDefaults();
25
+ return defaults[key]?.defaultEnabled ?? false;
26
+ }
27
+
28
+ function isPlatformMode(): boolean {
29
+ const v = process.env.IS_PLATFORM?.trim().toLowerCase();
30
+ return v === "true" || v === "1";
31
+ }
32
+
33
+ export function arePlatformFeaturesEnabled(): boolean {
34
+ if (isPlatformMode()) return true;
35
+ return isFeatureFlagEnabled("platform-features-in-local-mode");
36
+ }
@@ -115,6 +115,10 @@ export function withExtensionCorsHeaders(
115
115
  }
116
116
  }
117
117
 
118
+ // ---------------------------------------------------------------------------
119
+ // WKWebView CORS
120
+ // ---------------------------------------------------------------------------
121
+
118
122
  /**
119
123
  * Check whether the request `Origin` header matches a known webview origin.
120
124
  * Returns the validated origin string, or null if CORS headers should not be
@@ -0,0 +1,60 @@
1
+ import type { Server } from "bun";
2
+
3
+ import { ensureVellumGuardianBinding } from "../../auth/guardian-bootstrap.js";
4
+ import { CURRENT_POLICY_EPOCH } from "../../auth/policy.js";
5
+ import { mintToken, verifyToken } from "../../auth/token-service.js";
6
+ import { getLogger } from "../../logger.js";
7
+ import { isLoopbackPeer } from "../../util/is-loopback-address.js";
8
+
9
+ const log = getLogger("auth-token");
10
+
11
+ const WEB_ORIGIN_RE = /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/;
12
+
13
+ const TOKEN_TTL_SECONDS = 30 * 24 * 60 * 60;
14
+
15
+ export async function handleCreateToken(
16
+ req: Request,
17
+ server: Server<unknown> | undefined,
18
+ ): Promise<Response> {
19
+ if (!server || !isLoopbackPeer(server, req)) {
20
+ log.warn("Token create rejected: not a loopback peer");
21
+ return Response.json({ error: "Forbidden" }, { status: 403 });
22
+ }
23
+
24
+ const origin = req.headers.get("origin");
25
+ if (!origin || !WEB_ORIGIN_RE.test(origin)) {
26
+ log.warn({ origin }, "Token create rejected: missing or invalid Origin");
27
+ return Response.json({ error: "Forbidden" }, { status: 403 });
28
+ }
29
+
30
+ const authHeader = req.headers.get("authorization");
31
+ if (!authHeader || !authHeader.toLowerCase().startsWith("bearer ")) {
32
+ log.warn("Token create rejected: missing or malformed Authorization header");
33
+ return Response.json({ error: "Unauthorized" }, { status: 401 });
34
+ }
35
+ const bearerToken = authHeader.slice(7);
36
+ const verifyResult = verifyToken(bearerToken, "vellum-gateway");
37
+ if (!verifyResult.ok) {
38
+ log.warn({ reason: verifyResult.reason }, "Token create rejected: invalid guardian token");
39
+ return Response.json({ error: "Unauthorized" }, { status: 401 });
40
+ }
41
+ if (verifyResult.claims.scope_profile !== "actor_client_v1") {
42
+ log.warn({ scope: verifyResult.claims.scope_profile }, "Token create rejected: insufficient scope");
43
+ return Response.json({ error: "Unauthorized" }, { status: 401 });
44
+ }
45
+
46
+ const guardianPrincipalId = await ensureVellumGuardianBinding();
47
+
48
+ const token = mintToken({
49
+ aud: "vellum-gateway",
50
+ sub: `actor:self:${guardianPrincipalId}`,
51
+ scope_profile: "actor_client_v1",
52
+ policy_epoch: CURRENT_POLICY_EPOCH,
53
+ ttlSeconds: TOKEN_TTL_SECONDS,
54
+ });
55
+
56
+ const expiresAt = Math.floor(Date.now() / 1000) + TOKEN_TTL_SECONDS;
57
+ log.info("Bearer token minted for web local mode");
58
+
59
+ return Response.json({ token, expiresAt });
60
+ }
@@ -231,7 +231,7 @@ describe("channel-verification-session-proxy guardian init", () => {
231
231
  expect(body.ok).toBe(true);
232
232
  });
233
233
 
234
- it("rejects handleResetBootstrap in containerized mode", async () => {
234
+ it("rejects handleResetBootstrap without valid bootstrap secret", async () => {
235
235
  process.env.GUARDIAN_BOOTSTRAP_SECRET = "secret-abc";
236
236
  const config = makeConfig();
237
237
  const handler = createChannelVerificationSessionProxyHandler(config);
@@ -240,6 +240,6 @@ describe("channel-verification-session-proxy guardian init", () => {
240
240
 
241
241
  expect(response.status).toBe(403);
242
242
  const body = await response.json();
243
- expect(body.error).toBe("Reset not available in containerized mode");
243
+ expect(body.error).toBe("Invalid bootstrap secret");
244
244
  });
245
245
  });
@@ -458,15 +458,19 @@ export function createChannelVerificationSessionProxyHandler(
458
458
  );
459
459
  }
460
460
 
461
- // Docker mode uses secret-based consumption tracking resetting the
462
- // lockfile alone wouldn't help because consumed secrets are tracked
463
- // separately. Only bare-metal (no bootstrap secret) uses the simple
464
- // lockfile as the sole guard.
465
- if (parseBootstrapSecrets().length > 0) {
466
- return Response.json(
467
- { error: "Reset not available in containerized mode" },
468
- { status: 403 },
469
- );
461
+ // When bootstrap secrets are configured, require a valid one to
462
+ // authorize the reset. This allows bare-metal re-pair (the macOS app
463
+ // reads the secret from the lockfile) while preventing unauthorized
464
+ // resets on Docker deployments where secrets are ephemeral.
465
+ const expectedSecrets = parseBootstrapSecrets();
466
+ if (expectedSecrets.length > 0) {
467
+ const provided = req?.headers.get("x-bootstrap-secret");
468
+ if (!provided || !expectedSecrets.includes(provided)) {
469
+ return Response.json(
470
+ { error: "Invalid bootstrap secret" },
471
+ { status: 403 },
472
+ );
473
+ }
470
474
  }
471
475
 
472
476
  // Refuse while an init request is awaiting an upstream response —
@@ -482,6 +486,7 @@ export function createChannelVerificationSessionProxyHandler(
482
486
 
483
487
  const lockDir = getGatewaySecurityDir();
484
488
  const lockPath = join(lockDir, "guardian-init.lock");
489
+ const consumedPath = join(lockDir, "guardian-init-consumed.json");
485
490
 
486
491
  try {
487
492
  if (existsSync(lockPath)) {
@@ -490,8 +495,12 @@ export function createChannelVerificationSessionProxyHandler(
490
495
  "Guardian bootstrap lock file removed — re-init is now allowed",
491
496
  );
492
497
  }
498
+ if (existsSync(consumedPath)) {
499
+ unlinkSync(consumedPath);
500
+ log.info("Guardian consumed secrets file removed");
501
+ }
493
502
  } catch (err) {
494
- log.error({ err }, "Failed to remove guardian-init.lock");
503
+ log.error({ err }, "Failed to remove guardian-init lock/consumed files");
495
504
  return Response.json(
496
505
  { error: "Failed to remove lock file" },
497
506
  { status: 500 },
@@ -35,7 +35,9 @@ export function createFeatureFlagsGetHandler() {
35
35
  const remote = readRemoteFeatureFlags();
36
36
  const hasRemoteSnapshot = hasRemoteFeatureFlagSnapshot();
37
37
 
38
- // Build entries for ALL declared flags, merging persisted values
38
+ // Build entries for ALL declared flags, merging persisted values.
39
+ // When a remote snapshot exists, flags absent from it are treated
40
+ // as disabled (the platform omitted them intentionally).
39
41
  const entries: FeatureFlagEntry[] = [];
40
42
  for (const [key, def] of Object.entries(defaults)) {
41
43
  const persistedValue = persisted[key];
package/src/index.ts CHANGED
@@ -151,6 +151,7 @@ import {
151
151
  handlePreflight,
152
152
  withCorsHeaders,
153
153
  } from "./http/middleware/cors.js";
154
+ import { handleCreateToken } from "./http/routes/auth-token.js";
154
155
  import {
155
156
  createRouter,
156
157
  type RouteDefinition,
@@ -159,6 +160,7 @@ import {
159
160
  import { SleepWakeDetector } from "./sleep-wake-detector.js";
160
161
  import { callTelegramApi } from "./telegram/api.js";
161
162
  import { fetchImpl } from "./fetch.js";
163
+ import { arePlatformFeaturesEnabled } from "./feature-flag-resolver.js";
162
164
  import { isNewCommand, handleNewCommand } from "./webhook-pipeline.js";
163
165
  import { reconcileTelegramWebhook } from "./telegram/webhook-manager.js";
164
166
  import { registerEmailCallbackRoute } from "./email/register-callback.js";
@@ -1414,6 +1416,14 @@ async function main() {
1414
1416
  },
1415
1417
  ];
1416
1418
 
1419
+ // ── Web token auth ──
1420
+ routes.push({
1421
+ path: "/auth/token",
1422
+ method: "POST",
1423
+ auth: "custom",
1424
+ handler: (req) => handleCreateToken(req, server),
1425
+ });
1426
+
1417
1427
  // Runtime proxy catch-all — must be last so specific routes are checked first.
1418
1428
  routes.push({
1419
1429
  path: /^\//, // match everything
@@ -1685,6 +1695,7 @@ async function main() {
1685
1695
  );
1686
1696
  if (extensionOrigin)
1687
1697
  return withExtensionCorsHeaders(body, extensionOrigin);
1698
+
1688
1699
  return withCorsHeaders(body, webviewOrigin!);
1689
1700
  }
1690
1701
  log.error({ err }, "Unhandled gateway error");
@@ -1743,6 +1754,13 @@ async function main() {
1743
1754
  * Throttled to at most one outbound POST per 30 seconds. */
1744
1755
  let lastRecordActivityTs = 0;
1745
1756
  async function notifyRecordActivity(): Promise<void> {
1757
+ if (!arePlatformFeaturesEnabled()) {
1758
+ log.debug(
1759
+ "platform-features-in-local-mode is disabled — skipping record-activity",
1760
+ );
1761
+ return;
1762
+ }
1763
+
1746
1764
  const now = Date.now();
1747
1765
  if (now - lastRecordActivityTs < 30_000) return;
1748
1766
  lastRecordActivityTs = now;
@@ -72,6 +72,9 @@ const ASSISTANT_SUPPORTED_COMMAND_PATHS = [
72
72
  "channel-verification-sessions resend",
73
73
  "channel-verification-sessions cancel",
74
74
  "channel-verification-sessions revoke",
75
+ "channels",
76
+ "channels list",
77
+ "channels get",
75
78
  "clients",
76
79
  "clients disconnect",
77
80
  "clients list",
package/src/schema.ts CHANGED
@@ -1935,13 +1935,13 @@ export function buildSchema(): Record<string, unknown> {
1935
1935
  post: {
1936
1936
  summary: "Reset guardian bootstrap lock",
1937
1937
  description:
1938
- "Loopback-only, bare-metal-only endpoint that removes the guardian-init lock file so that /v1/guardian/init can be called again. Used by the desktop app to recover from a lost actor token.",
1938
+ "Loopback-only endpoint that removes the guardian-init lock file so that /v1/guardian/init can be called again. When bootstrap secrets are configured, callers must provide a valid x-bootstrap-secret header. Used by the desktop app to recover from a lost actor token.",
1939
1939
  operationId: "guardianResetBootstrap",
1940
1940
  responses: {
1941
1941
  "200": { description: "Lock file removed (or already absent)" },
1942
1942
  "403": {
1943
1943
  description:
1944
- "Forbidden — non-loopback origin or containerized mode",
1944
+ "Forbidden — non-loopback origin or invalid bootstrap secret",
1945
1945
  },
1946
1946
  "409": {
1947
1947
  description: "Guardian init is in progress — try again shortly",
@@ -2,6 +2,7 @@ import type { CredentialCache } from "../credential-cache.js";
2
2
  import type { ConfigFileCache } from "../config-file-cache.js";
3
3
  import { credentialKey } from "../credential-key.js";
4
4
  import { fetchImpl } from "../fetch.js";
5
+ import { arePlatformFeaturesEnabled } from "../feature-flag-resolver.js";
5
6
  import { callTelegramApi } from "./api.js";
6
7
  import { getLogger } from "../logger.js";
7
8
 
@@ -31,6 +32,13 @@ interface PlatformCallbackRouteResponse {
31
32
  async function registerManagedTelegramCallbackRoute(
32
33
  caches?: WebhookManagerCaches,
33
34
  ): Promise<string | undefined> {
35
+ if (!arePlatformFeaturesEnabled()) {
36
+ log.debug(
37
+ "platform-features-in-local-mode is disabled — skipping managed Telegram callback registration",
38
+ );
39
+ return undefined;
40
+ }
41
+
34
42
  const [platformBaseUrlRaw, assistantApiKeyRaw, assistantIdRaw] =
35
43
  caches?.credentials
36
44
  ? await Promise.all([