From 97012ff3aef29fd77ead64c939c9f8b673c5bafa Mon Sep 17 00:00:00 2001 From: Noa Flaherty Date: Thu, 26 Feb 2026 20:00:13 -0500 Subject: [PATCH 1/2] feat: add client-only PATCH auth split for feature-flags Co-Authored-By: Claude --- gateway/src/config.ts | 49 ++++- gateway/src/feature-flags-auth.test.ts | 256 +++++++++++++++++++++++++ gateway/src/index.ts | 92 ++++++++- 3 files changed, 386 insertions(+), 11 deletions(-) create mode 100644 gateway/src/feature-flags-auth.test.ts diff --git a/gateway/src/config.ts b/gateway/src/config.ts index 6ca9acbaf0d..b6a2e5b3c58 100644 --- a/gateway/src/config.ts +++ b/gateway/src/config.ts @@ -1,5 +1,6 @@ -import { readFileSync } from "node:fs"; -import { join } from "node:path"; +import { randomBytes } from "node:crypto"; +import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs"; +import { join, dirname } from "node:path"; import { homedir } from "node:os"; import { getLogger, type LogFileConfig } from "./logger.js"; import { getRootDir, readKeychainCredential, readCredential, readTwilioCredentials, readWhatsAppCredentials, readSlackChannelCredentials } from "./credential-reader.js"; @@ -89,6 +90,8 @@ export type GatewayConfig = { slackDeliverAuthBypass: boolean; /** When true, trust X-Forwarded-For for client IP resolution (set when behind a reverse proxy). */ trustProxy: boolean; + /** Dedicated token for authenticating PATCH /v1/feature-flags/* requests (distinct from runtimeBearerToken). */ + featureFlagToken: string | undefined; }; function parseRoutingJson(raw: string): RoutingEntry[] { @@ -130,6 +133,40 @@ function readHttpTokenFile(): string | null { } } +function getFeatureFlagTokenPath(): string { + return process.env.FEATURE_FLAG_TOKEN_PATH + ?? join(process.env.BASE_DATA_DIR?.trim() || homedir(), ".vellum", "feature-flag-token"); +} + +/** + * Read the feature-flag token from file, generating a new one if the file + * doesn't exist. This follows the same pattern as http-token but uses a + * separate file so the two tokens are independently revocable. + */ +function readOrGenerateFeatureFlagToken(): string | null { + const tokenPath = getFeatureFlagTokenPath(); + try { + const existing = readFileSync(tokenPath, "utf-8").trim(); + if (existing) return existing; + } catch { + // File doesn't exist — generate below + } + + try { + const dir = dirname(tokenPath); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + const token = randomBytes(32).toString("hex"); + writeFileSync(tokenPath, token + "\n", { mode: 0o600 }); + log.info({ path: tokenPath }, "Generated new feature-flag token"); + return token; + } catch (err) { + log.warn({ err, path: tokenPath }, "Failed to generate feature-flag token file"); + return null; + } +} + export function loadConfig(): GatewayConfig { const telegramBotToken = process.env.TELEGRAM_BOT_TOKEN || undefined; const telegramWebhookSecret = process.env.TELEGRAM_WEBHOOK_SECRET || undefined; @@ -201,6 +238,12 @@ export function loadConfig(): GatewayConfig { const runtimeGatewayOriginSecret = process.env.RUNTIME_GATEWAY_ORIGIN_SECRET || runtimeBearerToken; + // Dedicated feature-flag client token: env var takes precedence, then file + // (auto-generated on first run). Intentionally separate from runtimeBearerToken + // so PATCH /v1/feature-flags/* can reject the runtime token. + const featureFlagToken = + process.env.FEATURE_FLAG_TOKEN || readOrGenerateFeatureFlagToken() || undefined; + const MAX_TIMEOUT_MS = 2_147_483_647; // 2^31 - 1, max safe setTimeout delay const shutdownDrainMsRaw = process.env.GATEWAY_SHUTDOWN_DRAIN_MS || "5000"; @@ -467,6 +510,7 @@ export function loadConfig(): GatewayConfig { hasSlackChannelAppToken: !!slackChannelAppToken, slackDeliverAuthBypass, trustProxy, + hasFeatureFlagToken: !!featureFlagToken, }, "Configuration loaded", ); @@ -517,6 +561,7 @@ export function loadConfig(): GatewayConfig { slackChannelAppToken, slackDeliverAuthBypass, trustProxy, + featureFlagToken, }; } diff --git a/gateway/src/feature-flags-auth.test.ts b/gateway/src/feature-flags-auth.test.ts new file mode 100644 index 00000000000..8d4ecbaecde --- /dev/null +++ b/gateway/src/feature-flags-auth.test.ts @@ -0,0 +1,256 @@ +import { describe, test, expect, beforeAll, afterAll } from "bun:test"; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +/** + * Integration tests for the feature-flags auth split: + * + * - PATCH /v1/feature-flags/:key requires the dedicated feature-flag token + * - PATCH /v1/feature-flags/:key rejects the runtime bearer token with 403 + * - PATCH /v1/feature-flags/:key with no token returns 401 + * - GET /v1/feature-flags accepts the runtime bearer token + * - GET /v1/feature-flags accepts the feature-flag token + */ + +const RUNTIME_TOKEN = "test-runtime-bearer-token"; +const FEATURE_FLAG_TOKEN = "test-feature-flag-client-token"; + +let server: ReturnType; +let baseUrl: string; +let tmpDir: string; + +beforeAll(async () => { + // Create isolated temp directory for config and token files + tmpDir = mkdtempSync(join(tmpdir(), "gw-ff-auth-test-")); + const vellumDir = join(tmpDir, ".vellum"); + mkdirSync(join(vellumDir, "workspace"), { recursive: true }); + + // Write http-token file + writeFileSync(join(vellumDir, "http-token"), RUNTIME_TOKEN); + + // Write feature-flag-token file + writeFileSync(join(vellumDir, "feature-flag-token"), FEATURE_FLAG_TOKEN); + + // Write a minimal config.json so the feature-flags handler can read it + writeFileSync( + join(vellumDir, "workspace", "config.json"), + JSON.stringify({ featureFlags: { "skills.test-skill.enabled": true } }), + ); + + // Set environment so loadConfig picks up our temp directory + process.env.BASE_DATA_DIR = tmpDir; + // Prevent env vars from overriding file-based tokens + delete process.env.RUNTIME_BEARER_TOKEN; + delete process.env.RUNTIME_PROXY_BEARER_TOKEN; + delete process.env.FEATURE_FLAG_TOKEN; + delete process.env.FEATURE_FLAG_TOKEN_PATH; + delete process.env.VELLUM_HTTP_TOKEN_PATH; + + // Import after env is set so loadConfig reads our temp files + const { loadConfig } = await import("./config.js"); + const { validateBearerToken } = await import("./http/auth/bearer.js"); + const { createFeatureFlagsGetHandler, createFeatureFlagsPatchHandler } = + await import("./http/routes/feature-flags.js"); + + const config = loadConfig(); + const handleFeatureFlagsGet = createFeatureFlagsGetHandler(); + const handleFeatureFlagsPatch = createFeatureFlagsPatchHandler(); + + // Verify tokens loaded correctly + if (config.runtimeBearerToken !== RUNTIME_TOKEN) { + throw new Error( + `Expected runtimeBearerToken to be "${RUNTIME_TOKEN}", got "${config.runtimeBearerToken}"`, + ); + } + if (config.featureFlagToken !== FEATURE_FLAG_TOKEN) { + throw new Error( + `Expected featureFlagToken to be "${FEATURE_FLAG_TOKEN}", got "${config.featureFlagToken}"`, + ); + } + + // Start a minimal Bun server that replicates only the feature-flag auth + // routing from index.ts (avoids starting the full gateway with all its + // side effects like Telegram reconciliation and credential watchers). + server = Bun.serve({ + port: 0, // random available port + async fetch(req) { + const url = new URL(req.url); + + // GET /v1/feature-flags — accepts either token + if (url.pathname === "/v1/feature-flags" && req.method === "GET") { + if (!config.runtimeBearerToken && !config.featureFlagToken) { + return Response.json( + { error: "Service not configured: bearer token required" }, + { status: 503 }, + ); + } + const authHeader = req.headers.get("authorization"); + let authorized = false; + if (config.runtimeBearerToken) { + const runtimeAuth = validateBearerToken(authHeader, config.runtimeBearerToken); + if (runtimeAuth.authorized) authorized = true; + } + if (!authorized && config.featureFlagToken) { + const flagAuth = validateBearerToken(authHeader, config.featureFlagToken); + if (flagAuth.authorized) authorized = true; + } + if (!authorized) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + return handleFeatureFlagsGet(req); + } + + // PATCH /v1/feature-flags/:flagKey — requires feature-flag token + const patchMatch = url.pathname.match(/^\/v1\/feature-flags\/(.+)$/); + if (patchMatch && req.method === "PATCH") { + if (!config.featureFlagToken) { + return Response.json( + { error: "Service not configured: feature-flag token required" }, + { status: 503 }, + ); + } + + // Explicitly reject runtime bearer token + if (config.runtimeBearerToken) { + const isRuntimeToken = validateBearerToken( + req.headers.get("authorization"), + config.runtimeBearerToken, + ); + if (isRuntimeToken.authorized) { + return Response.json( + { error: "Forbidden: runtime token cannot be used for feature-flag mutations" }, + { status: 403 }, + ); + } + } + + const authResult = validateBearerToken( + req.headers.get("authorization"), + config.featureFlagToken, + ); + if (!authResult.authorized) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + + let flagKey: string; + try { + flagKey = decodeURIComponent(patchMatch[1]); + } catch { + return Response.json({ error: "Invalid flag key encoding" }, { status: 400 }); + } + return handleFeatureFlagsPatch(req, flagKey); + } + + return Response.json({ error: "Not found" }, { status: 404 }); + }, + }); + + baseUrl = `http://localhost:${server.port}`; +}); + +afterAll(() => { + server?.stop(true); + try { + rmSync(tmpDir, { recursive: true, force: true }); + } catch { + // best-effort cleanup + } +}); + +describe("PATCH /v1/feature-flags/:key auth", () => { + test("rejects request with runtime bearer token (403)", async () => { + const res = await fetch(`${baseUrl}/v1/feature-flags/skills.my-skill.enabled`, { + method: "PATCH", + headers: { + Authorization: `Bearer ${RUNTIME_TOKEN}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ enabled: true }), + }); + expect(res.status).toBe(403); + const body = await res.json(); + expect(body.error).toContain("runtime token"); + }); + + test("succeeds with feature-flag client token", async () => { + const res = await fetch(`${baseUrl}/v1/feature-flags/skills.my-skill.enabled`, { + method: "PATCH", + headers: { + Authorization: `Bearer ${FEATURE_FLAG_TOKEN}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ enabled: true }), + }); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.key).toBe("skills.my-skill.enabled"); + expect(body.enabled).toBe(true); + }); + + test("rejects request with no token (401)", async () => { + const res = await fetch(`${baseUrl}/v1/feature-flags/skills.my-skill.enabled`, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ enabled: false }), + }); + expect(res.status).toBe(401); + }); + + test("rejects request with wrong token (401)", async () => { + const res = await fetch(`${baseUrl}/v1/feature-flags/skills.my-skill.enabled`, { + method: "PATCH", + headers: { + Authorization: "Bearer totally-wrong-token", + "Content-Type": "application/json", + }, + body: JSON.stringify({ enabled: false }), + }); + expect(res.status).toBe(401); + }); +}); + +describe("GET /v1/feature-flags auth", () => { + test("succeeds with runtime bearer token", async () => { + const res = await fetch(`${baseUrl}/v1/feature-flags`, { + method: "GET", + headers: { + Authorization: `Bearer ${RUNTIME_TOKEN}`, + }, + }); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.flags).toBeDefined(); + }); + + test("succeeds with feature-flag client token", async () => { + const res = await fetch(`${baseUrl}/v1/feature-flags`, { + method: "GET", + headers: { + Authorization: `Bearer ${FEATURE_FLAG_TOKEN}`, + }, + }); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.flags).toBeDefined(); + }); + + test("rejects request with no token (401)", async () => { + const res = await fetch(`${baseUrl}/v1/feature-flags`, { + method: "GET", + }); + expect(res.status).toBe(401); + }); + + test("rejects request with wrong token (401)", async () => { + const res = await fetch(`${baseUrl}/v1/feature-flags`, { + method: "GET", + headers: { + Authorization: "Bearer totally-wrong-token", + }, + }); + expect(res.status).toBe(401); + }); +}); diff --git a/gateway/src/index.ts b/gateway/src/index.ts index f57739fba17..9ce667b9de3 100644 --- a/gateway/src/index.ts +++ b/gateway/src/index.ts @@ -116,6 +116,54 @@ function startHttpTokenWatcher(cfg: GatewayConfig): FSWatcher | null { } } +/** + * Watch `~/.vellum/feature-flag-token` and update the config when the file + * changes. Mirrors startHttpTokenWatcher but for the feature-flag client token. + */ +function startFeatureFlagTokenWatcher(cfg: GatewayConfig): FSWatcher | null { + const tokenPath = process.env.FEATURE_FLAG_TOKEN_PATH + ?? join(process.env.BASE_DATA_DIR?.trim() || homedir(), ".vellum", "feature-flag-token"); + + const dir = dirname(tokenPath); + try { + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + } catch (err) { + log.warn({ err, path: dir }, "Cannot create token directory, skipping feature-flag-token watcher"); + return null; + } + + let debounceTimer: ReturnType | null = null; + + function refresh(): void { + if (process.env.FEATURE_FLAG_TOKEN) return; + + try { + const token = readFileSync(tokenPath, "utf-8").trim() || undefined; + if (token && token !== cfg.featureFlagToken) { + cfg.featureFlagToken = token; + log.info("Feature-flag token refreshed from file"); + } + } catch { + // File doesn't exist yet + } + } + + try { + const watcher = watch(existsSync(tokenPath) ? tokenPath : dir, { persistent: false }, (_event, filename) => { + if (!existsSync(tokenPath) && filename !== "feature-flag-token") return; + if (debounceTimer) clearTimeout(debounceTimer); + debounceTimer = setTimeout(refresh, 500); + }); + log.info({ path: tokenPath }, "Watching feature-flag-token for changes"); + return watcher; + } catch (err) { + log.warn({ err, path: tokenPath }, "Failed to watch feature-flag-token file"); + return null; + } +} + function main() { const config = loadConfig(); initLogger(config.logFile); @@ -402,17 +450,24 @@ function main() { // ── Feature flags API ── if (url.pathname === "/v1/feature-flags" && req.method === "GET") { - if (!config.runtimeBearerToken) { + if (!config.runtimeBearerToken && !config.featureFlagToken) { return Response.json( { error: "Service not configured: bearer token required" }, { status: 503 }, ); } - const authResult = validateBearerToken( - tracedReq.headers.get("authorization"), - config.runtimeBearerToken, - ); - if (!authResult.authorized) { + // GET accepts either the runtime bearer token or the feature-flag token + const authHeader = tracedReq.headers.get("authorization"); + let authorized = false; + if (config.runtimeBearerToken) { + const runtimeAuth = validateBearerToken(authHeader, config.runtimeBearerToken); + if (runtimeAuth.authorized) authorized = true; + } + if (!authorized && config.featureFlagToken) { + const flagAuth = validateBearerToken(authHeader, config.featureFlagToken); + if (flagAuth.authorized) authorized = true; + } + if (!authorized) { authRateLimiter.recordFailure(getClientIp(req, svr, config.trustProxy)); return Response.json({ error: "Unauthorized" }, { status: 401 }); } @@ -421,15 +476,32 @@ function main() { const featureFlagPatchMatch = url.pathname.match(/^\/v1\/feature-flags\/(.+)$/); if (featureFlagPatchMatch && req.method === "PATCH") { - if (!config.runtimeBearerToken) { + if (!config.featureFlagToken) { return Response.json( - { error: "Service not configured: bearer token required" }, + { error: "Service not configured: feature-flag token required" }, { status: 503 }, ); } + + // Explicitly reject the runtime bearer token on PATCH even if it is + // otherwise valid — PATCH requires the dedicated feature-flag token. + if (config.runtimeBearerToken) { + const isRuntimeToken = validateBearerToken( + tracedReq.headers.get("authorization"), + config.runtimeBearerToken, + ); + if (isRuntimeToken.authorized) { + authRateLimiter.recordFailure(getClientIp(req, svr, config.trustProxy)); + return Response.json( + { error: "Forbidden: runtime token cannot be used for feature-flag mutations" }, + { status: 403 }, + ); + } + } + const authResult = validateBearerToken( tracedReq.headers.get("authorization"), - config.runtimeBearerToken, + config.featureFlagToken, ); if (!authResult.authorized) { authRateLimiter.recordFailure(getClientIp(req, svr, config.trustProxy)); @@ -611,6 +683,7 @@ function main() { configFileWatcher.start(); const httpTokenWatcher = startHttpTokenWatcher(config); + const featureFlagTokenWatcher = startFeatureFlagTokenWatcher(config); const drainMs = config.shutdownDrainMs; @@ -620,6 +693,7 @@ function main() { credentialWatcher.stop(); configFileWatcher.stop(); httpTokenWatcher?.close(); + featureFlagTokenWatcher?.close(); telegramDedupCache.stopCleanup(); smsDedupCache.stopCleanup(); whatsappDedupCache.stopCleanup(); From 9f4d0312ab38c5f3fab12949465d30cc48191810 Mon Sep 17 00:00:00 2001 From: Noa Flaherty Date: Thu, 26 Feb 2026 20:05:17 -0500 Subject: [PATCH 2/2] fix: remove rate-limit on 403 and handle identical token edge case - Remove authRateLimiter.recordFailure() from 403 path to avoid penalizing legitimately authenticated clients who used the wrong token type (Issue 1) - Only record failures on 401 (truly invalid authentication) - Skip runtime-token rejection when FEATURE_FLAG_TOKEN is identical to runtimeBearerToken to support single-token deployments (Issue 2) Addresses review feedback on PR #10171 --- gateway/src/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gateway/src/index.ts b/gateway/src/index.ts index 9ce667b9de3..b552b0eaa44 100644 --- a/gateway/src/index.ts +++ b/gateway/src/index.ts @@ -485,13 +485,13 @@ function main() { // Explicitly reject the runtime bearer token on PATCH even if it is // otherwise valid — PATCH requires the dedicated feature-flag token. - if (config.runtimeBearerToken) { + // Skip this check if both tokens are identical (allows single-token deployments). + if (config.runtimeBearerToken && config.runtimeBearerToken !== config.featureFlagToken) { const isRuntimeToken = validateBearerToken( tracedReq.headers.get("authorization"), config.runtimeBearerToken, ); if (isRuntimeToken.authorized) { - authRateLimiter.recordFailure(getClientIp(req, svr, config.trustProxy)); return Response.json( { error: "Forbidden: runtime token cannot be used for feature-flag mutations" }, { status: 403 },