Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 47 additions & 2 deletions gateway/src/config.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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[] {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -467,6 +510,7 @@ export function loadConfig(): GatewayConfig {
hasSlackChannelAppToken: !!slackChannelAppToken,
slackDeliverAuthBypass,
trustProxy,
hasFeatureFlagToken: !!featureFlagToken,
},
"Configuration loaded",
);
Expand Down Expand Up @@ -517,6 +561,7 @@ export function loadConfig(): GatewayConfig {
slackChannelAppToken,
slackDeliverAuthBypass,
trustProxy,
featureFlagToken,
};
}

Expand Down
256 changes: 256 additions & 0 deletions gateway/src/feature-flags-auth.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof Bun.serve>;
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);
});
});
Loading