feat(authz): introduce centralized proxy-based authz pipeline and lifecycle policy (#1632)

Integrated into release/v3.7.2
This commit is contained in:
abix5
2026-04-27 13:12:42 +03:00
committed by GitHub
parent 5026aaaabb
commit dbdfb8a328
97 changed files with 2611 additions and 1007 deletions

View File

@@ -110,6 +110,13 @@ REQUIRE_API_KEY=false
# Default: false | Security risk if enabled on shared instances.
ALLOW_API_KEY_REVEAL=false
# Shared secret for the internal Codex Responses WebSocket bridge.
# Used by: src/app/api/internal/codex-responses-ws/route.ts — authenticates
# bridge requests between the Electron/browser WS relay and OmniRoute.
# ⚠️ REQUIRED for production — if unset, all WS bridge requests are rejected.
# Generate: openssl rand -base64 32
# OMNIROUTE_WS_BRIDGE_SECRET=
# Comma-separated API key IDs that skip request logging (GDPR/compliance).
# Used by: src/lib/compliance/index.ts — suppresses logs for specific keys.
# NO_LOG_API_KEY_IDS=key_abc123,key_def456

View File

@@ -135,14 +135,19 @@ jobs:
- name: Smoke packaged Electron app
if: matrix.platform != 'linux'
# Windows CI: requestSingleInstanceLock() fails due to USERPROFILE
# sanitization needed for the build step. Smoke is best-effort there.
continue-on-error: ${{ matrix.platform == 'windows' }}
env:
ELECTRON_SMOKE_TIMEOUT_MS: 60000
ELECTRON_SMOKE_STREAM_LOGS: "1"
run: npm run electron:smoke:packaged
- name: Smoke packaged Electron app (Linux)
if: matrix.platform == 'linux'
env:
ELECTRON_SMOKE_TIMEOUT_MS: 60000
ELECTRON_SMOKE_STREAM_LOGS: "1"
run: xvfb-run -a npm run electron:smoke:packaged
- name: Collect installers

View File

@@ -1,5 +1,5 @@
import { randomUUID } from "crypto";
import { getCorsOrigin } from "../utils/cors.ts";
import { CORS_HEADERS } from "../utils/cors.ts";
/**
* Audio Speech Handler (TTS)
*
@@ -49,7 +49,7 @@ function upstreamErrorResponse(res, errText) {
{ error: { message: errorMessage, code: res.status } },
{
status: res.status,
headers: { "Access-Control-Allow-Origin": getCorsOrigin() },
headers: { ...CORS_HEADERS },
}
);
}
@@ -62,8 +62,8 @@ function audioStreamResponse(res, defaultContentType = "audio/mpeg") {
return new Response(res.body, {
status: 200,
headers: {
...CORS_HEADERS,
"Content-Type": contentType,
"Access-Control-Allow-Origin": getCorsOrigin(),
"Transfer-Encoding": "chunked",
},
});
@@ -174,7 +174,6 @@ async function handleHyperbolicSpeech(providerConfig, body, token) {
status: 200,
headers: {
"Content-Type": "audio/mpeg",
"Access-Control-Allow-Origin": getCorsOrigin(),
},
});
}
@@ -320,7 +319,6 @@ async function handleInworldSpeech(providerConfig, body, modelId, token) {
status: 200,
headers: {
"Content-Type": mimeType,
"Access-Control-Allow-Origin": getCorsOrigin(),
},
});
}
@@ -491,7 +489,6 @@ async function handleCoquiSpeech(providerConfig, body) {
status: 200,
headers: {
"Content-Type": contentType,
"Access-Control-Allow-Origin": getCorsOrigin(),
},
});
}
@@ -519,7 +516,6 @@ async function handleTortoiseSpeech(providerConfig, body) {
status: 200,
headers: {
"Content-Type": contentType,
"Access-Control-Allow-Origin": getCorsOrigin(),
},
});
}

View File

@@ -1,4 +1,4 @@
import { getCorsOrigin } from "../utils/cors.ts";
import { CORS_HEADERS } from "../utils/cors.ts";
/**
* Audio Transcription Handler
*
@@ -51,7 +51,7 @@ function upstreamErrorResponse(res, errText) {
{ error: { message: errorMessage, code: res.status } },
{
status: res.status,
headers: { "Access-Control-Allow-Origin": getCorsOrigin() },
headers: { ...CORS_HEADERS },
}
);
}
@@ -152,7 +152,7 @@ async function handleDeepgramTranscription(
// Return it explicitly so the client can distinguish from a credentials error
return Response.json(
{ text: text ?? "", noSpeechDetected: text === null || text === "" },
{ headers: { "Access-Control-Allow-Origin": getCorsOrigin() } }
{ headers: { ...CORS_HEADERS } }
);
}
@@ -213,10 +213,7 @@ async function handleAssemblyAITranscription(providerConfig, file, modelId, toke
const result = await pollRes.json();
if (result.status === "completed") {
return Response.json(
{ text: result.text || "" },
{ headers: { "Access-Control-Allow-Origin": getCorsOrigin() } }
);
return Response.json({ text: result.text || "" }, { headers: { ...CORS_HEADERS } });
}
if (result.status === "error") {
@@ -250,7 +247,7 @@ async function handleNvidiaTranscription(providerConfig, file, modelId, token) {
// Normalize to { text } — Nvidia may return { text } directly or nested
const text = data.text || data.transcript || "";
return Response.json({ text }, { headers: { "Access-Control-Allow-Origin": getCorsOrigin() } });
return Response.json({ text }, { headers: { ...CORS_HEADERS } });
}
/**
@@ -281,7 +278,7 @@ async function handleHuggingFaceTranscription(providerConfig, file, modelId, tok
// HuggingFace returns { text } directly
const text = data.text || "";
return Response.json({ text }, { headers: { "Access-Control-Allow-Origin": getCorsOrigin() } });
return Response.json({ text }, { headers: { ...CORS_HEADERS } });
}
/**
@@ -389,7 +386,7 @@ export async function handleAudioTranscription({
return new Response(data, {
status: 200,
headers: { "Content-Type": contentType, "Access-Control-Allow-Origin": getCorsOrigin() },
headers: { "Content-Type": contentType },
});
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err));

View File

@@ -1,4 +1,4 @@
import { getCorsOrigin } from "../utils/cors.ts";
import { CORS_HEADERS } from "../utils/cors.ts";
import { detectFormatFromEndpoint, getTargetFormat } from "../services/provider.ts";
import { translateRequest, needsTranslation } from "../translator/index.ts";
import { FORMATS } from "../translator/formats.ts";
@@ -1018,7 +1018,6 @@ export async function handleChatCore({
status: cachedIdemp.status,
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": getCorsOrigin(),
"X-OmniRoute-Idempotent": "true",
...buildOmniRouteResponseMetaHeaders({
provider,
@@ -1355,7 +1354,6 @@ export async function handleChatCore({
response: new Response(JSON.stringify(cached), {
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": getCorsOrigin(),
[OMNIROUTE_RESPONSE_HEADERS.cache]: "HIT",
...buildOmniRouteResponseMetaHeaders({
provider,
@@ -1872,7 +1870,6 @@ export async function handleChatCore({
status: statusCode,
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": getCorsOrigin(),
},
}
),
@@ -3184,7 +3181,6 @@ export async function handleChatCore({
response: new Response(JSON.stringify(translatedResponse), {
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": getCorsOrigin(),
[OMNIROUTE_RESPONSE_HEADERS.cache]: "MISS",
...buildOmniRouteResponseMetaHeaders({
provider,
@@ -3210,7 +3206,6 @@ export async function handleChatCore({
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
"Access-Control-Allow-Origin": getCorsOrigin(),
[OMNIROUTE_RESPONSE_HEADERS.cache]: "MISS",
...buildOmniRouteResponseMetaHeaders({
provider,

View File

@@ -20,10 +20,7 @@ import { getImageProvider, parseImageModel } from "../config/imageRegistry.ts";
import { mapImageSize } from "../translator/image/sizeMapper.ts";
import { getCodexClientVersion, getCodexUserAgent } from "../config/codexClient.ts";
import { ChatGptWebExecutor } from "../executors/chatgpt-web.ts";
import {
getChatGptImage,
findChatGptImageBySha256,
} from "../services/chatgptImageCache.ts";
import { getChatGptImage, findChatGptImageBySha256 } from "../services/chatgptImageCache.ts";
import { createHash } from "node:crypto";
import { saveCallLog } from "@/lib/usageDb";
import {
@@ -32,6 +29,7 @@ import {
fetchComfyOutput,
extractComfyOutputFiles,
} from "../utils/comfyuiClient.ts";
import { fetchRemoteImage } from "@/shared/network/remoteImageFetch";
const OPENAI_IMAGE_TO_IMAGE_MODELS = new Set([
"black-forest-labs/FLUX.1-redux",
@@ -625,8 +623,7 @@ async function handleChatGptWebImageGeneration({
// own limit for image-1 / dall-e-3) so a stray n=1000 doesn't pin the
// executor for hours before the upstream HTTP timeout fires.
const CHATGPT_WEB_IMAGE_N_MAX = 4;
const rawCount =
Number.isInteger(body.n) && (body.n as number) > 0 ? (body.n as number) : 1;
const rawCount = Number.isInteger(body.n) && (body.n as number) > 0 ? (body.n as number) : 1;
if (rawCount > CHATGPT_WEB_IMAGE_N_MAX) {
return saveImageErrorResult({
provider,
@@ -1547,15 +1544,11 @@ async function resolveImageSource(source) {
}
if (isHttpUrl(trimmed)) {
const response = await fetch(trimmed);
if (!response.ok) {
throw new Error(`Failed to fetch image source (${response.status})`);
}
const buffer = Buffer.from(await response.arrayBuffer());
const remoteImage = await fetchRemoteImage(trimmed);
return {
buffer,
base64: buffer.toString("base64"),
contentType: response.headers.get("content-type") || "application/octet-stream",
buffer: remoteImage.buffer,
base64: remoteImage.buffer.toString("base64"),
contentType: remoteImage.contentType,
};
}
@@ -2437,12 +2430,8 @@ async function normalizeNanoBananaTaskResult(taskData, body, log) {
if (urlCandidates.length > 0) {
const firstUrl = urlCandidates[0];
const resp = await fetch(firstUrl);
if (!resp.ok) {
throw new Error(`Failed to fetch NanoBanana result image URL (${resp.status})`);
}
const arrayBuffer = await resp.arrayBuffer();
const base64 = Buffer.from(arrayBuffer).toString("base64");
const remoteImage = await fetchRemoteImage(firstUrl);
const base64 = remoteImage.buffer.toString("base64");
return [{ b64_json: base64, revised_prompt: body.prompt }];
}
}

View File

@@ -1,4 +1,4 @@
import { getCorsOrigin } from "../utils/cors.ts";
import { CORS_HEADERS } from "../utils/cors.ts";
/**
* Moderation Handler
*
@@ -58,14 +58,14 @@ export async function handleModeration({ body, credentials }) {
status: res.status,
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": getCorsOrigin(),
...CORS_HEADERS,
},
});
}
const data = await res.json();
return Response.json(data, {
headers: { "Access-Control-Allow-Origin": getCorsOrigin() },
headers: { ...CORS_HEADERS },
});
} catch (err) {
return errorResponse(500, `Moderation request failed: ${err.message}`);

View File

@@ -1,4 +1,4 @@
import { getCorsOrigin } from "../utils/cors.ts";
import { CORS_HEADERS } from "../utils/cors.ts";
/**
* Rerank Handler
*
@@ -131,7 +131,7 @@ export async function handleRerank({
const result = transformResponseFromProvider(providerConfig, data);
return Response.json(result, {
headers: { "Access-Control-Allow-Origin": getCorsOrigin() },
headers: { ...CORS_HEADERS },
});
} catch (err) {
return errorResponse(500, `Rerank request failed: ${err.message}`);

View File

@@ -1,4 +1,4 @@
import { getCorsOrigin } from "../utils/cors.ts";
import { CORS_HEADERS } from "../utils/cors.ts";
/**
* Responses API Handler for Workers
* Converts Chat Completions to Codex Responses API format
@@ -81,7 +81,6 @@ export async function handleResponsesCore({
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
"Access-Control-Allow-Origin": getCorsOrigin(),
},
}),
};

View File

@@ -42,6 +42,8 @@ function getPlatform(): string {
switch (p) {
case "win32":
return "windows";
case "darwin":
return "macos";
default:
return p; // "linux", etc.
}

View File

@@ -255,7 +255,14 @@ async function validateResponseQuality(
return { valid: false, reason: "empty content and no tool_calls in response" };
}
return { valid: true };
return {
valid: true,
clonedResponse: new Response(text, {
status: response.status,
statusText: response.statusText,
headers: response.headers,
}),
};
}
// In-memory atomic counter per combo for round-robin distribution
@@ -1628,7 +1635,7 @@ export async function handleComboChat({
}
}
return result;
return quality.clonedResponse ?? result;
}
// Extract error info from response

View File

@@ -1,4 +1,4 @@
import { getCorsOrigin } from "./cors.ts";
import { CORS_HEADERS } from "./cors.ts";
import { detectFormat } from "../services/provider.ts";
import { translateResponse, initState } from "../translator/index.ts";
import { FORMATS } from "../translator/formats.ts";
@@ -124,7 +124,6 @@ function createNonStreamingResponse(sourceFormat, model) {
response: new Response(JSON.stringify(openaiResponse), {
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": getCorsOrigin(),
},
}),
};
@@ -158,7 +157,6 @@ function createNonStreamingResponse(sourceFormat, model) {
response: new Response(JSON.stringify(finalResponse), {
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": getCorsOrigin(),
},
}),
};
@@ -206,7 +204,6 @@ function createStreamingResponse(sourceFormat, model) {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
"Access-Control-Allow-Origin": getCorsOrigin(),
},
}),
};

View File

@@ -1,22 +1,13 @@
/**
* CORS configuration for open-sse handlers.
* Static CORS headers for open-sse handlers.
*
* Reads `CORS_ORIGIN` env var (default: "*") so that all handlers
* use the same configurable origin. Equivalent to src/shared/utils/cors.ts
* for the open-sse package boundary.
* `Access-Control-Allow-Origin` is set by the Next.js middleware
* (`src/server/cors/origins.ts`). Handlers in this package only need the
* methods/headers list; the middleware overlays the allowed origin per
* the central allowlist on the way out.
*/
const CORS_ORIGIN = process.env.CORS_ORIGIN || "*";
export const CORS_HEADERS: Record<string, string> = {
"Access-Control-Allow-Origin": CORS_ORIGIN,
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization, x-api-key, anthropic-version",
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, PATCH, OPTIONS",
"Access-Control-Allow-Headers":
"Content-Type, Authorization, x-api-key, anthropic-version, x-omniroute-connection, x-internal-test, accept",
};
/**
* Returns just the origin header for merging into existing header objects.
*/
export function getCorsOrigin(): string {
return CORS_ORIGIN;
}

View File

@@ -1,4 +1,4 @@
import { getCorsOrigin } from "./cors.ts";
import { CORS_HEADERS } from "./cors.ts";
import { getDefaultErrorMessage, getErrorInfo } from "../config/errorConfig.ts";
import { normalizePayloadForLog } from "@/lib/logPayloads";
import type { ModelCooldownErrorPayload } from "@/types";
@@ -32,7 +32,6 @@ export function errorResponse(statusCode, message) {
status: statusCode,
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": getCorsOrigin(),
},
});
}
@@ -258,7 +257,6 @@ export function providerCircuitOpenResponse(
status: 503,
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": getCorsOrigin(),
"Retry-After": String(retryAfterSec),
"X-OmniRoute-Provider-Breaker": "open",
},
@@ -307,7 +305,6 @@ export function modelCooldownResponse({
status: 429,
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": getCorsOrigin(),
"Retry-After": String(retryAfterSec),
},
}

View File

@@ -1,4 +1,4 @@
import { getCorsOrigin } from "./cors.ts";
import { CORS_HEADERS } from "./cors.ts";
type PendingToolCall = {
id?: string;
@@ -105,7 +105,6 @@ export function transformToOllama(response, model) {
return new Response(response.body.pipeThrough(transform), {
headers: {
"Content-Type": "application/x-ndjson",
"Access-Control-Allow-Origin": getCorsOrigin(),
},
});
}

View File

@@ -323,12 +323,22 @@ export function buildSmokeEnv({
smokeEnv.TMPDIR ||= join(dataDir, "tmp");
}
return {
const baseEnv = {
...smokeEnv,
DATA_DIR: dataDir,
ELECTRON_ENABLE_LOGGING: "1",
ELECTRON_ENABLE_STACK_DUMPING: "1",
};
// CI environments need sandbox disabled (GitHub Actions runners
// cannot configure SUID chrome-sandbox on Linux, and Windows
// runners may exit silently without it).
if (parentEnv.CI) {
baseEnv.CI = parentEnv.CI;
baseEnv.ELECTRON_DISABLE_SANDBOX = "1";
}
return baseEnv;
}
function isInsideDir(parentDir, candidateDir) {
@@ -357,6 +367,15 @@ async function ensureSmokeEnvDirs(smokeEnv, dataDir) {
),
];
// On Windows, Electron derives its userData from APPDATA/<productName>.
// requestSingleInstanceLock() runs synchronously at module load and
// fails silently if the directory doesn't exist yet — causing exit(0).
if (platform() === "win32" && smokeEnv.APPDATA) {
for (const subdir of ["omniroute-desktop", "OmniRoute", "omniroute"]) {
dirs.push(join(smokeEnv.APPDATA, subdir));
}
}
await Promise.all(dirs.map((dir) => mkdir(dir, { recursive: true })));
}
@@ -398,13 +417,25 @@ async function main() {
await assertPortIsFree(smokeUrl);
await ensureSmokeEnvDirs(smokeEnv, dataDir);
// ── CI sandbox workaround ──────────────────────────────────
// GitHub Actions runners cannot set SUID on chrome-sandbox (Linux)
// and Windows runners may fail silently without --no-sandbox.
const spawnArgs = [];
if (process.env.CI) {
spawnArgs.push("--no-sandbox", "--disable-gpu");
if (platform() === "linux") {
spawnArgs.push("--disable-dev-shm-usage");
}
}
console.log(`[electron-smoke] launching ${appExecutable}`);
if (spawnArgs.length) console.log(`[electron-smoke] CI args: ${spawnArgs.join(" ")}`);
console.log(`[electron-smoke] DATA_DIR=${dataDir}`);
console.log(`[electron-smoke] waiting for ${smokeUrl}`);
const logs = { value: "" };
const streamLogs = process.env.ELECTRON_SMOKE_STREAM_LOGS === "1";
const child = spawn(appExecutable, [], {
const child = spawn(appExecutable, spawnArgs, {
detached: platform() !== "win32",
env: smokeEnv,
stdio: ["ignore", "pipe", "pipe"],

View File

@@ -184,21 +184,45 @@ export default function SecurityTab() {
<h3 className="text-lg font-semibold">{t("apiEndpointProtection")}</h3>
</div>
<div className="flex flex-col gap-4">
{/* Require auth for /models */}
<div className="rounded-lg border border-border/50 bg-black/[0.02] dark:bg-white/[0.02] p-3 text-sm text-text-muted">
<p className="font-medium text-text">{t("authModelHeading")}</p>
<ul className="list-disc pl-5 mt-1 space-y-1">
<li>{t("authModelClient")}</li>
<li>{t("authModelManagement")}</li>
<li>{t("authModelPublic")}</li>
</ul>
</div>
<div className="flex items-center justify-between">
<div>
<p className="font-medium">{t("requireAuthModels")}</p>
<p className="text-sm text-text-muted">{t("requireAuthModelsDesc")}</p>
<p className="font-medium">{t("bruteForceProtection")}</p>
<p className="text-sm text-text-muted">{t("bruteForceProtectionDesc")}</p>
</div>
<Toggle
checked={settings.requireAuthForModels === true}
onChange={() => updateSetting("requireAuthForModels", !settings.requireAuthForModels)}
checked={settings.bruteForceProtection !== false}
onChange={() =>
updateSetting("bruteForceProtection", !(settings.bruteForceProtection !== false))
}
disabled={loading}
/>
</div>
<div>
<div className="mb-2">
<p className="font-medium">{t("corsAllowedOrigins")}</p>
<p className="text-sm text-text-muted">{t("corsAllowedOriginsDesc")}</p>
</div>
<Input
type="text"
placeholder="https://app.example.com, https://admin.example.com"
value={typeof settings.corsOrigins === "string" ? settings.corsOrigins : ""}
onChange={(e) => setSettings((prev) => ({ ...prev, corsOrigins: e.target.value }))}
onBlur={(e) => updateSetting("corsOrigins", e.target.value.trim())}
/>
</div>
{/* Blocked Providers */}
<div className="pt-4 border-t border-border/50">
<div>
<div className="mb-3">
<p className="font-medium">{t("blockedProviders")}</p>
<p className="text-sm text-text-muted">{t("blockedProvidersDesc")}</p>

View File

@@ -10,6 +10,11 @@ import {
} from "@/lib/auth/managementPassword";
import { loginSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
import {
checkLoginGuard,
clearLoginAttempts,
recordLoginFailure,
} from "@/server/auth/loginGuard";
// SECURITY: No hardcoded fallback — JWT_SECRET must be configured.
if (!process.env.JWT_SECRET) {
@@ -59,6 +64,32 @@ export async function POST(request) {
return NextResponse.json({ error: "Invalid password payload" }, { status: 400 });
}
const settings = await getSettings();
const bruteForceEnabled = settings.bruteForceProtection !== false;
const clientIp = auditContext.ipAddress || null;
const guardCheck = checkLoginGuard(clientIp, { enabled: bruteForceEnabled });
if (!guardCheck.allowed) {
logAuditEvent({
action: "auth.login.locked",
actor: "anonymous",
target: "dashboard-auth",
resourceType: "auth_session",
status: "failed",
ipAddress: clientIp || undefined,
requestId: auditContext.requestId,
metadata: { retryAfterSeconds: guardCheck.retryAfterSeconds || 0 },
});
return NextResponse.json(
{ error: "Too many failed attempts. Try again later." },
{
status: 429,
headers: guardCheck.retryAfterSeconds
? { "Retry-After": String(guardCheck.retryAfterSeconds) }
: {},
}
);
}
const passwordState = await ensurePersistentManagementPasswordHash({
settings,
source: "auth.login",
@@ -119,9 +150,12 @@ export async function POST(request) {
},
});
clearLoginAttempts(clientIp);
return NextResponse.json({ success: true });
}
const failureDecision = recordLoginFailure(clientIp, { enabled: bruteForceEnabled });
logAuditEvent({
action: "auth.login.failed",
actor: "anonymous",
@@ -130,8 +164,21 @@ export async function POST(request) {
status: "failed",
ipAddress: auditContext.ipAddress || undefined,
requestId: auditContext.requestId,
metadata: { reason: "invalid_password" },
metadata: { reason: "invalid_password", lockedOut: failureDecision.allowed === false },
});
if (!failureDecision.allowed) {
return NextResponse.json(
{ error: "Too many failed attempts. Try again later." },
{
status: 429,
headers: failureDecision.retryAfterSeconds
? { "Retry-After": String(failureDecision.retryAfterSeconds) }
: {},
}
);
}
return NextResponse.json({ error: "Invalid password" }, { status: 401 });
} catch (error) {
console.error("[AUTH] Login failed:", error);

View File

@@ -1,12 +1,24 @@
import { randomUUID } from "node:crypto";
import { NextResponse } from "next/server";
import { buildComboTestRequestBody, extractComboTestResponseText } from "@/lib/combos/testHealth";
import { getComboByName, getCombos } from "@/lib/localDb";
import { getApiKeys, getComboByName, getCombos } from "@/lib/localDb";
import { resolveNestedComboTargets } from "@omniroute/open-sse/services/combo.ts";
import { testComboSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
async function getInternalApiKey(): Promise<string | null> {
try {
const keys = await getApiKeys();
const active = (
keys as Array<{ key: string; isActive?: boolean; revokedAt?: string | null }>
).find((k) => k.key && k.isActive !== false && !k.revokedAt);
return active?.key ?? null;
} catch {
return null;
}
}
function buildComboTestResult(target, partial = {}) {
return {
model: target.modelStr,
@@ -19,7 +31,7 @@ function buildComboTestResult(target, partial = {}) {
};
}
async function testComboTarget(target, baseInternalUrl) {
async function testComboTarget(target, baseInternalUrl, internalApiKey: string | null) {
const startTime = Date.now();
try {
const isEmbedding =
@@ -38,8 +50,7 @@ async function testComboTarget(target, baseInternalUrl) {
method: "POST",
headers: {
"Content-Type": "application/json",
// Internal dashboard tests still use the normal /v1 pipeline but
// bypass REQUIRE_API_KEY so admins can test with local session auth.
...(internalApiKey ? { Authorization: `Bearer ${internalApiKey}` } : {}),
"X-Internal-Test": "combo-health-check",
// Force a fresh execution path so combo tests cannot be satisfied by
// OmniRoute's semantic cache or other request reuse layers.
@@ -145,8 +156,9 @@ export async function POST(request) {
}
const baseInternalUrl = getBaseUrl(request);
const internalApiKey = await getInternalApiKey();
const results = await Promise.all(
targets.map((target) => testComboTarget(target, baseInternalUrl))
targets.map((target) => testComboTarget(target, baseInternalUrl, internalApiKey))
);
const resolvedResult = results.find((result) => result.status === "ok") || null;
const resolvedBy = resolvedResult?.model || null;

View File

@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
import { getApiKeyById } from "@/lib/localDb";
import { isApiKeyRevealEnabled } from "@/lib/apiKeyExposure";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import * as log from "@/sse/utils/logger";
// GET /api/keys/[id]/reveal - Reveal full API key for explicit copy actions
export async function GET(request, { params }) {
@@ -22,7 +23,7 @@ export async function GET(request, { params }) {
return NextResponse.json({ key: key.key });
} catch (error) {
console.log("Error revealing key:", error);
log.error("keys", "Error revealing key", error);
return NextResponse.json({ error: "Failed to reveal key" }, { status: 500 });
}
}

View File

@@ -10,6 +10,7 @@ import { syncToCloud } from "@/lib/cloudSync";
import { updateKeyPermissionsSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import * as log from "@/sse/utils/logger";
// GET /api/keys/[id] - Get single API key
export async function GET(request, { params }) {
@@ -31,7 +32,7 @@ export async function GET(request, { params }) {
key: keyValue ? keyValue.slice(0, 8) + "****" + keyValue.slice(-4) : null,
});
} catch (error) {
console.log("Error fetching key:", error);
log.error("keys", "Error fetching key", error);
return NextResponse.json({ error: "Failed to fetch key" }, { status: 500 });
}
}
@@ -103,7 +104,7 @@ export async function PATCH(request, { params }) {
...(accessSchedule !== undefined && { accessSchedule }),
});
} catch (error) {
console.log("Error updating key permissions:", error);
log.error("keys", "Error updating key permissions", error);
return NextResponse.json({ error: "Failed to update permissions" }, { status: 500 });
}
}
@@ -126,7 +127,7 @@ export async function DELETE(request, { params }) {
return NextResponse.json({ message: "Key deleted successfully" });
} catch (error) {
console.log("Error deleting key:", error);
log.error("keys", "Error deleting key", error);
return NextResponse.json({ error: "Failed to delete key" }, { status: 500 });
}
}
@@ -142,6 +143,6 @@ async function syncKeysToCloudIfEnabled() {
const machineId = await getConsistentMachineId();
await syncToCloud(machineId);
} catch (error) {
console.log("Error syncing keys to cloud:", error);
log.error("keys", "Error syncing keys to cloud", error);
}
}

View File

@@ -6,6 +6,7 @@ import { createKeySchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
import { isApiKeyRevealEnabled, maskStoredApiKey } from "@/lib/apiKeyExposure";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import * as log from "@/sse/utils/logger";
function parsePagination(request: Request) {
const url = new URL(request.url);
@@ -43,7 +44,7 @@ export async function GET(request: Request) {
allowKeyReveal: isApiKeyRevealEnabled(),
});
} catch (error) {
console.log("Error fetching keys:", error);
log.error("keys", "Error fetching keys", error);
return NextResponse.json({ error: "Failed to fetch keys" }, { status: 500 });
}
}
@@ -84,7 +85,7 @@ export async function POST(request) {
{ status: 201 }
);
} catch (error) {
console.log("Error creating key:", error);
log.error("keys", "Error creating key", error);
return NextResponse.json({ error: "Failed to create key" }, { status: 500 });
}
}
@@ -100,6 +101,6 @@ async function syncKeysToCloudIfEnabled() {
const machineId = await getConsistentMachineId();
await syncToCloud(machineId);
} catch (error) {
console.log("Error syncing keys to cloud:", error);
log.error("keys", "Error syncing keys to cloud", error);
}
}

View File

@@ -1,8 +1,5 @@
import { NextResponse } from "next/server";
import { getApiKeyMetadata } from "@/lib/localDb";
import { CORS_HEADERS } from "@/shared/utils/cors";
import { extractApiKey, isValidApiKey } from "@/sse/services/auth";
import { extractApiKey } from "@/sse/services/auth";
export interface ApiKeyRequestScope {
apiKey: string | null;
@@ -13,43 +10,8 @@ export interface ApiKeyRequestScope {
export async function getApiKeyRequestScope(request: Request): Promise<ApiKeyRequestScope> {
const apiKey = extractApiKey(request);
if (process.env.REQUIRE_API_KEY === "true") {
if (!apiKey) {
return {
apiKey: null,
apiKeyId: null,
apiKeyMetadata: null,
rejection: NextResponse.json(
{ error: { message: "Missing API key", type: "invalid_request_error" } },
{ status: 401, headers: CORS_HEADERS }
),
};
}
if (!(await isValidApiKey(apiKey))) {
return {
apiKey: null,
apiKeyId: null,
apiKeyMetadata: null,
rejection: NextResponse.json(
{ error: { message: "Invalid API key", type: "invalid_request_error" } },
{ status: 401, headers: CORS_HEADERS }
),
};
}
}
if (apiKey && !(await isValidApiKey(apiKey))) {
return {
apiKey: null,
apiKeyId: null,
apiKeyMetadata: null,
rejection: NextResponse.json(
{ error: { message: "Invalid API key", type: "invalid_request_error" } },
{ status: 401, headers: CORS_HEADERS }
),
};
if (!apiKey) {
return { apiKey: null, apiKeyId: null, apiKeyMetadata: null, rejection: null };
}
const apiKeyMetadata = await getApiKeyMetadata(apiKey);

View File

@@ -1,4 +1,3 @@
import { CORS_ORIGIN } from "@/shared/utils/cors";
import { handleChat } from "@/sse/handlers/chat";
import { initTranslators } from "@omniroute/open-sse/translator/index.ts";
import { transformToOllama } from "@omniroute/open-sse/utils/ollamaTransform.ts";
@@ -16,7 +15,6 @@ async function ensureInitialized() {
export async function OPTIONS() {
return new Response(null, {
headers: {
"Access-Control-Allow-Origin": CORS_ORIGIN,
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "*",
},

View File

@@ -1,11 +1,5 @@
import { CORS_ORIGIN } from "@/shared/utils/cors";
import { handleAudioSpeech } from "@omniroute/open-sse/handlers/audioSpeech.ts";
import {
getProviderCredentials,
clearRecoveredProviderState,
extractApiKey,
isValidApiKey,
} from "@/sse/services/auth";
import { getProviderCredentials, clearRecoveredProviderState } from "@/sse/services/auth";
import {
parseSpeechModel,
getSpeechProvider,
@@ -25,7 +19,6 @@ import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
export async function OPTIONS() {
return new Response(null, {
headers: {
"Access-Control-Allow-Origin": CORS_ORIGIN,
"Access-Control-Allow-Methods": "POST, OPTIONS",
"Access-Control-Allow-Headers": "*",
},
@@ -37,13 +30,6 @@ export async function OPTIONS() {
* OpenAI TTS API compatible. Returns audio stream.
*/
export async function POST(request) {
if (process.env.REQUIRE_API_KEY === "true") {
const apiKey = extractApiKey(request);
if (!apiKey) return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Missing API key");
const valid = await isValidApiKey(apiKey);
if (!valid) return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Invalid API key");
}
let rawBody;
try {
rawBody = await request.json();

View File

@@ -1,14 +1,7 @@
import { CORS_ORIGIN } from "@/shared/utils/cors";
// Allow large audio/video file uploads — 5min for processing large files (up to 2GB)
export const maxDuration = 300;
import { handleAudioTranscription } from "@omniroute/open-sse/handlers/audioTranscription.ts";
import {
getProviderCredentials,
clearRecoveredProviderState,
extractApiKey,
isValidApiKey,
} from "@/sse/services/auth";
import { getProviderCredentials, clearRecoveredProviderState } from "@/sse/services/auth";
import {
parseTranscriptionModel,
getTranscriptionProvider,
@@ -26,7 +19,6 @@ import { getProviderNodes } from "@/lib/localDb";
export async function OPTIONS() {
return new Response(null, {
headers: {
"Access-Control-Allow-Origin": CORS_ORIGIN,
"Access-Control-Allow-Methods": "POST, OPTIONS",
"Access-Control-Allow-Headers": "*",
},
@@ -38,14 +30,6 @@ export async function OPTIONS() {
* OpenAI Whisper API compatible (multipart/form-data)
*/
export async function POST(request) {
// Optional API key validation
if (process.env.REQUIRE_API_KEY === "true") {
const apiKey = extractApiKey(request);
if (!apiKey) return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Missing API key");
const valid = await isValidApiKey(apiKey);
if (!valid) return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Invalid API key");
}
let formData;
try {
formData = await request.formData();

View File

@@ -1,4 +1,4 @@
import { CORS_ORIGIN, CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors";
import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors";
import { callCloudWithMachineId } from "@/shared/utils/cloud";
import { handleChat } from "@/sse/handlers/chat";
import { initTranslators } from "@omniroute/open-sse/translator/index.ts";
@@ -38,9 +38,7 @@ export async function POST(request) {
const ct = request.headers.get("content-type") ?? "";
const cl = request.headers.get("content-length");
if (cl && Number(cl) > 256 * 1024) {
console.error(
`[CHAT-ROUTE] large body content-type="${ct}" content-length=${cl}`
);
console.error(`[CHAT-ROUTE] large body content-type="${ct}" content-length=${cl}`);
}
}

View File

@@ -1,4 +1,4 @@
import { CORS_ORIGIN, CORS_HEADERS } from "@/shared/utils/cors";
import { CORS_HEADERS } from "@/shared/utils/cors";
import { buildClientRawRequest, handleChat } from "@/sse/handlers/chat";
import { initTranslators } from "@omniroute/open-sse/translator/index.ts";
import { createInjectionGuard } from "@/middleware/promptInjectionGuard";
@@ -21,7 +21,6 @@ function ensureInitialized() {
export async function OPTIONS() {
return new Response(null, {
headers: {
"Access-Control-Allow-Origin": CORS_ORIGIN,
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "*",
},

View File

@@ -1,4 +1,3 @@
import { CORS_ORIGIN } from "@/shared/utils/cors";
import { handleEmbedding } from "@omniroute/open-sse/handlers/embeddings.ts";
import {
getProviderCredentials,
@@ -30,7 +29,6 @@ import { getAllCustomModels, getProviderNodes } from "@/lib/localDb";
export async function OPTIONS() {
return new Response(null, {
headers: {
"Access-Control-Allow-Origin": CORS_ORIGIN,
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "*",
},
@@ -232,18 +230,6 @@ export async function POST(request) {
}
const body = validation.data;
// Optional API key validation
if (process.env.REQUIRE_API_KEY === "true") {
const apiKey = extractApiKey(request);
if (!apiKey) {
return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Missing API key");
}
const valid = await isValidApiKey(apiKey);
if (!valid) {
return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Invalid API key");
}
}
// Enforce API key policies (model restrictions + budget limits)
const policy = await enforceApiKeyPolicy(request, body.model);
if (policy.rejection) return policy.rejection;

View File

@@ -1,4 +1,3 @@
import { CORS_ORIGIN } from "@/shared/utils/cors";
import { handleImageEdit } from "@omniroute/open-sse/handlers/imageGeneration.ts";
import {
getProviderCredentials,
@@ -31,7 +30,6 @@ import { enforceApiKeyPolicy } from "@/shared/utils/apiKeyPolicy";
export async function OPTIONS() {
return new Response(null, {
headers: {
"Access-Control-Allow-Origin": CORS_ORIGIN,
"Access-Control-Allow-Methods": "POST, OPTIONS",
"Access-Control-Allow-Headers": "*",
},
@@ -84,7 +82,10 @@ export async function POST(request: Request) {
try {
formData = await request.formData();
} catch (err) {
log.warn("IMAGE", `Invalid multipart body: ${err instanceof Error ? err.message : String(err)}`);
log.warn(
"IMAGE",
`Invalid multipart body: ${err instanceof Error ? err.message : String(err)}`
);
return errorResponse(HTTP_STATUS.BAD_REQUEST, "Invalid multipart body");
}
@@ -127,7 +128,10 @@ export async function POST(request: Request) {
const credentials = await getProviderCredentials(parsed.provider, apiKey);
if (!credentials) {
return errorResponse(HTTP_STATUS.UNAUTHORIZED, `No credentials for provider: ${parsed.provider}`);
return errorResponse(
HTTP_STATUS.UNAUTHORIZED,
`No credentials for provider: ${parsed.provider}`
);
}
if (credentials.allRateLimited) {
return unavailableResponse(
@@ -163,10 +167,7 @@ export async function POST(request: Request) {
});
}
const errorPayload = toJsonErrorPayload(
(result as any).error,
"Image edit provider error"
);
const errorPayload = toJsonErrorPayload((result as any).error, "Image edit provider error");
return new Response(JSON.stringify(errorPayload), {
status: (result as any).status,
headers: { "Content-Type": "application/json" },

View File

@@ -1,4 +1,3 @@
import { CORS_ORIGIN } from "@/shared/utils/cors";
import { handleImageGeneration } from "@omniroute/open-sse/handlers/imageGeneration.ts";
import {
getProviderCredentials,
@@ -28,7 +27,6 @@ import { getAllCustomModels } from "@/lib/localDb";
export async function OPTIONS() {
return new Response(null, {
headers: {
"Access-Control-Allow-Origin": CORS_ORIGIN,
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "*",
},
@@ -133,18 +131,6 @@ export async function POST(request) {
}
const body = validation.data;
// Optional API key validation
if (process.env.REQUIRE_API_KEY === "true") {
const apiKey = extractApiKey(request);
if (!apiKey) {
return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Missing API key");
}
const valid = await isValidApiKey(apiKey);
if (!valid) {
return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Invalid API key");
}
}
// Enforce API key policies (model restrictions + budget limits)
const policy = await enforceApiKeyPolicy(request, body.model);
if (policy.rejection) return policy.rejection;

View File

@@ -38,23 +38,6 @@ export async function POST(request) {
}
const body = validation.data;
if (process.env.REQUIRE_API_KEY === "true") {
const apiKey = extractApiKey(request);
if (!apiKey) {
return new Response(JSON.stringify({ error: "Missing API key" }), {
status: 401,
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
});
}
const valid = await isValidApiKey(apiKey);
if (!valid) {
return new Response(JSON.stringify({ error: "Invalid API key" }), {
status: 401,
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
});
}
}
const estimated = buildEstimatedCountResponse(body);
const requestedModel = typeof body.model === "string" ? body.model : "";
if (!requestedModel) {

View File

@@ -1,4 +1,3 @@
import { CORS_ORIGIN } from "@/shared/utils/cors";
import { handleChat } from "@/sse/handlers/chat";
import { initTranslators } from "@omniroute/open-sse/translator/index.ts";
@@ -21,7 +20,6 @@ async function ensureInitialized() {
export async function OPTIONS() {
return new Response(null, {
headers: {
"Access-Control-Allow-Origin": CORS_ORIGIN,
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "*",
},

View File

@@ -1,4 +1,3 @@
import { CORS_ORIGIN } from "@/shared/utils/cors";
import { PROVIDER_MODELS, PROVIDER_ID_TO_ALIAS } from "@/shared/constants/models";
import { AI_PROVIDERS } from "@/shared/constants/providers";
import {
@@ -9,7 +8,6 @@ import {
getProviderNodes,
getModelIsHidden,
} from "@/lib/localDb";
import { isAuthenticated } from "@/shared/utils/apiAuth";
import { getAllEmbeddingModels } from "@omniroute/open-sse/config/embeddingRegistry.ts";
import { getAllImageModels } from "@omniroute/open-sse/config/imageRegistry.ts";
import { getAllRerankModels } from "@omniroute/open-sse/config/rerankRegistry.ts";
@@ -142,39 +140,14 @@ function buildAliasMaps() {
*/
export async function getUnifiedModelsResponse(
request: Request,
corsHeaders: Record<string, string> = {
"Access-Control-Allow-Origin": CORS_ORIGIN,
}
corsHeaders: Record<string, string> = {}
) {
const diagnosticHeaders = getCatalogDiagnosticsHeaders({ request });
try {
// Issue #100: Optionally require authentication for /models (security hardening)
// When enabled, unauthenticated requests get 401 with proper error response.
// Supports API key (Bearer token) for external clients and JWT cookie for dashboard.
let settings: Record<string, any> = {};
try {
settings = await getSettings();
} catch {}
if (settings.requireAuthForModels === true) {
if (!(await isAuthenticated(request))) {
return Response.json(
{
error: {
message: "Authentication required",
type: "invalid_request_error",
code: "invalid_api_key",
},
},
{
status: 401,
headers: {
...corsHeaders,
...diagnosticHeaders,
},
}
);
}
}
const { aliasToProviderId, providerIdToAlias } = buildAliasMaps();

View File

@@ -1,4 +1,3 @@
import { CORS_ORIGIN } from "@/shared/utils/cors";
import { getUnifiedModelsResponse } from "./catalog";
/**
@@ -7,7 +6,6 @@ import { getUnifiedModelsResponse } from "./catalog";
export async function OPTIONS() {
return new Response(null, {
headers: {
"Access-Control-Allow-Origin": CORS_ORIGIN,
"Access-Control-Allow-Methods": "GET, OPTIONS",
"Access-Control-Allow-Headers": "*",
},

View File

@@ -1,11 +1,5 @@
import { CORS_ORIGIN } from "@/shared/utils/cors";
import { handleModeration } from "@omniroute/open-sse/handlers/moderations.ts";
import {
getProviderCredentials,
clearRecoveredProviderState,
extractApiKey,
isValidApiKey,
} from "@/sse/services/auth";
import { getProviderCredentials, clearRecoveredProviderState } from "@/sse/services/auth";
import { parseModerationModel } from "@omniroute/open-sse/config/moderationRegistry.ts";
import { errorResponse } from "@omniroute/open-sse/utils/error.ts";
import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts";
@@ -19,7 +13,6 @@ import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
export async function OPTIONS() {
return new Response(null, {
headers: {
"Access-Control-Allow-Origin": CORS_ORIGIN,
"Access-Control-Allow-Methods": "POST, OPTIONS",
"Access-Control-Allow-Headers": "*",
},
@@ -31,13 +24,6 @@ export async function OPTIONS() {
* OpenAI Moderations API compatible.
*/
export async function POST(request) {
if (process.env.REQUIRE_API_KEY === "true") {
const apiKey = extractApiKey(request);
if (!apiKey) return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Missing API key");
const valid = await isValidApiKey(apiKey);
if (!valid) return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Invalid API key");
}
let rawBody;
try {
rawBody = await request.json();

View File

@@ -1,4 +1,3 @@
import { CORS_ORIGIN } from "@/shared/utils/cors";
import { handleMusicGeneration } from "@omniroute/open-sse/handlers/musicGeneration.ts";
import {
getProviderCredentials,
@@ -25,7 +24,6 @@ import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
export async function OPTIONS() {
return new Response(null, {
headers: {
"Access-Control-Allow-Origin": CORS_ORIGIN,
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "*",
},
@@ -76,18 +74,6 @@ export async function POST(request) {
return errorResponse(HTTP_STATUS.BAD_REQUEST, "Prompt is required");
}
// Optional API key validation
if (process.env.REQUIRE_API_KEY === "true") {
const apiKey = extractApiKey(request);
if (!apiKey) {
return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Missing API key");
}
const valid = await isValidApiKey(apiKey);
if (!valid) {
return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Invalid API key");
}
}
// Enforce API key policies (model restrictions + budget limits)
const policy = await enforceApiKeyPolicy(request, body.model);
if (policy.rejection) return policy.rejection;

View File

@@ -1,4 +1,3 @@
import { CORS_ORIGIN } from "@/shared/utils/cors";
import { buildClientRawRequest, handleChat } from "@/sse/handlers/chat";
import { initTranslators } from "@omniroute/open-sse/translator/index.ts";
import { errorResponse } from "@omniroute/open-sse/utils/error.ts";
@@ -22,7 +21,6 @@ async function ensureInitialized() {
export async function OPTIONS() {
return new Response(null, {
headers: {
"Access-Control-Allow-Origin": CORS_ORIGIN,
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "*",
},

View File

@@ -1,4 +1,3 @@
import { CORS_ORIGIN } from "@/shared/utils/cors";
import { errorResponse, unavailableResponse } from "@omniroute/open-sse/utils/error.ts";
import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts";
import { getRegistryEntry } from "@omniroute/open-sse/config/providerRegistry.ts";
@@ -20,7 +19,6 @@ import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
export async function OPTIONS() {
return new Response(null, {
headers: {
"Access-Control-Allow-Origin": CORS_ORIGIN,
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "*",
},
@@ -53,14 +51,6 @@ export async function POST(request, { params }) {
}
const body = validation.data;
// Optional API key validation
if (process.env.REQUIRE_API_KEY === "true") {
const apiKey = extractApiKey(request);
if (!apiKey || !(await isValidApiKey(apiKey))) {
return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Invalid API key");
}
}
// Add provider prefix if missing
if (body.model && !body.model.includes("/")) {
body.model = `${providerAlias}/${body.model}`;

View File

@@ -1,4 +1,3 @@
import { CORS_ORIGIN } from "@/shared/utils/cors";
import { handleImageGeneration } from "@omniroute/open-sse/handlers/imageGeneration.ts";
import { errorResponse, unavailableResponse } from "@omniroute/open-sse/utils/error.ts";
import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts";
@@ -21,7 +20,6 @@ import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
export async function OPTIONS() {
return new Response(null, {
headers: {
"Access-Control-Allow-Origin": CORS_ORIGIN,
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "*",
},
@@ -52,14 +50,6 @@ export async function POST(request, { params }) {
}
const body = validation.data;
// Optional API key validation
if (process.env.REQUIRE_API_KEY === "true") {
const apiKey = extractApiKey(request);
if (!apiKey || !(await isValidApiKey(apiKey))) {
return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Invalid API key");
}
}
// Ensure model has provider prefix
if (!body.model.includes("/")) {
body.model = `${rawProvider}/${body.model}`;

View File

@@ -1,11 +1,5 @@
import { CORS_ORIGIN } from "@/shared/utils/cors";
import { handleRerank } from "@omniroute/open-sse/handlers/rerank.ts";
import {
getProviderCredentials,
clearRecoveredProviderState,
extractApiKey,
isValidApiKey,
} from "@/sse/services/auth";
import { getProviderCredentials, clearRecoveredProviderState } from "@/sse/services/auth";
import { parseRerankModel, getRerankProvider } from "@omniroute/open-sse/config/rerankRegistry.ts";
import { errorResponse } from "@omniroute/open-sse/utils/error.ts";
import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts";
@@ -20,7 +14,6 @@ import { getProviderNodes } from "@/lib/localDb";
export async function OPTIONS() {
return new Response(null, {
headers: {
"Access-Control-Allow-Origin": CORS_ORIGIN,
"Access-Control-Allow-Methods": "POST, OPTIONS",
"Access-Control-Allow-Headers": "*",
},
@@ -52,14 +45,6 @@ function buildDynamicRerankProvider(node: any) {
* and local provider_nodes (oMLX, vLLM, etc.) via dynamic routing.
*/
export async function POST(request) {
// Optional API key validation
if (process.env.REQUIRE_API_KEY === "true") {
const apiKey = extractApiKey(request);
if (!apiKey) return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Missing API key");
const valid = await isValidApiKey(apiKey);
if (!valid) return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Invalid API key");
}
let rawBody;
try {
rawBody = await request.json();
@@ -174,7 +159,7 @@ export async function POST(request) {
const data = await res.json();
return Response.json(data, {
headers: { "Access-Control-Allow-Origin": CORS_ORIGIN },
headers: {},
});
} catch (err: any) {
return errorResponse(500, `Rerank request failed: ${err.message}`);

View File

@@ -1,4 +1,3 @@
import { CORS_ORIGIN } from "@/shared/utils/cors";
import { handleChat } from "@/sse/handlers/chat";
import { initTranslators } from "@omniroute/open-sse/translator/index.ts";
@@ -15,7 +14,6 @@ async function ensureInitialized() {
export async function OPTIONS() {
return new Response(null, {
headers: {
"Access-Control-Allow-Origin": CORS_ORIGIN,
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "*",
},

View File

@@ -1,4 +1,3 @@
import { CORS_ORIGIN } from "@/shared/utils/cors";
import { handleChat } from "@/sse/handlers/chat";
// NOTE: We do NOT call initTranslators() here — the translator registry is
@@ -13,7 +12,6 @@ import { handleChat } from "@/sse/handlers/chat";
export async function OPTIONS() {
return new Response(null, {
headers: {
"Access-Control-Allow-Origin": CORS_ORIGIN,
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "*",
},

View File

@@ -1,4 +1,3 @@
import { CORS_ORIGIN } from "@/shared/utils/cors";
import { handleSearch } from "@omniroute/open-sse/handlers/search.ts";
import { getProviderCredentials, extractApiKey, isValidApiKey } from "@/sse/services/auth";
import {
@@ -24,7 +23,6 @@ import {
} from "@omniroute/open-sse/services/searchCache.ts";
const CORS_HEADERS = {
"Access-Control-Allow-Origin": CORS_ORIGIN,
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "*",
};
@@ -104,18 +102,6 @@ export async function POST(request: Request) {
}
const body = validation.data;
// Optional API key validation
if (process.env.REQUIRE_API_KEY === "true") {
const apiKey = extractApiKey(request);
if (!apiKey) {
return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Missing API key");
}
const valid = await isValidApiKey(apiKey);
if (!valid) {
return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Invalid API key");
}
}
// Enforce API key policies — use "search" as model identifier for consistent policy config
const policy = await enforceApiKeyPolicy(request, "search");
if (policy.rejection) return policy.rejection;

View File

@@ -1,4 +1,3 @@
import { CORS_ORIGIN } from "@/shared/utils/cors";
import { handleVideoGeneration } from "@omniroute/open-sse/handlers/videoGeneration.ts";
import {
getProviderCredentials,
@@ -25,7 +24,6 @@ import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
export async function OPTIONS() {
return new Response(null, {
headers: {
"Access-Control-Allow-Origin": CORS_ORIGIN,
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "*",
},
@@ -76,18 +74,6 @@ export async function POST(request) {
return errorResponse(HTTP_STATUS.BAD_REQUEST, "Prompt is required");
}
// Optional API key validation
if (process.env.REQUIRE_API_KEY === "true") {
const apiKey = extractApiKey(request);
if (!apiKey) {
return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Missing API key");
}
const valid = await isValidApiKey(apiKey);
if (!valid) {
return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Invalid API key");
}
}
// Enforce API key policies (model restrictions + budget limits)
const policy = await enforceApiKeyPolicy(request, body.model);
if (policy.rejection) return policy.rejection;

View File

@@ -1,4 +1,3 @@
import { CORS_ORIGIN } from "@/shared/utils/cors";
import { buildClientRawRequest, handleChat } from "@/sse/handlers/chat";
import { initTranslators } from "@omniroute/open-sse/translator/index.ts";
import { v1betaGeminiGenerateSchema } from "@/shared/validation/schemas";
@@ -23,7 +22,6 @@ async function ensureInitialized() {
export async function OPTIONS() {
return new Response(null, {
headers: {
"Access-Control-Allow-Origin": CORS_ORIGIN,
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "*",
},

View File

@@ -1,4 +1,3 @@
import { CORS_ORIGIN } from "@/shared/utils/cors";
import { PROVIDER_MODELS } from "@/shared/constants/models";
import { getAllCustomModels, getSyncedAvailableModels } from "@/lib/db/models";
import { getResolvedModelCapabilities } from "@/lib/modelCapabilities";
@@ -10,7 +9,6 @@ import { getSyncedCapabilities } from "@/lib/modelsDevSync";
export async function OPTIONS() {
return new Response(null, {
headers: {
"Access-Control-Allow-Origin": CORS_ORIGIN,
"Access-Control-Allow-Methods": "GET, OPTIONS",
"Access-Control-Allow-Headers": "*",
},

View File

@@ -3058,6 +3058,14 @@
"apiEndpointProtection": "API Endpoint Protection",
"requireAuthModels": "Require API key for /models",
"requireAuthModelsDesc": "When ON, the /v1/models endpoint returns 404 for unauthenticated requests. Prevents model discovery by unauthorized users.",
"authModelHeading": "Active authorization model",
"authModelClient": "Client API endpoints (/v1/*, /chat/*, /responses/*, /codex/*, /messages/*) require a Bearer API key.",
"authModelManagement": "Management endpoints (/dashboard, /api/*) require a dashboard session or management credential.",
"authModelPublic": "Only login, health, and bootstrap routes are public.",
"bruteForceProtection": "Login brute-force protection",
"bruteForceProtectionDesc": "Throttle and lock /api/auth/login after repeated failures from the same IP.",
"corsAllowedOrigins": "CORS allowed origins",
"corsAllowedOriginsDesc": "Comma-separated list of browser origins permitted to call this server. Empty list = no browser CORS access (server-to-server still works). Use CORS_ALLOW_ALL=true env var only for development.",
"blockedProviders": "Blocked Providers",
"blockedProvidersDesc": "Hide specific providers from the /v1/models response. Blocked providers will not appear in model listings.",
"providersBlocked": "{count} provider(s) blocked from /models",

View File

@@ -11,7 +11,8 @@ export type RuntimeReloadSection =
| "usageTracking"
| "healthCheckLogs"
| "thoughtSignature"
| "modelsDevSync";
| "modelsDevSync"
| "corsOrigins";
export interface RuntimeReloadChange {
section: RuntimeReloadSection;
@@ -29,6 +30,7 @@ interface RuntimeSettingsSnapshot {
hideHealthCheckLogs: boolean;
modelsDevSyncEnabled: boolean;
modelsDevSyncInterval: number | null;
corsOrigins: string;
}
const DEFAULT_RUNTIME_SETTINGS_SNAPSHOT: RuntimeSettingsSnapshot = {
@@ -42,6 +44,7 @@ const DEFAULT_RUNTIME_SETTINGS_SNAPSHOT: RuntimeSettingsSnapshot = {
hideHealthCheckLogs: false,
modelsDevSyncEnabled: false,
modelsDevSyncInterval: null,
corsOrigins: "",
};
let lastAppliedSnapshot: RuntimeSettingsSnapshot | null = null;
@@ -176,6 +179,7 @@ export function buildRuntimeSettingsSnapshot(
hideHealthCheckLogs: settings.hideHealthCheckLogs === true,
modelsDevSyncEnabled: settings.modelsDevSyncEnabled === true,
modelsDevSyncInterval: normalizeNumber(settings.modelsDevSyncInterval),
corsOrigins: typeof settings.corsOrigins === "string" ? settings.corsOrigins : "",
};
}
@@ -248,6 +252,11 @@ async function applyThoughtSignatureSection(mode: string) {
setGeminiThoughtSignatureMode(mode);
}
async function applyCorsOriginsSection(corsOrigins: string) {
const { setRuntimeAllowedOrigins } = await import("@/server/cors/origins");
setRuntimeAllowedOrigins(corsOrigins);
}
async function applyModelsDevSyncSection(
previousSnapshot: RuntimeSettingsSnapshot,
currentSnapshot: RuntimeSettingsSnapshot,
@@ -378,6 +387,11 @@ export async function applyRuntimeSettings(
markChanged("modelsDevSync");
}
if (force || hasChanged(currentSnapshot.corsOrigins, previousSnapshot.corsOrigins)) {
await applyCorsOriginsSection(currentSnapshot.corsOrigins);
markChanged("corsOrigins");
}
lastAppliedSnapshot = currentSnapshot;
return changes;
}

View File

@@ -42,6 +42,11 @@ interface ApiKeyMetadata {
maxRequestsPerMinute: number | null;
// T08: Per-key max concurrent sticky sessions (0 = unlimited)
maxSessions: number;
// Phase 3 lifecycle/policy fields
revokedAt: string | null;
expiresAt: string | null;
ipAllowlist: string[];
scopes: string[];
}
interface ApiKeyRow extends JsonRecord {
@@ -97,7 +102,9 @@ interface ApiKeyView extends JsonRecord {
// LRU cache for API key validation (valid keys only)
const _keyValidationCache = new Map<string, { valid: boolean; timestamp: number }>();
const _keyMetadataCache = new Map<string, CacheEntry<ApiKeyMetadata>>();
const _lastUsedUpdateCache = new Map<string, number>();
const CACHE_TTL = 60 * 1000; // 1 minute TTL
const LAST_USED_UPDATE_TTL = 5 * 60 * 1000;
const MAX_CACHE_SIZE = 1000;
// Compiled regex cache for wildcard patterns
@@ -121,12 +128,31 @@ function invalidateCaches() {
_keyValidationCache.clear();
_keyMetadataCache.clear();
_modelPermissionCache.clear();
_lastUsedUpdateCache.clear();
}
function toRecord(value: unknown): JsonRecord {
return value && typeof value === "object" ? (value as JsonRecord) : {};
}
function isConfiguredEnvApiKey(key: string): boolean {
const envKey = process.env.OMNIROUTE_API_KEY || process.env.ROUTER_API_KEY;
return Boolean(envKey && key === envKey);
}
function markApiKeyUsed(db: ApiKeysDbLike, id: unknown, now: number): void {
if (typeof id !== "string" || id.trim() === "") return;
const lastUpdate = _lastUsedUpdateCache.get(id);
if (lastUpdate && now - lastUpdate < LAST_USED_UPDATE_TTL) return;
db.prepare("UPDATE api_keys SET last_used_at = @lastUsedAt WHERE id = @id").run({
id,
lastUsedAt: new Date(now).toISOString(),
});
_lastUsedUpdateCache.set(id, now);
}
/**
* LRU eviction for cache
*/
@@ -204,6 +230,32 @@ function ensureApiKeysColumns(db: ApiKeysDbLike) {
db.exec("ALTER TABLE api_keys ADD COLUMN max_sessions INTEGER NOT NULL DEFAULT 0");
console.log("[DB] Added api_keys.max_sessions column");
}
// Phase 3: lifecycle / policy columns. Idempotent fallback for DBs whose
// migration 032_apikey_lifecycle.sql did not run yet.
if (!columnNames.has("revoked_at")) {
db.exec("ALTER TABLE api_keys ADD COLUMN revoked_at TEXT");
console.log("[DB] Added api_keys.revoked_at column");
}
if (!columnNames.has("expires_at")) {
db.exec("ALTER TABLE api_keys ADD COLUMN expires_at TEXT");
console.log("[DB] Added api_keys.expires_at column");
}
if (!columnNames.has("last_used_at")) {
db.exec("ALTER TABLE api_keys ADD COLUMN last_used_at TEXT");
console.log("[DB] Added api_keys.last_used_at column");
}
if (!columnNames.has("key_prefix")) {
db.exec("ALTER TABLE api_keys ADD COLUMN key_prefix TEXT");
console.log("[DB] Added api_keys.key_prefix column");
}
if (!columnNames.has("ip_allowlist")) {
db.exec("ALTER TABLE api_keys ADD COLUMN ip_allowlist TEXT");
console.log("[DB] Added api_keys.ip_allowlist column");
}
if (!columnNames.has("scopes")) {
db.exec("ALTER TABLE api_keys ADD COLUMN scopes TEXT");
console.log("[DB] Added api_keys.scopes column");
}
_schemaChecked = true;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
@@ -227,12 +279,14 @@ function getPreparedStatements(db: ApiKeysDbLike): ApiKeysStatements {
) {
_stmtGetAllKeys = db.prepare<ApiKeyRow>("SELECT * FROM api_keys ORDER BY created_at");
_stmtGetKeyById = db.prepare<ApiKeyRow>("SELECT * FROM api_keys WHERE id = ?");
_stmtValidateKey = db.prepare<JsonRecord>("SELECT 1 FROM api_keys WHERE key = ?");
_stmtValidateKey = db.prepare<JsonRecord>(
"SELECT id, expires_at, revoked_at, is_active FROM api_keys WHERE key = ?"
);
_stmtGetKeyMetadata = db.prepare<ApiKeyRow>(
"SELECT id, name, machine_id, allowed_models, allowed_connections, no_log, auto_resolve, is_active, access_schedule, max_requests_per_day, max_requests_per_minute, max_sessions FROM api_keys WHERE key = ?"
"SELECT id, name, machine_id, allowed_models, allowed_connections, no_log, auto_resolve, is_active, access_schedule, max_requests_per_day, max_requests_per_minute, max_sessions, revoked_at, expires_at, ip_allowlist, scopes FROM api_keys WHERE key = ?"
);
_stmtInsertKey = db.prepare(
"INSERT INTO api_keys (id, name, key, machine_id, allowed_models, no_log, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)"
"INSERT INTO api_keys (id, name, key, machine_id, allowed_models, no_log, created_at, key_prefix) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"
);
_stmtDeleteKey = db.prepare("DELETE FROM api_keys WHERE id = ?");
}
@@ -373,6 +427,24 @@ function parseAllowedConnections(value: unknown): string[] {
}
}
function parseStringList(value: unknown): string[] {
if (!value || typeof value !== "string" || value.trim() === "") return [];
try {
const parsed = JSON.parse(value);
return Array.isArray(parsed)
? parsed.filter((entry): entry is string => typeof entry === "string")
: [];
} catch {
return [];
}
}
function parseNullableTimestamp(value: unknown): string | null {
if (typeof value !== "string") return null;
const trimmed = value.trim();
return trimmed === "" ? null : trimmed;
}
export async function createApiKey(name: string, machineId: string) {
if (!machineId) {
throw new Error("machineId is required");
@@ -403,7 +475,8 @@ export async function createApiKey(name: string, machineId: string) {
apiKey.machineId,
"[]",
0,
apiKey.createdAt
apiKey.createdAt,
apiKey.key.slice(0, 12)
);
setNoLog(apiKey.id, false);
@@ -566,15 +639,66 @@ export async function deleteApiKey(id: string) {
}
/**
* Validate API key with caching for performance
* Cached valid keys reduce DB hits on every request
* Revoke an API key by id. Logical, not destructive: the row stays so it can
* be audited, but validateApiKey() rejects it immediately after caches expire
* (or sooner because invalidateCaches() runs here).
*/
export async function revokeApiKey(id: string): Promise<boolean> {
const db = getDbInstance() as ApiKeysDbLike;
getPreparedStatements(db);
const result = db
.prepare(
"UPDATE api_keys SET revoked_at = COALESCE(revoked_at, @ts), is_active = 0 WHERE id = @id"
)
.run({ id, ts: new Date().toISOString() });
if ((result.changes ?? 0) === 0) return false;
invalidateCaches();
backupDbFile("pre-write");
return true;
}
/**
* Set or clear the expiry of an API key. Pass null to remove the expiry.
*/
export async function setApiKeyExpiry(id: string, expiresAt: string | null): Promise<boolean> {
const db = getDbInstance() as ApiKeysDbLike;
getPreparedStatements(db);
const result = db
.prepare("UPDATE api_keys SET expires_at = @expiresAt WHERE id = @id")
.run({ id, expiresAt });
if ((result.changes ?? 0) === 0) return false;
invalidateCaches();
backupDbFile("pre-write");
return true;
}
/**
* Validate API key with lifecycle gates and caching.
*
* A key is valid only when ALL of the following are true:
* - the row exists,
* - is_active = 1,
* - revoked_at IS NULL,
* - expires_at IS NULL OR expires_at > now.
*
* Cache TTL is short (CACHE_TTL) and the metadata cache is also invalidated
* by revokeApiKey/updateApiKeyPermissions/deleteApiKey, so a revoke takes
* effect within at most CACHE_TTL even without an explicit clear in the
* caller.
*/
export async function validateApiKey(key: string | null | undefined) {
if (!key || typeof key !== "string") return false;
if (isConfiguredEnvApiKey(key)) return true;
const now = Date.now();
// Check cache first
const cached = _keyValidationCache.get(key);
if (cached && now - cached.timestamp < CACHE_TTL) {
return cached.valid;
@@ -582,16 +706,27 @@ export async function validateApiKey(key: string | null | undefined) {
const db = getDbInstance() as ApiKeysDbLike;
const stmt = getPreparedStatements(db);
const row = stmt.validateKey.get(key);
const valid = !!row;
const row = stmt.validateKey.get(key) as JsonRecord | undefined;
// Only cache valid keys to prevent cache pollution
if (valid) {
evictIfNeeded(_keyValidationCache);
_keyValidationCache.set(key, { valid: true, timestamp: now });
if (!row) return false;
const isActive = parseIsActive(row.is_active ?? row.isActive);
if (!isActive) return false;
const revokedAt = row.revoked_at ?? row.revokedAt;
if (typeof revokedAt === "string" && revokedAt.trim() !== "") return false;
const expiresAt = row.expires_at ?? row.expiresAt;
if (typeof expiresAt === "string" && expiresAt.trim() !== "") {
const expiresMs = Date.parse(expiresAt);
if (Number.isFinite(expiresMs) && expiresMs <= now) return false;
}
return valid;
evictIfNeeded(_keyValidationCache);
_keyValidationCache.set(key, { valid: true, timestamp: now });
markApiKeyUsed(db, row.id, now);
return true;
}
/**
@@ -605,8 +740,7 @@ export async function getApiKeyMetadata(
const now = Date.now();
// persistent env-var key support (persistent passthrough keys) (#1350)
const envKey = process.env.OMNIROUTE_API_KEY || process.env.ROUTER_API_KEY;
if (envKey && key === envKey) {
if (isConfiguredEnvApiKey(key)) {
return {
id: "env-key",
name: "Environment Key",
@@ -620,6 +754,10 @@ export async function getApiKeyMetadata(
maxRequestsPerDay: null,
maxRequestsPerMinute: null,
maxSessions: 0,
revokedAt: null,
expiresAt: null,
ipAllowlist: [],
scopes: [],
};
}
@@ -662,6 +800,10 @@ export async function getApiKeyMetadata(
maxRequestsPerMinute: typeof rawMaxRPM === "number" && rawMaxRPM > 0 ? rawMaxRPM : null,
// T08: max concurrent sessions; 0 = unlimited (default & backward-compatible)
maxSessions: typeof rawMaxSessions === "number" && rawMaxSessions > 0 ? rawMaxSessions : 0,
revokedAt: parseNullableTimestamp(record.revoked_at ?? (record as JsonRecord).revokedAt),
expiresAt: parseNullableTimestamp(record.expires_at ?? (record as JsonRecord).expiresAt),
ipAllowlist: parseStringList(record.ip_allowlist ?? (record as JsonRecord).ipAllowlist),
scopes: parseStringList((record as JsonRecord).scopes),
};
if (!metadata.id) {
@@ -766,6 +908,7 @@ function clearPreparedStatementCache() {
*/
export function clearApiKeyCaches() {
invalidateCaches();
_lastUsedUpdateCache.clear();
_modelPermissionCache.clear();
_regexCache.clear();
}

View File

@@ -156,6 +156,40 @@ function hasTable(db: Database.Database, tableName: string): boolean {
return Boolean(row?.name);
}
function hasColumn(db: Database.Database, tableName: string, columnName: string): boolean {
const columns = db.prepare(`PRAGMA table_info(${tableName})`).all() as Array<{ name?: string }>;
return columns.some((column) => column.name === columnName);
}
function ensureColumn(
db: Database.Database,
tableName: string,
columnName: string,
ddl: string
): void {
if (!hasColumn(db, tableName, columnName)) {
db.exec(ddl);
}
}
function isApiKeyLifecycleMigration(migration: { version: string; name: string }): boolean {
return migration.version === "032";
}
function applyApiKeyLifecycleMigration(db: Database.Database): void {
ensureColumn(db, "api_keys", "revoked_at", "ALTER TABLE api_keys ADD COLUMN revoked_at TEXT");
ensureColumn(db, "api_keys", "expires_at", "ALTER TABLE api_keys ADD COLUMN expires_at TEXT");
ensureColumn(db, "api_keys", "last_used_at", "ALTER TABLE api_keys ADD COLUMN last_used_at TEXT");
ensureColumn(db, "api_keys", "key_prefix", "ALTER TABLE api_keys ADD COLUMN key_prefix TEXT");
ensureColumn(db, "api_keys", "ip_allowlist", "ALTER TABLE api_keys ADD COLUMN ip_allowlist TEXT");
ensureColumn(db, "api_keys", "scopes", "ALTER TABLE api_keys ADD COLUMN scopes TEXT");
db.exec(`
CREATE INDEX IF NOT EXISTS idx_api_keys_revoked_at ON api_keys(revoked_at);
CREATE INDEX IF NOT EXISTS idx_api_keys_expires_at ON api_keys(expires_at);
`);
}
function inferPhysicalSchemaBaseline(db: Database.Database): {
version: string;
description: string;
@@ -419,10 +453,13 @@ export function runMigrations(db: Database.Database, options?: { isNewDb?: boole
let count = 0;
for (const migration of pending) {
const sql = fs.readFileSync(migration.path, "utf-8");
const applyMigration = db.transaction(() => {
db.exec(sql);
if (isApiKeyLifecycleMigration(migration)) {
applyApiKeyLifecycleMigration(db);
} else {
const sql = fs.readFileSync(migration.path, "utf-8");
db.exec(sql);
}
db.prepare("INSERT INTO _omniroute_migrations (version, name) VALUES (?, ?)").run(
migration.version,
migration.name

View File

@@ -0,0 +1,24 @@
-- Migration 032: API Key lifecycle hardening
--
-- Phase 3 of the unified-authz plan. Adds explicit lifecycle and policy
-- columns to api_keys without touching the existing `key` column. Existing
-- plain-text keys remain valid; key hashing is a separate follow-up step
-- once revocation/expiry are wired through the validator and policy layer.
--
-- New columns:
-- revoked_at ISO timestamp, NULL when not revoked.
-- expires_at ISO timestamp, NULL = no expiry.
-- last_used_at ISO timestamp updated by validateApiKey on success.
-- key_prefix first ~12 visible chars of the key, for safe display.
-- ip_allowlist JSON array of CIDRs/IPs; NULL or [] = allow any.
-- scopes JSON array of scope strings; NULL or [] = default scopes.
ALTER TABLE api_keys ADD COLUMN revoked_at TEXT;
ALTER TABLE api_keys ADD COLUMN expires_at TEXT;
ALTER TABLE api_keys ADD COLUMN last_used_at TEXT;
ALTER TABLE api_keys ADD COLUMN key_prefix TEXT;
ALTER TABLE api_keys ADD COLUMN ip_allowlist TEXT;
ALTER TABLE api_keys ADD COLUMN scopes TEXT;
CREATE INDEX IF NOT EXISTS idx_api_keys_revoked_at ON api_keys(revoked_at);
CREATE INDEX IF NOT EXISTS idx_api_keys_expires_at ON api_keys(expires_at);

View File

@@ -61,7 +61,6 @@ export const webRuntimeEnvSchema = z.object({
API_KEY_SECRET: optionalTrimmedString,
INITIAL_PASSWORD: optionalTrimmedString,
AUTH_COOKIE_SECURE: optionalBooleanEnv,
REQUIRE_API_KEY: optionalBooleanEnv,
PRICING_SYNC_ENABLED: optionalBooleanEnv,
OMNIROUTE_DISABLE_BACKGROUND_SERVICES: optionalBooleanEnv,
CLOUD_URL: optionalHttpUrl,

View File

@@ -1,6 +1,7 @@
/**
* Vision Bridge helper functions for image processing.
*/
import { fetchRemoteImage } from "@/shared/network/remoteImageFetch";
/**
* Provider to environment variable mapping for API key resolution.
@@ -111,14 +112,9 @@ export function resolveImageAsDataUri(imageUrl: string): string {
}
async function fetchRemoteImageAsDataUri(imageUrl: string, signal: AbortSignal): Promise<string> {
const response = await fetch(imageUrl, { signal });
if (!response.ok) {
throw new Error(`Vision image fetch error ${response.status}`);
}
const mediaType = response.headers.get("content-type")?.split(";")[0]?.trim() || "image/png";
const bytes = Buffer.from(await response.arrayBuffer());
return `data:${mediaType};base64,${bytes.toString("base64")}`;
const remoteImage = await fetchRemoteImage(imageUrl, { signal });
const mediaType = remoteImage.contentType.split(";")[0]?.trim() || "image/png";
return `data:${mediaType};base64,${remoteImage.buffer.toString("base64")}`;
}
async function normalizeVisionImageInput(

View File

@@ -1,213 +1,22 @@
import { NextResponse } from "next/server";
import { jwtVerify, SignJWT } from "jose";
import { generateRequestId } from "./shared/utils/requestId";
import { checkBodySize, getBodySizeLimit } from "./shared/middleware/bodySizeGuard";
import { isDraining } from "./lib/gracefulShutdown";
import { isPublicApiRoute } from "./shared/constants/publicApiRoutes";
import type { NextRequest } from "next/server";
import { runAuthzPipeline } from "./server/authz/pipeline";
const E2E_MODE = process.env.NEXT_PUBLIC_OMNIROUTE_E2E_MODE === "1";
let apiAuthModulePromise: Promise<typeof import("./shared/utils/apiAuth")> | null = null;
let settingsModulePromise: Promise<typeof import("./lib/db/settings")> | null = null;
let modelSyncModulePromise: Promise<typeof import("./shared/services/modelSyncScheduler")> | null =
null;
function getJwtSecret(): Uint8Array {
return new TextEncoder().encode(process.env.JWT_SECRET || "");
}
async function getApiAuthModule() {
if (!apiAuthModulePromise) {
apiAuthModulePromise = import("./shared/utils/apiAuth");
}
return apiAuthModulePromise;
}
async function getSettingsModule() {
if (!settingsModulePromise) {
settingsModulePromise = import("./lib/db/settings");
}
return settingsModulePromise;
}
async function getModelSyncModule() {
if (!modelSyncModulePromise) {
modelSyncModulePromise = import("./shared/services/modelSyncScheduler");
}
return modelSyncModulePromise;
}
export async function proxy(request: any) {
const { pathname } = request.nextUrl;
// Pipeline: Add request ID header for end-to-end tracing
const requestId = generateRequestId();
const response = NextResponse.next();
response.headers.set("X-Request-Id", requestId);
// ──────────────── Pre-flight: Reject during shutdown drain ────────────────
if (isDraining() && pathname.startsWith("/api/")) {
return NextResponse.json(
{
error: {
code: "SERVICE_UNAVAILABLE",
message: "Server is shutting down",
correlation_id: requestId,
},
},
{ status: 503 }
);
}
// ──────────────── Pre-flight: Reject oversized bodies ────────────────
if (pathname.startsWith("/api/") && request.method !== "GET" && request.method !== "OPTIONS") {
const bodySizeRejection = checkBodySize(request, getBodySizeLimit(pathname));
if (bodySizeRejection) return bodySizeRejection;
}
if (E2E_MODE) {
if (pathname.startsWith("/dashboard")) {
return response;
}
if (pathname.startsWith("/api/") && !pathname.startsWith("/api/v1/")) {
return response;
}
}
// ──────────────── Protect Management API Routes ────────────────
if (pathname.startsWith("/api/") && !pathname.startsWith("/api/v1/")) {
// Allow public routes (login, logout, health, etc.)
if (isPublicApiRoute(pathname, request.method)) {
return response;
}
// Allow the model auto-sync scheduler to reach only its internal provider routes.
const { isModelSyncInternalRequest } = await getModelSyncModule();
if (
isModelSyncInternalRequest(request) &&
/^\/api\/providers\/[^/]+\/(sync-models|models)$/.test(pathname)
) {
return response;
}
// Check if auth is required at all (respects requireLogin setting)
const { isAuthRequired, verifyAuth } = await getApiAuthModule();
const authRequired = await isAuthRequired();
if (!authRequired) {
return response;
}
// Verify authentication (JWT cookie or Bearer API key)
const authError = await verifyAuth(request);
if (authError) {
const status = authError === "Invalid management token" ? 403 : 401;
return NextResponse.json(
{
error: {
code: "AUTH_001",
message: authError,
correlation_id: requestId,
},
},
{ status }
);
}
}
// ──────────────── Protect Dashboard Routes ────────────────
if (pathname.startsWith("/dashboard")) {
// Always allow onboarding — it has its own setupComplete guard
if (pathname.startsWith("/dashboard/onboarding")) {
return response;
}
try {
// Direct import — no HTTP self-fetch overhead
const { getSettings } = await getSettingsModule();
const settings = await getSettings();
// Skip auth if login is not required
if (settings.requireLogin === false) {
return response;
}
// Skip auth ONLY for fresh installs (before onboarding) where no password exists yet.
// Once setupComplete is true, always require auth — prevents bypass if password row is lost (#151)
if (!settings.setupComplete && !settings.password && !process.env.INITIAL_PASSWORD) {
return response;
}
} catch (err) {
// FASE-01: Log settings fetch errors instead of silencing them
console.error("[Middleware] settings_error: Settings read failed:", err.message, {
path: pathname,
requestId,
});
// On error, require login (fall through to token check)
}
const token = request.cookies.get("auth_token")?.value;
if (token) {
try {
const { payload } = await jwtVerify(token, getJwtSecret());
// Auto-refresh: if token expires within 7 days, issue a fresh 30-day token
const exp = payload.exp as number;
const now = Math.floor(Date.now() / 1000);
const REFRESH_WINDOW = 7 * 24 * 60 * 60; // 7 days in seconds
if (exp && exp - now < REFRESH_WINDOW) {
try {
const freshToken = await new SignJWT({ authenticated: true })
.setProtectedHeader({ alg: "HS256" })
.setExpirationTime("30d")
.sign(getJwtSecret());
// Detect secure context
const fwdProto = (request.headers.get("x-forwarded-proto") || "")
.split(",")[0]
.trim()
.toLowerCase();
const isHttps = fwdProto === "https" || request.nextUrl?.protocol === "https:";
const useSecure = process.env.AUTH_COOKIE_SECURE === "true" || isHttps;
response.cookies.set("auth_token", freshToken, {
httpOnly: true,
secure: useSecure,
sameSite: "lax",
path: "/",
});
console.log(
`[Middleware] JWT auto-refreshed for ${pathname} (was expiring in ${Math.round((exp - now) / 3600)}h)`
);
} catch (refreshErr) {
// Refresh failed — continue with existing valid token
console.error("[Middleware] JWT auto-refresh failed:", refreshErr.message);
}
}
return response;
} catch (err) {
// FASE-01: Log auth errors instead of silently redirecting
console.error("[Middleware] auth_error: JWT verification failed:", err.message, {
path: pathname,
tokenPresent: true,
requestId,
});
const redirectResponse = NextResponse.redirect(new URL("/login", request.url));
redirectResponse.cookies.delete("auth_token");
return redirectResponse;
}
}
return NextResponse.redirect(new URL("/login", request.url));
}
// Redirect / to /dashboard if logged in, or /dashboard if it's the root
if (pathname === "/") {
return NextResponse.redirect(new URL("/dashboard", request.url));
}
return response;
export async function proxy(request: NextRequest) {
return runAuthzPipeline(request, { enforce: true });
}
export const config = {
matcher: ["/", "/dashboard/:path*", "/api/:path*"],
matcher: [
"/",
"/dashboard/:path*",
"/api/:path*",
"/v1/:path*",
"/v1",
"/chat/:path*",
"/responses/:path*",
"/responses",
"/codex/:path*",
"/codex",
"/models",
],
};

View File

@@ -0,0 +1,106 @@
/**
* Login brute-force guard.
*
* Tracks failed `/api/auth/login` attempts per client IP in process memory
* and returns lockout decisions. Single-process scope is intentional — this
* is a defense-in-depth check that pairs with Cloudflare/reverse-proxy rate
* limiting, not a substitute for it.
*
* Tunables:
* - failure threshold: 5 within `WINDOW_MS`
* - lockout duration: `LOCKOUT_MS`
* - sliding window: `WINDOW_MS`
*
* The guard is a no-op when `enabled` is false; the caller decides based on
* the `bruteForceProtection` setting (default true).
*/
const WINDOW_MS = 15 * 60 * 1000;
const LOCKOUT_MS = 15 * 60 * 1000;
const FAILURE_THRESHOLD = 5;
interface AttemptState {
count: number;
firstAttemptAt: number;
lockedUntil: number | null;
}
const attempts: Map<string, AttemptState> = new Map();
export interface GuardDecision {
allowed: boolean;
retryAfterSeconds?: number;
}
function nowMs(): number {
return Date.now();
}
function clientKey(rawIp: string | null | undefined): string {
const ip = (rawIp || "").trim();
return ip || "__unknown__";
}
export function checkLoginGuard(
rawIp: string | null | undefined,
options: { enabled: boolean }
): GuardDecision {
if (!options.enabled) return { allowed: true };
const state = attempts.get(clientKey(rawIp));
if (!state) return { allowed: true };
const now = nowMs();
if (state.lockedUntil && state.lockedUntil > now) {
return {
allowed: false,
retryAfterSeconds: Math.ceil((state.lockedUntil - now) / 1000),
};
}
return { allowed: true };
}
export function recordLoginFailure(
rawIp: string | null | undefined,
options: { enabled: boolean }
): GuardDecision {
if (!options.enabled) return { allowed: true };
const key = clientKey(rawIp);
const now = nowMs();
const existing = attempts.get(key);
if (!existing || now - existing.firstAttemptAt > WINDOW_MS) {
attempts.set(key, { count: 1, firstAttemptAt: now, lockedUntil: null });
return { allowed: true };
}
const nextCount = existing.count + 1;
if (nextCount >= FAILURE_THRESHOLD) {
const lockedUntil = now + LOCKOUT_MS;
attempts.set(key, {
count: nextCount,
firstAttemptAt: existing.firstAttemptAt,
lockedUntil,
});
return { allowed: false, retryAfterSeconds: Math.ceil(LOCKOUT_MS / 1000) };
}
attempts.set(key, {
count: nextCount,
firstAttemptAt: existing.firstAttemptAt,
lockedUntil: null,
});
return { allowed: true };
}
export function clearLoginAttempts(rawIp: string | null | undefined): void {
attempts.delete(clientKey(rawIp));
}
export function resetLoginGuardForTests(): void {
attempts.clear();
}
export const LOGIN_GUARD_TUNABLES = Object.freeze({
WINDOW_MS,
LOCKOUT_MS,
FAILURE_THRESHOLD,
});

View File

@@ -0,0 +1,88 @@
import {
AUTHZ_HEADER_AUTH_ID,
AUTHZ_HEADER_AUTH_KIND,
AUTHZ_HEADER_AUTH_LABEL,
AUTHZ_HEADER_AUTH_SCOPES,
AUTHZ_HEADER_ROUTE_CLASS,
} from "./headers";
import type { AuthSubject, RouteClass } from "./types";
export class AuthzAssertionError extends Error {
readonly status: number;
readonly code: string;
constructor(code: string, message: string, status = 500) {
super(message);
this.name = "AuthzAssertionError";
this.code = code;
this.status = status;
}
}
type HeaderSource = Headers | { get(name: string): string | null };
function readHeader(source: HeaderSource, name: string): string | null {
return source.get(name) ?? null;
}
function isHeaderSource(value: unknown): value is HeaderSource {
if (value instanceof Headers) return true;
if (typeof value !== "object" || value === null) return false;
return typeof (value as { get?: unknown }).get === "function";
}
export function readSubjectFromHeaders(headers: HeaderSource): AuthSubject {
const kind = (readHeader(headers, AUTHZ_HEADER_AUTH_KIND) ?? "anonymous") as AuthSubject["kind"];
const id = readHeader(headers, AUTHZ_HEADER_AUTH_ID) ?? "anonymous";
const label = readHeader(headers, AUTHZ_HEADER_AUTH_LABEL) ?? undefined;
const rawScopes = readHeader(headers, AUTHZ_HEADER_AUTH_SCOPES);
const scopes = rawScopes
? rawScopes
.split(",")
.map((s) => s.trim())
.filter(Boolean)
: [];
return { kind, id, label, scopes };
}
export function readRouteClassFromHeaders(headers: HeaderSource): RouteClass | null {
const raw = readHeader(headers, AUTHZ_HEADER_ROUTE_CLASS);
if (raw === "PUBLIC" || raw === "CLIENT_API" || raw === "MANAGEMENT") return raw;
return null;
}
export function assertAuth(
request: Request | { headers: HeaderSource },
expected: RouteClass
): AuthSubject {
const headers = request instanceof Request ? request.headers : request.headers;
if (!isHeaderSource(headers)) {
throw new AuthzAssertionError("AUTHZ_INVALID_REQUEST", "Request headers are unavailable", 500);
}
const actualClass = readRouteClassFromHeaders(headers);
if (!actualClass) {
throw new AuthzAssertionError(
"AUTHZ_NOT_INITIALIZED",
"Request did not pass through the authz middleware",
500
);
}
if (actualClass !== expected) {
throw new AuthzAssertionError(
"AUTHZ_ROUTE_CLASS_MISMATCH",
`Expected ${expected} but got ${actualClass}`,
500
);
}
const subject = readSubjectFromHeaders(headers);
if (expected !== "PUBLIC" && subject.kind === "anonymous") {
throw new AuthzAssertionError("AUTHZ_UNAUTHENTICATED", "Authentication required", 401);
}
return subject;
}

View File

@@ -0,0 +1,122 @@
import {
PUBLIC_API_ROUTE_PREFIXES,
PUBLIC_READONLY_API_ROUTE_PREFIXES,
PUBLIC_READONLY_METHODS,
} from "../../shared/constants/publicApiRoutes";
import type { ClassificationReason, RouteClass, RouteClassification } from "./types";
const CLIENT_API_ALIAS_PREFIXES: ReadonlyArray<{ alias: string; canonical: string }> = [
{ alias: "/chat/completions", canonical: "/api/v1/chat/completions" },
{ alias: "/responses", canonical: "/api/v1/responses" },
{ alias: "/models", canonical: "/api/v1/models" },
];
function normalizePathname(rawPath: string): { path: string; reason?: ClassificationReason } {
let path = rawPath || "/";
if (!path.startsWith("/")) path = "/" + path;
if (path.length > 1 && path.endsWith("/")) path = path.slice(0, -1);
if (path === "/codex" || path.startsWith("/codex/")) {
return { path: "/api/v1/responses", reason: "client_api_codex_alias" };
}
if (path === "/v1/v1" || path.startsWith("/v1/v1/")) {
const tail = path.slice("/v1/v1".length) || "";
return { path: "/api/v1" + tail, reason: "client_api_double_prefix" };
}
if (path === "/v1" || path.startsWith("/v1/")) {
const tail = path.slice("/v1".length) || "";
return { path: "/api/v1" + tail, reason: "client_api_alias" };
}
for (const { alias, canonical } of CLIENT_API_ALIAS_PREFIXES) {
if (path === alias) {
return { path: canonical, reason: "client_api_alias" };
}
if (path.startsWith(alias + "/")) {
return { path: canonical + path.slice(alias.length), reason: "client_api_alias" };
}
}
return { path };
}
export function classifyRoute(rawPath: string, method: string = "GET"): RouteClassification {
const { path: normalizedPath, reason: aliasReason } = normalizePathname(rawPath);
if (normalizedPath === "/" || normalizedPath === "") {
return {
routeClass: "MANAGEMENT",
reason: "root_redirect",
normalizedPath: "/",
};
}
if (normalizedPath.startsWith("/dashboard")) {
return {
routeClass: "MANAGEMENT",
reason: "dashboard_prefix",
normalizedPath,
};
}
if (normalizedPath === "/api/v1" || normalizedPath.startsWith("/api/v1/")) {
return {
routeClass: "CLIENT_API",
reason: aliasReason ?? "client_api_v1",
normalizedPath,
};
}
if (normalizedPath.startsWith("/api/")) {
if (isClassifiedAsPublic(normalizedPath, method)) {
return {
routeClass: "PUBLIC",
reason: matchesReadonlyPublic(normalizedPath, method)
? "public_readonly_prefix"
: "public_prefix",
normalizedPath,
};
}
return {
routeClass: "MANAGEMENT",
reason: "management_api",
normalizedPath,
};
}
return {
routeClass: "MANAGEMENT",
reason: "fallback_management",
normalizedPath,
};
}
function matchesReadonlyPublic(path: string, method: string): boolean {
if (!PUBLIC_READONLY_METHODS.has(String(method).toUpperCase())) return false;
return PUBLIC_READONLY_API_ROUTE_PREFIXES.some((p) => path.startsWith(p));
}
function isClassifiedAsPublic(path: string, method: string): boolean {
const isV1ApiPrefix = (p: string) =>
p === "/api/v1" || p === "/api/v1/" || p.startsWith("/api/v1/");
const filtered = PUBLIC_API_ROUTE_PREFIXES.filter((p) => p !== "/api/v1/");
if (filtered.some((prefix) => path.startsWith(prefix)) && !isV1ApiPrefix(path)) {
return true;
}
return matchesReadonlyPublic(path, method);
}
export function isClientApi(routeClass: RouteClass): boolean {
return routeClass === "CLIENT_API";
}
export function isManagement(routeClass: RouteClass): boolean {
return routeClass === "MANAGEMENT";
}
export function isPublic(routeClass: RouteClass): boolean {
return routeClass === "PUBLIC";
}

View File

@@ -0,0 +1,42 @@
import type { AuthSubject, RouteClass, RouteClassification } from "./types";
export interface AuthDecision {
allow: true;
subject: AuthSubject;
}
export interface AuthRejection {
allow: false;
status: number;
code: string;
message: string;
}
export type AuthOutcome = AuthDecision | AuthRejection;
export interface RequestLike {
method: string;
headers: Headers;
cookies?: { get?: (name: string) => { value?: string } | undefined };
nextUrl?: { pathname?: string | null } | null;
url?: string;
}
export interface PolicyContext {
request: RequestLike;
classification: RouteClassification;
requestId: string;
}
export interface RoutePolicy {
readonly routeClass: RouteClass;
evaluate(ctx: PolicyContext): Promise<AuthOutcome>;
}
export function allow(subject: AuthSubject): AuthDecision {
return { allow: true, subject };
}
export function reject(status: number, code: string, message: string): AuthRejection {
return { allow: false, status, code, message };
}

View File

@@ -0,0 +1,34 @@
/**
* Header constants used by the authz pipeline.
*
* Middleware adds these headers to the upstream request after a successful
* auth decision. Route handlers and downstream services read them through
* the assertAuth() helper instead of re-running auth logic.
*
* All header names are lowercase to match Next.js / fetch semantics.
*
* IMPORTANT: these headers are stripped from incoming client requests
* before classification (see pipeline.ts) so a remote caller cannot
* pre-populate them and impersonate a privileged subject.
*/
export const AUTHZ_HEADER_REQUEST_ID = "x-request-id";
export const AUTHZ_HEADER_ROUTE_CLASS = "x-omniroute-route-class";
export const AUTHZ_HEADER_AUTH_KIND = "x-omniroute-auth-kind";
export const AUTHZ_HEADER_AUTH_ID = "x-omniroute-auth-id";
export const AUTHZ_HEADER_AUTH_LABEL = "x-omniroute-auth-label";
export const AUTHZ_HEADER_AUTH_SCOPES = "x-omniroute-auth-scopes";
/**
* Headers the pipeline must NEVER trust on incoming requests. They are
* stripped before route classification to prevent header-spoofing attacks.
*/
export const AUTHZ_TRUSTED_HEADERS: ReadonlyArray<string> = [
AUTHZ_HEADER_ROUTE_CLASS,
AUTHZ_HEADER_AUTH_KIND,
AUTHZ_HEADER_AUTH_ID,
AUTHZ_HEADER_AUTH_LABEL,
AUTHZ_HEADER_AUTH_SCOPES,
];

View File

@@ -0,0 +1,243 @@
import { jwtVerify, SignJWT } from "jose";
import { NextResponse, type NextRequest } from "next/server";
import { isDraining } from "../../lib/gracefulShutdown";
import { checkBodySize, getBodySizeLimit } from "../../shared/middleware/bodySizeGuard";
import { generateRequestId } from "../../shared/utils/requestId";
import { applyCorsHeaders } from "../cors/origins";
import { classifyRoute } from "./classify";
import { clientApiPolicy } from "./policies/clientApi";
import { managementPolicy } from "./policies/management";
import { publicPolicy } from "./policies/public";
import {
AUTHZ_HEADER_AUTH_ID,
AUTHZ_HEADER_AUTH_KIND,
AUTHZ_HEADER_AUTH_LABEL,
AUTHZ_HEADER_AUTH_SCOPES,
AUTHZ_HEADER_REQUEST_ID,
AUTHZ_HEADER_ROUTE_CLASS,
AUTHZ_TRUSTED_HEADERS,
} from "./headers";
import type { AuthSubject, RouteClass, RouteClassification } from "./types";
import type { AuthOutcome, RoutePolicy } from "./context";
export interface AuthzPipelineOptions {
enforce?: boolean;
}
const POLICIES: Record<RouteClass, RoutePolicy> = {
PUBLIC: publicPolicy,
CLIENT_API: clientApiPolicy,
MANAGEMENT: managementPolicy,
};
function stampSubject(headers: Headers, subject: AuthSubject): void {
headers.set(AUTHZ_HEADER_AUTH_KIND, subject.kind);
headers.set(AUTHZ_HEADER_AUTH_ID, subject.id);
if (subject.label) headers.set(AUTHZ_HEADER_AUTH_LABEL, subject.label);
if (subject.scopes && subject.scopes.length > 0) {
headers.set(AUTHZ_HEADER_AUTH_SCOPES, subject.scopes.join(","));
}
}
function rejectionResponse(
outcome: Extract<AuthOutcome, { allow: false }>,
classification: RouteClassification,
requestId: string
): NextResponse {
const response = NextResponse.json(
{
error: {
code: outcome.code,
message: outcome.message,
correlation_id: requestId,
},
},
{ status: outcome.status }
);
response.headers.set(AUTHZ_HEADER_REQUEST_ID, requestId);
response.headers.set(AUTHZ_HEADER_ROUTE_CLASS, classification.routeClass);
return response;
}
function isDashboardPath(pathname: string): boolean {
return pathname === "/dashboard" || pathname.startsWith("/dashboard/");
}
function getCookieValue(request: NextRequest, name: string): string | null {
const fromCookies = request.cookies.get(name)?.value;
if (fromCookies) return fromCookies;
const cookieHeader = request.headers.get("cookie") || request.headers.get("Cookie");
if (!cookieHeader) return null;
for (const segment of cookieHeader.split(";")) {
const [rawKey, ...rawValue] = segment.split("=");
if (!rawKey || rawValue.length === 0) continue;
if (rawKey.trim() === name) return rawValue.join("=").trim() || null;
}
return null;
}
function getJwtSecret(): Uint8Array | null {
const secret = process.env.JWT_SECRET?.trim();
return secret ? new TextEncoder().encode(secret) : null;
}
function shouldUseSecureCookie(request: NextRequest): boolean {
if (process.env.AUTH_COOKIE_SECURE === "true") return true;
const forwardedProto = (request.headers.get("x-forwarded-proto") || "")
.split(",")[0]
.trim()
.toLowerCase();
return forwardedProto === "https" || request.nextUrl.protocol === "https:";
}
async function refreshDashboardSessionIfNeeded(
response: NextResponse,
request: NextRequest
): Promise<void> {
const secret = getJwtSecret();
if (!secret) return;
const token = getCookieValue(request, "auth_token");
if (!token) return;
try {
const { payload } = await jwtVerify(token, secret);
const exp = typeof payload.exp === "number" ? payload.exp : null;
if (!exp) return;
const now = Math.floor(Date.now() / 1000);
const refreshWindowSeconds = 7 * 24 * 60 * 60;
if (exp - now >= refreshWindowSeconds) return;
const freshToken = await new SignJWT({ authenticated: true })
.setProtectedHeader({ alg: "HS256" })
.setExpirationTime("30d")
.sign(secret);
response.cookies.set("auth_token", freshToken, {
httpOnly: true,
secure: shouldUseSecureCookie(request),
sameSite: "lax",
path: "/",
});
} catch (error) {
console.error("[Authz] JWT auto-refresh failed:", error);
}
}
function dashboardLoginRedirect(request: NextRequest, requestId: string): NextResponse {
const response = NextResponse.redirect(new URL("/login", request.url));
response.cookies.delete("auth_token");
stampRouteResponse(response, requestId, "MANAGEMENT");
applyCorsHeaders(response, request);
return response;
}
function drainingResponse(requestId: string): NextResponse {
const response = NextResponse.json(
{
error: {
code: "SERVICE_UNAVAILABLE",
message: "Server is shutting down",
correlation_id: requestId,
},
},
{ status: 503 }
);
response.headers.set(AUTHZ_HEADER_REQUEST_ID, requestId);
return response;
}
function stampRouteResponse(
response: Response,
requestId: string,
routeClass: RouteClass
): Response {
response.headers.set(AUTHZ_HEADER_REQUEST_ID, requestId);
response.headers.set(AUTHZ_HEADER_ROUTE_CLASS, routeClass);
return response;
}
export async function runAuthzPipeline(
request: NextRequest,
options: AuthzPipelineOptions = {}
): Promise<Response> {
const { pathname } = request.nextUrl;
const method = request.method;
const requestId = generateRequestId();
if (pathname === "/") {
const response = NextResponse.redirect(new URL("/dashboard", request.url));
return stampRouteResponse(response, requestId, "MANAGEMENT");
}
const classification = classifyRoute(pathname, method);
if (pathname.startsWith("/api/") && isDraining()) {
const response = drainingResponse(requestId);
stampRouteResponse(response, requestId, classification.routeClass);
applyCorsHeaders(response, request);
return response;
}
if (pathname.startsWith("/api/") && method !== "GET" && method !== "OPTIONS") {
const bodySizeRejection = checkBodySize(request, getBodySizeLimit(pathname));
if (bodySizeRejection) {
stampRouteResponse(bodySizeRejection, requestId, classification.routeClass);
applyCorsHeaders(bodySizeRejection, request);
return bodySizeRejection;
}
}
const requestHeaders = new Headers(request.headers);
for (const trusted of AUTHZ_TRUSTED_HEADERS) {
requestHeaders.delete(trusted);
}
requestHeaders.set(AUTHZ_HEADER_ROUTE_CLASS, classification.routeClass);
requestHeaders.set(AUTHZ_HEADER_REQUEST_ID, requestId);
if (method === "OPTIONS") {
const preflight = new NextResponse(null, { status: 204 });
preflight.headers.set(AUTHZ_HEADER_REQUEST_ID, requestId);
preflight.headers.set(AUTHZ_HEADER_ROUTE_CLASS, classification.routeClass);
applyCorsHeaders(preflight, request);
return preflight;
}
if (!options.enforce) {
const response = NextResponse.next({ request: { headers: requestHeaders } });
response.headers.set(AUTHZ_HEADER_REQUEST_ID, requestId);
response.headers.set(AUTHZ_HEADER_ROUTE_CLASS, classification.routeClass);
applyCorsHeaders(response, request);
return response;
}
const policy = POLICIES[classification.routeClass];
const outcome = await policy.evaluate({ request, classification, requestId });
if (!outcome.allow) {
if (classification.routeClass === "MANAGEMENT" && isDashboardPath(pathname)) {
return dashboardLoginRedirect(request, requestId);
}
const rejection = rejectionResponse(outcome, classification, requestId);
applyCorsHeaders(rejection, request);
return rejection;
}
stampSubject(requestHeaders, outcome.subject);
const response = NextResponse.next({ request: { headers: requestHeaders } });
response.headers.set(AUTHZ_HEADER_REQUEST_ID, requestId);
response.headers.set(AUTHZ_HEADER_ROUTE_CLASS, classification.routeClass);
applyCorsHeaders(response, request);
if (classification.routeClass === "MANAGEMENT" && isDashboardPath(pathname)) {
await refreshDashboardSessionIfNeeded(response, request);
}
return response;
}

View File

@@ -0,0 +1,33 @@
import type { AuthOutcome, PolicyContext, RoutePolicy } from "../context";
import { allow, reject } from "../context";
function extractBearer(headers: Headers): string | null {
const raw = headers.get("authorization") ?? headers.get("Authorization");
if (!raw) return null;
const trimmed = raw.trim();
if (!trimmed.toLowerCase().startsWith("bearer ")) return null;
return trimmed.slice(7).trim() || null;
}
function maskKeyId(apiKey: string): string {
const tail = apiKey.slice(-4);
return `key_${tail}`;
}
export const clientApiPolicy: RoutePolicy = {
routeClass: "CLIENT_API",
async evaluate(ctx: PolicyContext): Promise<AuthOutcome> {
const bearer = extractBearer(ctx.request.headers);
if (!bearer) {
return reject(401, "AUTH_002", "Authentication required");
}
const { validateApiKey } = await import("../../../lib/db/apiKeys");
const ok = await validateApiKey(bearer);
if (!ok) {
return reject(401, "AUTH_002", "Invalid API key");
}
return allow({ kind: "client_api_key", id: maskKeyId(bearer) });
},
};

View File

@@ -0,0 +1,40 @@
import { isModelSyncInternalRequest } from "../../../shared/services/modelSyncScheduler";
import { isAuthRequired, isDashboardSessionAuthenticated } from "../../../shared/utils/apiAuth";
import type { AuthOutcome, PolicyContext, RoutePolicy } from "../context";
import { allow, reject } from "../context";
const MODEL_SYNC_MANAGEMENT_PATH = /^\/api\/providers\/[^/]+\/(sync-models|models)$/;
function hasBearerToken(headers: Headers): boolean {
const authHeader = headers.get("authorization") ?? headers.get("Authorization");
return typeof authHeader === "string" && authHeader.trim().toLowerCase().startsWith("bearer ");
}
function isInternalModelSyncRequest(ctx: PolicyContext): boolean {
if (!MODEL_SYNC_MANAGEMENT_PATH.test(ctx.classification.normalizedPath)) return false;
return isModelSyncInternalRequest(ctx.request);
}
export const managementPolicy: RoutePolicy = {
routeClass: "MANAGEMENT",
async evaluate(ctx: PolicyContext): Promise<AuthOutcome> {
if (!(await isAuthRequired())) {
return allow({ kind: "anonymous", id: "anonymous", label: "auth-disabled" });
}
if (isInternalModelSyncRequest(ctx)) {
return allow({ kind: "management_key", id: "model-sync", label: "internal-model-sync" });
}
if (await isDashboardSessionAuthenticated(ctx.request)) {
return allow({ kind: "dashboard_session", id: "dashboard" });
}
const bearerPresent = hasBearerToken(ctx.request.headers);
return reject(
bearerPresent ? 403 : 401,
"AUTH_001",
bearerPresent ? "Invalid management token" : "Authentication required"
);
},
};

View File

@@ -0,0 +1,9 @@
import type { AuthOutcome, PolicyContext, RoutePolicy } from "../context";
import { allow } from "../context";
export const publicPolicy: RoutePolicy = {
routeClass: "PUBLIC",
async evaluate(_ctx: PolicyContext): Promise<AuthOutcome> {
return allow({ kind: "anonymous", id: "anonymous" });
},
};

74
src/server/authz/types.ts Normal file
View File

@@ -0,0 +1,74 @@
/**
* Unified authz type definitions.
*
* These types are the canonical contract between the Next.js middleware,
* the policy layer, and any defense-in-depth helpers used by route handlers.
*
* Three classes of HTTP routes are recognized:
*
* - PUBLIC — explicitly safe routes (login, logout, status, health,
* onboarding bootstrap).
* - CLIENT_API — model-serving and OpenAI/Anthropic-compatible endpoints
* protected by API keys.
* - MANAGEMENT — dashboard pages, settings, providers, keys, admin and
* diagnostics endpoints protected by dashboard session
* or management-grade credentials.
*
* Any route that cannot be classified MUST fail closed.
*/
export type RouteClass = "PUBLIC" | "CLIENT_API" | "MANAGEMENT";
/**
* Why a particular path was placed into a route class. Used for telemetry,
* diagnostics, and tests so we can prove that routing decisions are
* deterministic and reviewable.
*/
export type ClassificationReason =
| "public_prefix"
| "public_readonly_prefix"
| "dashboard_prefix"
| "client_api_v1"
| "client_api_alias"
| "client_api_codex_alias"
| "client_api_double_prefix"
| "management_api"
| "root_redirect"
| "fallback_management";
export interface RouteClassification {
routeClass: RouteClass;
reason: ClassificationReason;
/**
* The normalized internal pathname after rewrites. For example
* "/v1/chat/completions" or "/codex/foo" is normalized to
* "/api/v1/chat/completions" / "/api/v1/responses" so policy code does
* not have to know about every alias.
*/
normalizedPath: string;
}
/**
* Identity of the authenticated principal once a policy has accepted the
* request. Populated by the policy layer (Phase 2) and consumed by route
* handlers via assertAuth().
*/
export interface AuthSubject {
kind: "client_api_key" | "dashboard_session" | "management_key" | "anonymous";
/**
* Stable identifier of the principal:
* - hashed key id for API keys
* - "dashboard" for the single-tenant dashboard session
* - "anonymous" for unauthenticated PUBLIC requests
*/
id: string;
/**
* Optional human-friendly label (key name, scope hint). Never includes
* the raw secret.
*/
label?: string;
/**
* Scopes granted to this subject. Empty for unauthenticated subjects.
*/
scopes?: ReadonlyArray<string>;
}

124
src/server/cors/origins.ts Normal file
View File

@@ -0,0 +1,124 @@
/**
* Centralized CORS origin allowlist.
*
* Source of truth for which browser origins may call OmniRoute over CORS.
* No wildcard default. To allow any origin, opt in via `CORS_ALLOW_ALL=true`.
*
* Resolution order:
* 1. If `CORS_ALLOW_ALL=true` → echo any origin back (effectively `*`,
* but with `Vary: Origin` so caches stay correct).
* 2. Otherwise, the request's `Origin` is matched (case-insensitive,
* trailing slash ignored) against the allowlist.
* 3. Allowlist sources (merged): env `CORS_ALLOWED_ORIGINS` (csv) and
* anything injected at runtime via `setRuntimeAllowedOrigins()` from
* the persisted settings layer.
*
* The middleware applies the resolved value via `applyCorsHeaders()`.
* Per-route handlers no longer set `Access-Control-Allow-Origin` themselves.
*/
const ENV_ALLOW_ALL = "CORS_ALLOW_ALL";
const ENV_ALLOWED = "CORS_ALLOWED_ORIGINS";
const LEGACY_ENV_SINGLE = "CORS_ORIGIN";
const STANDARD_ALLOW_HEADERS =
"Content-Type, Authorization, x-api-key, anthropic-version, x-omniroute-connection, x-internal-test, accept";
const STANDARD_ALLOW_METHODS = "GET, POST, PUT, DELETE, PATCH, OPTIONS";
let runtimeAllowedOrigins: ReadonlySet<string> = new Set();
/**
* Persisted-setting hook. The settings layer should call this whenever the
* stored `corsOrigins` value changes (csv string).
*/
export function setRuntimeAllowedOrigins(csv: string | null | undefined): void {
runtimeAllowedOrigins = parseOriginList(csv);
}
export function getRuntimeAllowedOrigins(): ReadonlySet<string> {
return runtimeAllowedOrigins;
}
function parseOriginList(value: string | null | undefined): Set<string> {
const result = new Set<string>();
if (!value) return result;
for (const raw of value.split(",")) {
const v = raw.trim();
if (!v) continue;
result.add(normalizeOrigin(v));
}
return result;
}
function normalizeOrigin(origin: string): string {
return origin.toLowerCase().replace(/\/+$/, "");
}
function envAllowAll(): boolean {
const raw = process.env[ENV_ALLOW_ALL];
if (!raw) {
// Backward-compat: legacy `CORS_ORIGIN=*` behaves like allow-all.
return process.env[LEGACY_ENV_SINGLE]?.trim() === "*";
}
return raw.trim().toLowerCase() === "true" || raw.trim() === "1";
}
function envAllowedOrigins(): Set<string> {
const fromList = parseOriginList(process.env[ENV_ALLOWED]);
const legacy = process.env[LEGACY_ENV_SINGLE]?.trim();
if (legacy && legacy !== "*") {
fromList.add(normalizeOrigin(legacy));
}
return fromList;
}
/**
* Resolve which value should be returned in `Access-Control-Allow-Origin`
* for the given request origin, or `null` if the origin is not allowed.
*
* Returns the original (un-normalized) origin string when allowed so the
* browser sees an exact echo and cookies/credentials work correctly.
*/
export function resolveAllowedOrigin(requestOrigin: string | null | undefined): string | null {
if (envAllowAll()) {
// `*` is only safe when no credentials are involved; if a request did
// arrive with an Origin header, echo it back with Vary so credentialed
// flows can opt in via the explicit allowlist instead.
return requestOrigin && requestOrigin.length > 0 ? requestOrigin : "*";
}
if (!requestOrigin) return null;
const normalized = normalizeOrigin(requestOrigin);
if (envAllowedOrigins().has(normalized)) return requestOrigin;
if (runtimeAllowedOrigins.has(normalized)) return requestOrigin;
return null;
}
/**
* Apply CORS headers to a response in-place. Safe to call on any response
* (rejections, preflight, normal `next()` continuations). When the origin
* is not allowed, no `Access-Control-Allow-Origin` is added — browsers
* will block the response, which is the desired fail-closed behavior.
*/
export function applyCorsHeaders(response: Response, request: Request): void {
const requestOrigin = request.headers.get("origin");
const allowed = resolveAllowedOrigin(requestOrigin);
if (allowed !== null) {
response.headers.set("Access-Control-Allow-Origin", allowed);
response.headers.append("Vary", "Origin");
}
response.headers.set("Access-Control-Allow-Methods", STANDARD_ALLOW_METHODS);
response.headers.set("Access-Control-Allow-Headers", STANDARD_ALLOW_HEADERS);
const requestedHeaders = request.headers.get("access-control-request-headers");
if (requestedHeaders) {
response.headers.set("Access-Control-Allow-Headers", requestedHeaders);
}
}
/**
* Plain-object form for handlers that still need static CORS headers
* (no origin echo). Middleware overlays the proper `Access-Control-Allow-Origin`
* later on the way out.
*/
export const STATIC_CORS_HEADERS: Readonly<Record<string, string>> = Object.freeze({
"Access-Control-Allow-Methods": STANDARD_ALLOW_METHODS,
"Access-Control-Allow-Headers": STANDARD_ALLOW_HEADERS,
});

View File

@@ -65,7 +65,6 @@ export function checkBodySize(request: Request, limit: number = MAX_BODY_BYTES):
status: 413,
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": process.env.CORS_ORIGIN || "*",
},
}
);

View File

@@ -0,0 +1,118 @@
import {
type OutboundUrlGuardMode,
getProviderOutboundGuard,
parseAndValidatePublicUrl,
parseOutboundUrl,
} from "@/shared/network/outboundUrlGuard";
const DEFAULT_MAX_REMOTE_IMAGE_BYTES = 20 * 1024 * 1024;
const DEFAULT_MAX_REDIRECTS = 3;
const DEFAULT_TIMEOUT_MS = 15000;
export interface RemoteImageFetchOptions {
fetchImpl?: typeof fetch;
guard?: OutboundUrlGuardMode;
maxBytes?: number;
maxRedirects?: number;
signal?: AbortSignal;
timeoutMs?: number;
}
export interface RemoteImageFetchResult {
buffer: Buffer;
contentType: string;
url: string;
}
function validateRemoteImageUrl(input: string | URL, guard: OutboundUrlGuardMode) {
return guard === "public-only" ? parseAndValidatePublicUrl(input) : parseOutboundUrl(input);
}
function combineSignals(signal: AbortSignal | undefined, timeoutMs: number) {
const timeoutSignal = AbortSignal.timeout(timeoutMs);
if (!signal) return timeoutSignal;
return AbortSignal.any([signal, timeoutSignal]);
}
async function readResponseBuffer(response: Response, maxBytes: number) {
const contentLengthHeader = response.headers.get("content-length");
const contentLength = contentLengthHeader ? Number.parseInt(contentLengthHeader, 10) : null;
if (contentLength !== null && Number.isFinite(contentLength) && contentLength > maxBytes) {
throw new Error(`Remote image exceeds ${maxBytes} byte limit`);
}
if (!response.body) {
const buffer = Buffer.from(await response.arrayBuffer());
if (buffer.byteLength > maxBytes) {
throw new Error(`Remote image exceeds ${maxBytes} byte limit`);
}
return buffer;
}
const reader = response.body.getReader();
const chunks: Buffer[] = [];
let totalBytes = 0;
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = Buffer.from(value);
totalBytes += chunk.byteLength;
if (totalBytes > maxBytes) {
await reader.cancel();
throw new Error(`Remote image exceeds ${maxBytes} byte limit`);
}
chunks.push(chunk);
}
} finally {
reader.releaseLock();
}
return Buffer.concat(chunks, totalBytes);
}
export async function fetchRemoteImage(
input: string | URL,
options: RemoteImageFetchOptions = {}
): Promise<RemoteImageFetchResult> {
const fetchImpl = options.fetchImpl ?? fetch;
const guard = options.guard ?? getProviderOutboundGuard();
const maxBytes = options.maxBytes ?? DEFAULT_MAX_REMOTE_IMAGE_BYTES;
const maxRedirects = options.maxRedirects ?? DEFAULT_MAX_REDIRECTS;
const signal = combineSignals(options.signal, options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
let currentUrl = validateRemoteImageUrl(input, guard);
for (let redirectCount = 0; redirectCount <= maxRedirects; redirectCount++) {
const response = await fetchImpl(currentUrl.toString(), {
method: "GET",
redirect: "manual",
signal,
});
if (response.status >= 300 && response.status < 400) {
const location = response.headers.get("location");
if (!location) {
throw new Error(`Remote image redirect missing Location header (${response.status})`);
}
if (redirectCount >= maxRedirects) {
throw new Error(`Remote image exceeded ${maxRedirects} redirect limit`);
}
currentUrl = validateRemoteImageUrl(new URL(location, currentUrl), guard);
continue;
}
if (!response.ok) {
throw new Error(`Remote image fetch error ${response.status}`);
}
return {
buffer: await readResponseBuffer(response, maxBytes),
contentType: response.headers.get("content-type") || "application/octet-stream",
url: currentUrl.toString(),
};
}
throw new Error(`Remote image exceeded ${maxRedirects} redirect limit`);
}

View File

@@ -48,7 +48,7 @@ export function buildModelSyncInternalHeaders(): Record<string, string> {
return { [MODEL_SYNC_INTERNAL_AUTH_HEADER]: getInternalAuthToken() };
}
export function isModelSyncInternalRequest(request: Request): boolean {
export function isModelSyncInternalRequest(request: { headers: Headers }): boolean {
if (!internalAuthToken && globalState.__omnirouteModelSyncInternalAuthToken) {
internalAuthToken = globalState.__omnirouteModelSyncInternalAuthToken;
}

View File

@@ -1,37 +1,24 @@
/**
* Shared CORS configuration for all API routes.
* Static CORS headers for route handlers.
*
* Centralizes the Access-Control-Allow-Origin header so it can be
* configured via the CORS_ORIGIN environment variable instead of
* being hardcoded as "*" in every route handler.
*
* Usage:
* import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors";
*
* // In route responses:
* return new Response(body, { headers: { ...CORS_HEADERS, "Content-Type": "application/json" } });
*
* // For OPTIONS preflight:
* export function OPTIONS() { return handleCorsOptions(); }
*/
export const CORS_ORIGIN = process.env.CORS_ORIGIN || "*";
/**
* Standard CORS headers to spread into any Response.
* @type {Record<string, string>}
* `Access-Control-Allow-Origin` is intentionally NOT set here. The middleware
* (`src/middleware.ts` → `applyCorsHeaders`) is the single source of truth for
* which origin to echo, based on the central allowlist in
* `src/server/cors/origins.ts`. Route handlers may keep spreading
* `CORS_HEADERS` for the standard methods/allowed-headers; the middleware
* overlays the proper origin on the way out.
*/
export const CORS_HEADERS = {
"Access-Control-Allow-Origin": CORS_ORIGIN,
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, PATCH, OPTIONS",
"Access-Control-Allow-Headers":
"Content-Type, Authorization, x-api-key, anthropic-version, x-omniroute-connection, x-internal-test, accept",
};
} as const;
/**
* Handle CORS preflight (OPTIONS) request.
* @returns {Response} 204 No Content with CORS headers
* Preflight responder kept for routes that still ship their own OPTIONS handler.
* Returning 204 with `CORS_HEADERS` is enough; the middleware will add the
* allowed origin and `Vary: Origin` before the response leaves the server.
*/
export function handleCorsOptions() {
export function handleCorsOptions(): Response {
return new Response(null, { status: 204, headers: CORS_HEADERS });
}

View File

@@ -434,9 +434,9 @@ export const updateSettingsSchema = z.object({
cloudUrl: z.string().max(500).optional(),
baseUrl: z.string().max(500).optional(),
setupComplete: z.boolean().optional(),
requireAuthForModels: z.boolean().optional(),
blockedProviders: z.array(z.string().max(100)).optional(),
hideHealthCheckLogs: z.boolean().optional(),
bruteForceProtection: z.boolean().optional(),
hiddenSidebarItems: z.array(z.enum(HIDEABLE_SIDEBAR_ITEM_IDS)).optional(),
comboConfigMode: z.enum(COMBO_CONFIG_MODES).optional(),
// Routing settings (#134)

View File

@@ -43,7 +43,6 @@ export const updateSettingsSchema = z.object({
cloudUrl: z.string().max(500).optional(),
baseUrl: z.string().max(500).optional(),
setupComplete: z.boolean().optional(),
requireAuthForModels: z.boolean().optional(),
blockedProviders: z.array(z.string().max(100)).optional(),
hideHealthCheckLogs: z.boolean().optional(),
debugMode: z.boolean().optional(),
@@ -108,4 +107,5 @@ export const updateSettingsSchema = z.object({
// Missing settings
lkgpEnabled: z.boolean().optional(),
backgroundDegradation: z.unknown().optional(),
bruteForceProtection: z.boolean().optional(),
});

View File

@@ -3,7 +3,6 @@ import {
getProviderCredentialsWithQuotaPreflight,
markAccountUnavailable,
extractApiKey,
isValidApiKey,
} from "../services/auth";
import {
getRuntimeProviderProfile,
@@ -180,26 +179,7 @@ export async function handleChat(request: any, clientRawRequest: any = null) {
log.debug("AUTH", "No API key provided (local mode)");
}
// Optional strict API key mode for /v1 endpoints (require key on every request).
const isComboLiveTest = request.headers?.get?.("x-internal-test") === "combo-health-check";
if (process.env.REQUIRE_API_KEY === "true" && !isComboLiveTest) {
if (!apiKey) {
log.warn("AUTH", "Missing API key while REQUIRE_API_KEY=true");
return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Missing API key");
}
const valid = await isValidApiKey(apiKey);
if (!valid) {
log.warn("AUTH", "Invalid API key while REQUIRE_API_KEY=true");
return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Invalid API key");
}
} else if (apiKey && !isComboLiveTest) {
// Client sent a Bearer key — it must exist in DB (otherwise reject to avoid "key ignored" confusion).
const valid = await isValidApiKey(apiKey);
if (!valid) {
log.warn("AUTH", "API key not found or invalid (must be created in API Manager)");
return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Invalid API key");
}
}
if (!modelStr) {
log.warn("CHAT", "Missing model");

View File

@@ -28,7 +28,6 @@ declare namespace NodeJS {
OMNIROUTE_DISABLE_BACKGROUND_SERVICES?: string;
OMNIROUTE_PORT?: string;
PRICING_SYNC_ENABLED?: string;
REQUIRE_API_KEY?: string;
NODE_ENV?: "development" | "production" | "test";
}
}

View File

@@ -402,9 +402,9 @@ describe("CORS — centralized configuration", () => {
assert.ok(existsSync(full), "shared/utils/cors.ts should exist");
});
it("should export CORS_HEADERS and CORS_ORIGIN", () => {
it("should export CORS_HEADERS without a wildcard origin", () => {
const src = readSrc("shared/utils/cors.ts");
assert.match(src, /CORS_HEADERS/);
assert.match(src, /CORS_ORIGIN/);
assert.doesNotMatch(src, /Access-Control-Allow-Origin/);
});
});

View File

@@ -118,7 +118,7 @@ test("auth login route records failed password attempts", async () => {
assert.equal(event.actor, "anonymous");
assert.equal(event.status, "failed");
assert.equal(event.requestId, "req-auth-failed");
assert.deepEqual(event.metadata, { reason: "invalid_password" });
assert.deepEqual(event.metadata, { reason: "invalid_password", lockedOut: false });
});
test("provider create/update/delete routes emit sanitized credential audit events", async () => {

View File

@@ -0,0 +1,119 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omr-apikey-lifecycle-"));
process.env.DATA_DIR = TEST_DATA_DIR;
process.env.API_KEY_SECRET = "test-secret";
const core = await import("../../src/lib/db/core.ts");
const apiKeysDb = await import("../../src/lib/db/apiKeys.ts");
const ORIGINAL_OMNIROUTE_API_KEY = process.env.OMNIROUTE_API_KEY;
const ORIGINAL_ROUTER_API_KEY = process.env.ROUTER_API_KEY;
function reset() {
core.resetDbInstance();
apiKeysDb.resetApiKeyState();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
delete process.env.OMNIROUTE_API_KEY;
delete process.env.ROUTER_API_KEY;
}
test.beforeEach(() => {
reset();
});
test.after(() => {
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
if (ORIGINAL_OMNIROUTE_API_KEY === undefined) delete process.env.OMNIROUTE_API_KEY;
else process.env.OMNIROUTE_API_KEY = ORIGINAL_OMNIROUTE_API_KEY;
if (ORIGINAL_ROUTER_API_KEY === undefined) delete process.env.ROUTER_API_KEY;
else process.env.ROUTER_API_KEY = ORIGINAL_ROUTER_API_KEY;
});
async function makeKey(name = "lifecycle-test", machineId = "machine-lifecycle") {
const created = await apiKeysDb.createApiKey(name, machineId);
assert.ok(created?.key, "createApiKey returned a key");
return created;
}
test("validateApiKey returns true for a fresh active key", async () => {
const created = await makeKey();
assert.equal(await apiKeysDb.validateApiKey(created.key), true);
});
test("validateApiKey rejects revoked keys after revokeApiKey", async () => {
const created = await makeKey();
assert.equal(await apiKeysDb.validateApiKey(created.key), true);
const ok = await apiKeysDb.revokeApiKey(created.id);
assert.equal(ok, true);
assert.equal(await apiKeysDb.validateApiKey(created.key), false);
});
test("validateApiKey rejects keys whose expires_at has passed", async () => {
const created = await makeKey();
const past = new Date(Date.now() - 60_000).toISOString();
const ok = await apiKeysDb.setApiKeyExpiry(created.id, past);
assert.equal(ok, true);
assert.equal(await apiKeysDb.validateApiKey(created.key), false);
});
test("validateApiKey accepts keys with future expires_at", async () => {
const created = await makeKey();
const future = new Date(Date.now() + 60 * 60_000).toISOString();
const ok = await apiKeysDb.setApiKeyExpiry(created.id, future);
assert.equal(ok, true);
assert.equal(await apiKeysDb.validateApiKey(created.key), true);
});
test("validateApiKey rejects deactivated keys (is_active=false)", async () => {
const created = await makeKey();
const ok = await apiKeysDb.updateApiKeyPermissions(created.id, { isActive: false });
assert.equal(ok, true);
assert.equal(await apiKeysDb.validateApiKey(created.key), false);
});
test("revokeApiKey is idempotent and returns false for missing id", async () => {
const created = await makeKey();
assert.equal(await apiKeysDb.revokeApiKey(created.id), true);
assert.equal(await apiKeysDb.revokeApiKey(created.id), true);
assert.equal(await apiKeysDb.revokeApiKey("00000000-0000-0000-0000-000000000000"), false);
});
test("getApiKeyMetadata exposes lifecycle and policy fields", async () => {
const created = await makeKey();
await apiKeysDb.setApiKeyExpiry(created.id, new Date(Date.now() + 86_400_000).toISOString());
const md = await apiKeysDb.getApiKeyMetadata(created.key);
assert.ok(md);
assert.equal(md!.isActive, true);
assert.equal(md!.revokedAt, null);
assert.ok(md!.expiresAt && Date.parse(md!.expiresAt) > Date.now());
assert.deepEqual(md!.ipAllowlist, []);
assert.deepEqual(md!.scopes, []);
});
test("validateApiKey accepts configured environment API keys", async () => {
process.env.OMNIROUTE_API_KEY = "sk-env-lifecycle-test";
assert.equal(await apiKeysDb.validateApiKey("sk-env-lifecycle-test"), true);
});
test("validateApiKey updates last_used_at for persisted keys", async () => {
const created = await makeKey();
assert.equal(await apiKeysDb.validateApiKey(created.key), true);
const db = core.getDbInstance() as {
prepare(sql: string): { get(id: string): { last_used_at: string | null } | undefined };
};
const row = db.prepare("SELECT last_used_at FROM api_keys WHERE id = ?").get(created.id);
assert.ok(row?.last_used_at, "last_used_at should be set on successful validation");
assert.ok(Date.parse(row.last_used_at) > 0, "last_used_at should be an ISO timestamp");
});

View File

@@ -62,7 +62,7 @@ test("handleAudioSpeech proxies OpenAI-compatible providers with defaults", asyn
});
assert.equal(response.status, 200);
assert.equal(response.headers.get("content-type"), "audio/opus");
assert.ok(response.headers.get("access-control-allow-origin"));
assert.match(response.headers.get("access-control-allow-methods") || "", /OPTIONS/);
} finally {
globalThis.fetch = originalFetch;
}

View File

@@ -95,40 +95,13 @@ test("moderations route clears stale provider error metadata on success", async
}
});
test("moderations route covers CORS, API key auth, validation, and missing credential branches", async () => {
test("moderations route covers CORS, validation, and missing credential branches", async () => {
await resetStorage();
const optionsResponse = await moderationRoute.OPTIONS();
assert.equal(optionsResponse.status, 200);
assert.equal(optionsResponse.headers.get("Access-Control-Allow-Methods"), "POST, OPTIONS");
await withEnv("REQUIRE_API_KEY", "true", async () => {
const missingKeyResponse = await moderationRoute.POST(
new Request("http://localhost/v1/moderations", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ input: "hello" }),
})
);
assert.equal(missingKeyResponse.status, 401);
assert.match(await missingKeyResponse.text(), /Missing API key/i);
const invalidKeyResponse = await moderationRoute.POST(
new Request("http://localhost/v1/moderations", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: "Bearer invalid-test-key",
},
body: JSON.stringify({ input: "hello" }),
})
);
assert.equal(invalidKeyResponse.status, 401);
assert.match(await invalidKeyResponse.text(), /Invalid API key/i);
});
const invalidJsonResponse = await moderationRoute.POST(
new Request("http://localhost/v1/moderations", {
method: "POST",

View File

@@ -0,0 +1,65 @@
import { describe, it, beforeEach } from "node:test";
import assert from "node:assert/strict";
import {
checkLoginGuard,
clearLoginAttempts,
recordLoginFailure,
resetLoginGuardForTests,
LOGIN_GUARD_TUNABLES,
} from "../../../src/server/auth/loginGuard";
describe("loginGuard", () => {
beforeEach(() => {
resetLoginGuardForTests();
});
it("is a no-op when bruteForceProtection is disabled", () => {
for (let i = 0; i < 20; i++) {
const decision = recordLoginFailure("1.2.3.4", { enabled: false });
assert.equal(decision.allowed, true);
}
assert.equal(checkLoginGuard("1.2.3.4", { enabled: false }).allowed, true);
});
it("allows the first attempts up to threshold-1, locks on the threshold hit", () => {
const ip = "10.0.0.1";
for (let i = 0; i < LOGIN_GUARD_TUNABLES.FAILURE_THRESHOLD - 1; i++) {
const dec = recordLoginFailure(ip, { enabled: true });
assert.equal(dec.allowed, true, `attempt #${i + 1} should still be allowed`);
}
const lockingHit = recordLoginFailure(ip, { enabled: true });
assert.equal(lockingHit.allowed, false);
assert.ok((lockingHit.retryAfterSeconds || 0) > 0);
const subsequent = checkLoginGuard(ip, { enabled: true });
assert.equal(subsequent.allowed, false);
assert.ok((subsequent.retryAfterSeconds || 0) > 0);
});
it("scopes lockouts per IP", () => {
const ipA = "10.0.0.1";
const ipB = "10.0.0.2";
for (let i = 0; i < LOGIN_GUARD_TUNABLES.FAILURE_THRESHOLD; i++) {
recordLoginFailure(ipA, { enabled: true });
}
assert.equal(checkLoginGuard(ipA, { enabled: true }).allowed, false);
assert.equal(checkLoginGuard(ipB, { enabled: true }).allowed, true);
});
it("clearLoginAttempts releases the lock for that IP only", () => {
const ip = "10.0.0.7";
for (let i = 0; i < LOGIN_GUARD_TUNABLES.FAILURE_THRESHOLD; i++) {
recordLoginFailure(ip, { enabled: true });
}
assert.equal(checkLoginGuard(ip, { enabled: true }).allowed, false);
clearLoginAttempts(ip);
assert.equal(checkLoginGuard(ip, { enabled: true }).allowed, true);
});
it("treats null/undefined ip as a single bucket", () => {
for (let i = 0; i < LOGIN_GUARD_TUNABLES.FAILURE_THRESHOLD; i++) {
recordLoginFailure(null, { enabled: true });
}
assert.equal(checkLoginGuard(undefined, { enabled: true }).allowed, false);
});
});

View File

@@ -0,0 +1,190 @@
import test from "node:test";
import assert from "node:assert/strict";
import { classifyRoute } from "../../../src/server/authz/classify.ts";
import type { RouteClass } from "../../../src/server/authz/types.ts";
interface Case {
name: string;
path: string;
method?: string;
expectedClass: RouteClass;
expectedNormalized?: string;
}
const cases: Case[] = [
{ name: "root /", path: "/", expectedClass: "MANAGEMENT", expectedNormalized: "/" },
{ name: "dashboard root", path: "/dashboard", expectedClass: "MANAGEMENT" },
{ name: "dashboard nested", path: "/dashboard/settings", expectedClass: "MANAGEMENT" },
{ name: "dashboard onboarding", path: "/dashboard/onboarding", expectedClass: "MANAGEMENT" },
{
name: "/api/v1 base",
path: "/api/v1",
expectedClass: "CLIENT_API",
expectedNormalized: "/api/v1",
},
{
name: "/api/v1/chat/completions",
path: "/api/v1/chat/completions",
expectedClass: "CLIENT_API",
},
{ name: "/api/v1/responses", path: "/api/v1/responses", expectedClass: "CLIENT_API" },
{ name: "/api/v1/models", path: "/api/v1/models", expectedClass: "CLIENT_API" },
{ name: "/api/v1/embeddings", path: "/api/v1/embeddings", expectedClass: "CLIENT_API" },
{ name: "/api/v1/files", path: "/api/v1/files", expectedClass: "CLIENT_API" },
{ name: "/api/v1/batches", path: "/api/v1/batches", expectedClass: "CLIENT_API" },
{ name: "/api/v1/ws", path: "/api/v1/ws", expectedClass: "CLIENT_API" },
{
name: "/v1 alias",
path: "/v1",
expectedClass: "CLIENT_API",
expectedNormalized: "/api/v1",
},
{
name: "/v1/chat/completions alias",
path: "/v1/chat/completions",
expectedClass: "CLIENT_API",
expectedNormalized: "/api/v1/chat/completions",
},
{
name: "/v1/v1 double-prefix",
path: "/v1/v1",
expectedClass: "CLIENT_API",
expectedNormalized: "/api/v1",
},
{
name: "/v1/v1/embeddings double-prefix",
path: "/v1/v1/embeddings",
expectedClass: "CLIENT_API",
expectedNormalized: "/api/v1/embeddings",
},
{
name: "/chat/completions alias",
path: "/chat/completions",
expectedClass: "CLIENT_API",
expectedNormalized: "/api/v1/chat/completions",
},
{
name: "/responses alias",
path: "/responses",
expectedClass: "CLIENT_API",
expectedNormalized: "/api/v1/responses",
},
{
name: "/responses/abc alias",
path: "/responses/abc",
expectedClass: "CLIENT_API",
expectedNormalized: "/api/v1/responses/abc",
},
{
name: "/codex/* alias collapses to /api/v1/responses",
path: "/codex/anything",
expectedClass: "CLIENT_API",
expectedNormalized: "/api/v1/responses",
},
{
name: "/models alias",
path: "/models",
expectedClass: "CLIENT_API",
expectedNormalized: "/api/v1/models",
},
{
name: "/api/auth/login is PUBLIC",
path: "/api/auth/login",
method: "POST",
expectedClass: "PUBLIC",
},
{
name: "/api/auth/logout is PUBLIC",
path: "/api/auth/logout",
method: "POST",
expectedClass: "PUBLIC",
},
{
name: "/api/auth/status is PUBLIC",
path: "/api/auth/status",
method: "GET",
expectedClass: "PUBLIC",
},
{ name: "/api/init is PUBLIC", path: "/api/init", method: "POST", expectedClass: "PUBLIC" },
{
name: "/api/monitoring/health is PUBLIC",
path: "/api/monitoring/health",
method: "GET",
expectedClass: "PUBLIC",
},
{
name: "/api/cloud/* is PUBLIC",
path: "/api/cloud/something",
method: "GET",
expectedClass: "PUBLIC",
},
{
name: "/api/oauth/* is PUBLIC",
path: "/api/oauth/callback",
method: "GET",
expectedClass: "PUBLIC",
},
{
name: "/api/sync/bundle is PUBLIC",
path: "/api/sync/bundle",
method: "POST",
expectedClass: "PUBLIC",
},
{
name: "/api/settings/require-login GET is PUBLIC readonly",
path: "/api/settings/require-login",
method: "GET",
expectedClass: "PUBLIC",
},
{
name: "/api/settings/require-login POST is MANAGEMENT",
path: "/api/settings/require-login",
method: "POST",
expectedClass: "MANAGEMENT",
},
{
name: "/api/providers/* MUST stay MANAGEMENT",
path: "/api/providers/openai",
expectedClass: "MANAGEMENT",
},
{ name: "/api/keys MANAGEMENT", path: "/api/keys", expectedClass: "MANAGEMENT" },
{ name: "/api/settings MANAGEMENT", path: "/api/settings", expectedClass: "MANAGEMENT" },
{ name: "/api/audit MANAGEMENT", path: "/api/audit", expectedClass: "MANAGEMENT" },
{
name: "Unknown top-level path defaults MANAGEMENT (fail-closed)",
path: "/totally-unknown",
expectedClass: "MANAGEMENT",
},
];
for (const c of cases) {
test(`classifyRoute: ${c.name}`, () => {
const r = classifyRoute(c.path, c.method ?? "GET");
assert.equal(r.routeClass, c.expectedClass, `routeClass for ${c.path}`);
if (c.expectedNormalized) {
assert.equal(r.normalizedPath, c.expectedNormalized, `normalizedPath for ${c.path}`);
}
});
}
test("classifyRoute returns deterministic result for trailing slash", () => {
const a = classifyRoute("/api/v1/chat/completions", "POST");
const b = classifyRoute("/api/v1/chat/completions/", "POST");
assert.equal(a.routeClass, b.routeClass);
assert.equal(a.normalizedPath, b.normalizedPath);
});
test("classifyRoute strips trailing slash on root only when not '/'", () => {
assert.equal(classifyRoute("/").normalizedPath, "/");
});
test("classifyRoute treats /api/v1 prefix exactly", () => {
assert.equal(classifyRoute("/api/v1abc").routeClass, "MANAGEMENT");
assert.equal(classifyRoute("/api/v1/x").routeClass, "CLIENT_API");
});

View File

@@ -0,0 +1,112 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omr-clientapi-policy-"));
process.env.DATA_DIR = TEST_DATA_DIR;
process.env.API_KEY_SECRET = "test-secret";
const apiKeysDb = await import("../../../src/lib/db/apiKeys.ts");
const core = await import("../../../src/lib/db/core.ts");
const ORIGINAL_OMNIROUTE_API_KEY = process.env.OMNIROUTE_API_KEY;
const ORIGINAL_ROUTER_API_KEY = process.env.ROUTER_API_KEY;
function resetStorage() {
core.resetDbInstance();
apiKeysDb.resetApiKeyState();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
delete process.env.OMNIROUTE_API_KEY;
delete process.env.ROUTER_API_KEY;
}
test.beforeEach(() => {
resetStorage();
});
test.after(() => {
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
if (ORIGINAL_OMNIROUTE_API_KEY === undefined) delete process.env.OMNIROUTE_API_KEY;
else process.env.OMNIROUTE_API_KEY = ORIGINAL_OMNIROUTE_API_KEY;
if (ORIGINAL_ROUTER_API_KEY === undefined) delete process.env.ROUTER_API_KEY;
else process.env.ROUTER_API_KEY = ORIGINAL_ROUTER_API_KEY;
});
async function loadPolicy() {
const mod = await import(`../../../src/server/authz/policies/clientApi.ts?ts=${Date.now()}`);
return mod.clientApiPolicy;
}
function ctx(headers: Headers) {
return {
request: { method: "POST", headers },
classification: {
routeClass: "CLIENT_API" as const,
reason: "client_api_v1" as const,
normalizedPath: "/api/v1/chat/completions",
},
requestId: "req_test",
};
}
test("clientApiPolicy: missing bearer is rejected with 401", async () => {
const policy = await loadPolicy();
const out = await policy.evaluate(ctx(new Headers()));
assert.equal(out.allow, false);
if (!out.allow) {
assert.equal(out.status, 401);
assert.equal(out.code, "AUTH_002");
}
});
test("clientApiPolicy: invalid bearer is rejected with 401", async () => {
const policy = await loadPolicy();
const headers = new Headers({ authorization: "Bearer sk-totally-bogus" });
const out = await policy.evaluate(ctx(headers));
assert.equal(out.allow, false);
if (!out.allow) {
assert.equal(out.status, 401);
assert.equal(out.code, "AUTH_002");
}
});
test("clientApiPolicy: valid bearer is accepted as client_api_key subject", async () => {
const created = await apiKeysDb.createApiKey("policy-test-key", "machine-test-1234");
assert.ok(created?.key, "createApiKey must return a key");
const policy = await loadPolicy();
const headers = new Headers({ authorization: `Bearer ${created.key}` });
const out = await policy.evaluate(ctx(headers));
assert.equal(out.allow, true);
if (out.allow) {
assert.equal(out.subject.kind, "client_api_key");
assert.match(out.subject.id, /^key_/);
}
});
test("clientApiPolicy: revoked bearer is rejected", async () => {
const created = await apiKeysDb.createApiKey("policy-revoked-key", "machine-revoked");
assert.ok(await apiKeysDb.revokeApiKey(created.id));
const policy = await loadPolicy();
const headers = new Headers({ authorization: `Bearer ${created.key}` });
const out = await policy.evaluate(ctx(headers));
assert.equal(out.allow, false);
});
test("clientApiPolicy: environment API key remains accepted for client API routes", async () => {
process.env.OMNIROUTE_API_KEY = "sk-env-policy-test";
const policy = await loadPolicy();
const out = await policy.evaluate(
ctx(new Headers({ authorization: "Bearer sk-env-policy-test" }))
);
assert.equal(out.allow, true);
if (out.allow) {
assert.equal(out.subject.kind, "client_api_key");
}
});

View File

@@ -0,0 +1,121 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omr-mgmt-policy-"));
process.env.DATA_DIR = TEST_DATA_DIR;
process.env.API_KEY_SECRET = "test-secret";
const core = await import("../../../src/lib/db/core.ts");
const apiKeysDb = await import("../../../src/lib/db/apiKeys.ts");
const settingsDb = await import("../../../src/lib/db/settings.ts");
const modelSync = await import("../../../src/shared/services/modelSyncScheduler.ts");
const ORIGINAL_JWT = process.env.JWT_SECRET;
const ORIGINAL_INITIAL = process.env.INITIAL_PASSWORD;
function reset() {
core.resetDbInstance();
apiKeysDb.resetApiKeyState();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
delete process.env.JWT_SECRET;
delete process.env.INITIAL_PASSWORD;
}
test.beforeEach(() => {
reset();
});
test.after(() => {
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
if (ORIGINAL_JWT === undefined) delete process.env.JWT_SECRET;
else process.env.JWT_SECRET = ORIGINAL_JWT;
if (ORIGINAL_INITIAL === undefined) delete process.env.INITIAL_PASSWORD;
else process.env.INITIAL_PASSWORD = ORIGINAL_INITIAL;
});
async function loadPolicy() {
const mod = await import(`../../../src/server/authz/policies/management.ts?ts=${Date.now()}`);
return mod.managementPolicy;
}
function ctx(headers: Headers, method = "GET", path = "/api/keys") {
return {
request: { method, headers, url: `http://localhost${path}`, nextUrl: { pathname: path } },
classification: {
routeClass: "MANAGEMENT" as const,
reason: path.startsWith("/dashboard")
? ("dashboard_prefix" as const)
: ("management_api" as const),
normalizedPath: path,
},
requestId: "req_test",
};
}
test("managementPolicy: allows when auth not required (no password set)", async () => {
await settingsDb.updateSettings({ requireLogin: true, password: null });
const policy = await loadPolicy();
const out = await policy.evaluate(ctx(new Headers()));
assert.equal(out.allow, true);
if (out.allow) {
assert.equal(out.subject.kind, "anonymous");
assert.equal(out.subject.label, "auth-disabled");
}
});
test("managementPolicy: rejects 401 when auth required and no credentials", async () => {
process.env.JWT_SECRET = "test-jwt-secret-for-mgmt-policy";
process.env.INITIAL_PASSWORD = "initial-pass";
await settingsDb.updateSettings({ requireLogin: true });
const policy = await loadPolicy();
const out = await policy.evaluate(ctx(new Headers()));
assert.equal(out.allow, false);
if (!out.allow) {
assert.equal(out.status, 401);
assert.equal(out.code, "AUTH_001");
}
});
test("managementPolicy: rejects client API keys for dashboard access", async () => {
process.env.JWT_SECRET = "test-jwt-secret-for-mgmt-policy";
process.env.INITIAL_PASSWORD = "initial-pass";
await settingsDb.updateSettings({ requireLogin: true });
const created = await apiKeysDb.createApiKey("dashboard-denied", "machine-dashboard-denied");
const policy = await loadPolicy();
const out = await policy.evaluate(
ctx(new Headers({ authorization: `Bearer ${created.key}` }), "GET", "/dashboard")
);
assert.equal(out.allow, false);
if (!out.allow) {
assert.equal(out.status, 403);
assert.equal(out.code, "AUTH_001");
}
});
test("managementPolicy: allows internal model sync only on the dedicated provider routes", async () => {
process.env.JWT_SECRET = "test-jwt-secret-for-mgmt-policy";
process.env.INITIAL_PASSWORD = "initial-pass";
await settingsDb.updateSettings({ requireLogin: true });
const policy = await loadPolicy();
const internalHeaders = new Headers(modelSync.buildModelSyncInternalHeaders());
const allowed = await policy.evaluate(
ctx(internalHeaders, "POST", "/api/providers/conn-123/sync-models")
);
assert.equal(allowed.allow, true);
if (allowed.allow) {
assert.equal(allowed.subject.kind, "management_key");
assert.equal(allowed.subject.id, "model-sync");
}
const denied = await policy.evaluate(ctx(internalHeaders, "POST", "/api/keys"));
assert.equal(denied.allow, false);
});

View File

@@ -0,0 +1,141 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { SignJWT } from "jose";
import { NextRequest } from "next/server";
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omr-authz-pipeline-"));
process.env.DATA_DIR = TEST_DATA_DIR;
process.env.API_KEY_SECRET = "test-secret";
const core = await import("../../../src/lib/db/core.ts");
const apiKeysDb = await import("../../../src/lib/db/apiKeys.ts");
const settingsDb = await import("../../../src/lib/db/settings.ts");
const pipeline = await import("../../../src/server/authz/pipeline.ts");
const ORIGINAL_JWT = process.env.JWT_SECRET;
const ORIGINAL_INITIAL = process.env.INITIAL_PASSWORD;
const ORIGINAL_AUTH_COOKIE_SECURE = process.env.AUTH_COOKIE_SECURE;
function resetEnvironment() {
core.resetDbInstance();
apiKeysDb.resetApiKeyState();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
process.env.JWT_SECRET = "pipeline-jwt-secret";
process.env.INITIAL_PASSWORD = "pipeline-initial-password";
delete process.env.AUTH_COOKIE_SECURE;
globalThis.__omnirouteShutdown = { init: false, shuttingDown: false, activeRequests: 0 };
}
async function forceAuthRequired() {
await settingsDb.updateSettings({ requireLogin: true });
}
function request(url: string, init?: RequestInit): NextRequest {
return new NextRequest(url, init);
}
test.beforeEach(() => {
resetEnvironment();
});
test.after(() => {
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
if (ORIGINAL_JWT === undefined) delete process.env.JWT_SECRET;
else process.env.JWT_SECRET = ORIGINAL_JWT;
if (ORIGINAL_INITIAL === undefined) delete process.env.INITIAL_PASSWORD;
else process.env.INITIAL_PASSWORD = ORIGINAL_INITIAL;
if (ORIGINAL_AUTH_COOKIE_SECURE === undefined) delete process.env.AUTH_COOKIE_SECURE;
else process.env.AUTH_COOKIE_SECURE = ORIGINAL_AUTH_COOKIE_SECURE;
globalThis.__omnirouteShutdown = { init: false, shuttingDown: false, activeRequests: 0 };
});
test("runAuthzPipeline redirects root to dashboard before management auth", async () => {
await forceAuthRequired();
const response = await pipeline.runAuthzPipeline(request("http://localhost/"), { enforce: true });
assert.equal(response.status, 307);
assert.equal(response.headers.get("location"), "http://localhost/dashboard");
});
test("runAuthzPipeline redirects unauthenticated dashboard pages to login", async () => {
await forceAuthRequired();
const response = await pipeline.runAuthzPipeline(request("http://localhost/dashboard"), {
enforce: true,
});
assert.equal(response.status, 307);
assert.equal(response.headers.get("location"), "http://localhost/login");
assert.equal(response.headers.get("x-omniroute-route-class"), "MANAGEMENT");
assert.ok(response.headers.get("x-request-id"));
});
test("runAuthzPipeline keeps management API rejections as JSON", async () => {
await forceAuthRequired();
const response = await pipeline.runAuthzPipeline(request("http://localhost/api/keys"), {
enforce: true,
});
const body = await response.json();
assert.equal(response.status, 401);
assert.equal(response.headers.get("content-type")?.includes("application/json"), true);
assert.equal(body.error.code, "AUTH_001");
});
test("runAuthzPipeline rejects oversized API bodies before auth", async () => {
const response = await pipeline.runAuthzPipeline(
request("http://localhost/api/v1/chat/completions", {
method: "POST",
headers: {
"content-length": String(99 * 1024 * 1024),
origin: "https://app.example.com",
},
}),
{ enforce: true }
);
assert.equal(response.status, 413);
assert.equal(response.headers.get("x-omniroute-route-class"), "CLIENT_API");
assert.ok(response.headers.get("x-request-id"));
assert.equal(
response.headers.get("Access-Control-Allow-Methods"),
"GET, POST, PUT, DELETE, PATCH, OPTIONS"
);
});
test("runAuthzPipeline rejects new API requests during shutdown drain", async () => {
globalThis.__omnirouteShutdown = { init: true, shuttingDown: true, activeRequests: 0 };
const response = await pipeline.runAuthzPipeline(request("http://localhost/api/v1/models"), {
enforce: true,
});
const body = await response.json();
assert.equal(response.status, 503);
assert.equal(body.error.code, "SERVICE_UNAVAILABLE");
});
test("runAuthzPipeline refreshes dashboard JWTs near expiry", async () => {
await forceAuthRequired();
const secret = new TextEncoder().encode(process.env.JWT_SECRET);
const expiringToken = await new SignJWT({ authenticated: true })
.setProtectedHeader({ alg: "HS256" })
.setExpirationTime("1h")
.sign(secret);
const response = await pipeline.runAuthzPipeline(
request("http://localhost/dashboard", {
headers: { cookie: `auth_token=${expiringToken}` },
}),
{ enforce: true }
);
assert.equal(response.status, 200);
assert.match(response.headers.get("set-cookie") || "", /auth_token=/);
});

View File

@@ -0,0 +1,22 @@
import test from "node:test";
import assert from "node:assert/strict";
import { publicPolicy } from "../../../src/server/authz/policies/public.ts";
import type { PolicyContext } from "../../../src/server/authz/context.ts";
function ctx(): PolicyContext {
return {
request: { method: "GET", headers: new Headers() },
classification: { routeClass: "PUBLIC", reason: "public_prefix", normalizedPath: "/api/init" },
requestId: "req_test",
};
}
test("publicPolicy always allows with anonymous subject", async () => {
const out = await publicPolicy.evaluate(ctx());
assert.equal(out.allow, true);
if (out.allow) {
assert.equal(out.subject.kind, "anonymous");
assert.equal(out.subject.id, "anonymous");
}
});

View File

@@ -696,68 +696,6 @@ test("Batch processor fails orphaned finalizing batches during startup recovery"
}
});
test("Batch list route rejects missing API key when REQUIRE_API_KEY is enabled", async () => {
const previous = process.env.REQUIRE_API_KEY;
process.env.REQUIRE_API_KEY = "true";
try {
const response = await batchesRoute.GET(new Request("http://localhost/api/v1/batches"));
const json = await response.json();
assert.strictEqual(response.status, 401);
assert.strictEqual(json.error.message, "Missing API key");
} finally {
process.env.REQUIRE_API_KEY = previous ?? "false";
}
});
test("Files list route rejects invalid API key when REQUIRE_API_KEY is enabled", async () => {
const previous = process.env.REQUIRE_API_KEY;
process.env.REQUIRE_API_KEY = "true";
try {
const response = await filesRoute.GET(
new Request("http://localhost/api/v1/files", {
headers: { Authorization: "Bearer invalid-test-key" },
})
);
const json = await response.json();
assert.strictEqual(response.status, 401);
assert.strictEqual(json.error.message, "Invalid API key");
} finally {
process.env.REQUIRE_API_KEY = previous ?? "false";
}
});
test("Files upload route rejects invalid API key even when auth is optional", async () => {
const previous = process.env.REQUIRE_API_KEY;
process.env.REQUIRE_API_KEY = "false";
try {
const formData = new FormData();
formData.set("purpose", "batch");
formData.set(
"file",
new File([Buffer.from('{"ok":true}\n')], "input.jsonl", { type: "application/json" })
);
const response = await filesRoute.POST(
new Request("http://localhost/api/v1/files", {
method: "POST",
headers: { Authorization: "Bearer invalid-test-key" },
body: formData,
})
);
const json = await response.json();
assert.strictEqual(response.status, 401);
assert.strictEqual(json.error.message, "Invalid API key");
} finally {
process.env.REQUIRE_API_KEY = previous ?? "false";
}
});
test("Files upload route stores multipart content", async () => {
const fileContent = '{"custom_id":"req-1"}\n';
const formData = new FormData();
@@ -794,7 +732,7 @@ test("Files and batches routes expose explicit CORS preflight handlers", async (
assert.strictEqual(typeof route.OPTIONS, "function");
const response = await route.OPTIONS();
assert.strictEqual(response.status, 204);
assert.strictEqual(response.headers.get("Access-Control-Allow-Origin"), "*");
assert.strictEqual(response.headers.get("Access-Control-Allow-Origin"), null);
assert.match(
String(response.headers.get("Access-Control-Allow-Headers") || ""),
/Authorization/i

View File

@@ -49,7 +49,7 @@ test.after(async () => {
test("handleChat waits for a short cooldown and retries once within the configured budget", async () => {
await seedConnection("openai", {
apiKey: "sk-openai-cooldown-short",
rateLimitedUntil: new Date(Date.now() + 350).toISOString(),
rateLimitedUntil: new Date(Date.now() + 950).toISOString(),
lastError: "short cooldown window",
errorCode: 429,
});

View File

@@ -163,33 +163,6 @@ test("handleChat treats Accept text/event-stream as stream=true and returns a se
assert.match(raw, /\[DONE\]/);
});
test("handleChat enforces strict API key mode for missing and invalid keys", async () => {
process.env.REQUIRE_API_KEY = "true";
const missing = await handleChat(
buildRequest({
body: {
model: "openai/gpt-4o-mini",
stream: false,
messages: [{ role: "user", content: "missing auth" }],
},
})
);
const invalid = await handleChat(
buildRequest({
authKey: "sk-does-not-exist",
body: {
model: "openai/gpt-4o-mini",
stream: false,
messages: [{ role: "user", content: "invalid auth" }],
},
})
);
assert.equal(missing.status, 401);
assert.equal(invalid.status, 401);
});
test("handleChat rejects requests without a model", async () => {
const response = await handleChat(
buildRequest({

View File

@@ -0,0 +1,154 @@
import { describe, it, beforeEach, afterEach } from "node:test";
import assert from "node:assert/strict";
import { NextResponse } from "next/server";
import {
applyCorsHeaders,
resolveAllowedOrigin,
setRuntimeAllowedOrigins,
STATIC_CORS_HEADERS,
} from "../../../src/server/cors/origins";
const ENV_KEYS = ["CORS_ALLOW_ALL", "CORS_ALLOWED_ORIGINS", "CORS_ORIGIN"] as const;
function snapshotEnv(): Record<string, string | undefined> {
const snap: Record<string, string | undefined> = {};
for (const key of ENV_KEYS) snap[key] = process.env[key];
return snap;
}
function restoreEnv(snap: Record<string, string | undefined>) {
for (const key of ENV_KEYS) {
if (snap[key] === undefined) delete process.env[key];
else process.env[key] = snap[key];
}
}
describe("cors/origins.resolveAllowedOrigin", () => {
let envSnap: Record<string, string | undefined>;
beforeEach(() => {
envSnap = snapshotEnv();
for (const key of ENV_KEYS) delete process.env[key];
setRuntimeAllowedOrigins("");
});
afterEach(() => {
restoreEnv(envSnap);
setRuntimeAllowedOrigins("");
});
it("returns null by default — fail-closed without explicit allowlist", () => {
assert.equal(resolveAllowedOrigin("https://app.example.com"), null);
});
it("returns null when origin header is absent", () => {
process.env.CORS_ALLOWED_ORIGINS = "https://app.example.com";
assert.equal(resolveAllowedOrigin(null), null);
assert.equal(resolveAllowedOrigin(undefined), null);
});
it("echoes origin when env allowlist matches (case-insensitive, trailing slash ignored)", () => {
process.env.CORS_ALLOWED_ORIGINS = "https://app.example.com, https://Admin.Example.com/";
assert.equal(resolveAllowedOrigin("https://app.example.com"), "https://app.example.com");
assert.equal(resolveAllowedOrigin("https://admin.example.com"), "https://admin.example.com");
assert.equal(resolveAllowedOrigin("https://other.example.com"), null);
});
it("merges env allowlist and runtime allowlist", () => {
process.env.CORS_ALLOWED_ORIGINS = "https://env.example.com";
setRuntimeAllowedOrigins("https://runtime.example.com");
assert.equal(resolveAllowedOrigin("https://env.example.com"), "https://env.example.com");
assert.equal(
resolveAllowedOrigin("https://runtime.example.com"),
"https://runtime.example.com"
);
assert.equal(resolveAllowedOrigin("https://other.example.com"), null);
});
it("CORS_ALLOW_ALL=true echoes any origin (with Vary applied later)", () => {
process.env.CORS_ALLOW_ALL = "true";
assert.equal(
resolveAllowedOrigin("https://anything.example.com"),
"https://anything.example.com"
);
});
it("CORS_ALLOW_ALL falls back to '*' when no Origin header is present", () => {
process.env.CORS_ALLOW_ALL = "1";
assert.equal(resolveAllowedOrigin(null), "*");
});
it("legacy CORS_ORIGIN=* behaves like CORS_ALLOW_ALL", () => {
process.env.CORS_ORIGIN = "*";
assert.equal(
resolveAllowedOrigin("https://anything.example.com"),
"https://anything.example.com"
);
});
it("legacy CORS_ORIGIN=<single> is added to env allowlist", () => {
process.env.CORS_ORIGIN = "https://legacy.example.com";
assert.equal(resolveAllowedOrigin("https://legacy.example.com"), "https://legacy.example.com");
assert.equal(resolveAllowedOrigin("https://other.example.com"), null);
});
});
describe("cors/origins.applyCorsHeaders", () => {
let envSnap: Record<string, string | undefined>;
beforeEach(() => {
envSnap = snapshotEnv();
for (const key of ENV_KEYS) delete process.env[key];
setRuntimeAllowedOrigins("");
});
afterEach(() => {
restoreEnv(envSnap);
setRuntimeAllowedOrigins("");
});
it("does not set Allow-Origin when origin is not in allowlist", () => {
const res = NextResponse.json({ ok: true });
const req = new Request("https://server.example.com/api/v1/chat/completions", {
headers: { Origin: "https://evil.example.com" },
});
applyCorsHeaders(res, req);
assert.equal(res.headers.get("Access-Control-Allow-Origin"), null);
assert.match(res.headers.get("Access-Control-Allow-Methods") || "", /OPTIONS/);
});
it("echoes origin and sets Vary: Origin when origin is allowed", () => {
process.env.CORS_ALLOWED_ORIGINS = "https://app.example.com";
const res = NextResponse.json({ ok: true });
const req = new Request("https://server.example.com/api/v1/chat/completions", {
headers: { Origin: "https://app.example.com" },
});
applyCorsHeaders(res, req);
assert.equal(res.headers.get("Access-Control-Allow-Origin"), "https://app.example.com");
assert.match(res.headers.get("Vary") || "", /Origin/);
});
it("reflects requested headers from Access-Control-Request-Headers preflight", () => {
process.env.CORS_ALLOWED_ORIGINS = "https://app.example.com";
const res = NextResponse.json({ ok: true });
const req = new Request("https://server.example.com/api/v1/chat/completions", {
method: "OPTIONS",
headers: {
Origin: "https://app.example.com",
"Access-Control-Request-Headers": "x-custom-header, authorization",
},
});
applyCorsHeaders(res, req);
assert.equal(res.headers.get("Access-Control-Allow-Headers"), "x-custom-header, authorization");
});
});
describe("cors/origins.STATIC_CORS_HEADERS", () => {
it("never contains Access-Control-Allow-Origin", () => {
assert.equal(
Object.prototype.hasOwnProperty.call(STATIC_CORS_HEADERS, "Access-Control-Allow-Origin"),
false
);
assert.match(STATIC_CORS_HEADERS["Access-Control-Allow-Methods"], /OPTIONS/);
});
});

View File

@@ -203,6 +203,90 @@ test("runMigrations skips versions that are already tracked as applied", serial,
}
});
test(
"runMigrations applies api key lifecycle migration idempotently when columns already exist",
serial,
async () => {
const runner = await importFresh("src/lib/db/migrationRunner.ts");
const db = createDb();
try {
db.exec(`
CREATE TABLE api_keys (
id TEXT PRIMARY KEY,
key TEXT NOT NULL,
revoked_at TEXT
);
`);
const appliedCount = withMockedMigrationFs(
{
"032_apikey_lifecycle.sql": "ALTER TABLE api_keys ADD COLUMN revoked_at TEXT;",
},
() => runner.runMigrations(db)
);
assert.equal(appliedCount, 1);
const columns = db.prepare("PRAGMA table_info(api_keys)").all() as Array<{ name: string }>;
const names = new Set(columns.map((column) => column.name));
for (const expected of [
"revoked_at",
"expires_at",
"last_used_at",
"key_prefix",
"ip_allowlist",
"scopes",
]) {
assert.equal(names.has(expected), true, `${expected} should exist`);
}
assert.deepEqual(
db.prepare("SELECT version, name FROM _omniroute_migrations WHERE version = ?").get("032"),
{ version: "032", name: "apikey_lifecycle" }
);
} finally {
db.close();
}
}
);
test(
"runMigrations applies api key lifecycle hardening by version even if filename suffix changes",
serial,
async () => {
const runner = await importFresh("src/lib/db/migrationRunner.ts");
const db = createDb();
try {
db.exec(`
CREATE TABLE api_keys (
id TEXT PRIMARY KEY,
key TEXT NOT NULL
);
`);
const appliedCount = withMockedMigrationFs(
{
"032_renamed_lifecycle_patch.sql": "ALTER TABLE api_keys ADD COLUMN should_not_run TEXT;",
},
() => runner.runMigrations(db)
);
assert.equal(appliedCount, 1);
const columns = db.prepare("PRAGMA table_info(api_keys)").all() as Array<{ name: string }>;
const names = new Set(columns.map((column) => column.name));
assert.equal(names.has("revoked_at"), true);
assert.equal(names.has("expires_at"), true);
assert.equal(names.has("should_not_run"), false);
assert.deepEqual(
db.prepare("SELECT version, name FROM _omniroute_migrations WHERE version = ?").get("032"),
{ version: "032", name: "renamed_lifecycle_patch" }
);
} finally {
db.close();
}
}
);
test("getMigrationStatus reports applied and pending migrations", serial, async () => {
const runner = await importFresh("src/lib/db/migrationRunner.ts");
const db = createDb();

View File

@@ -171,14 +171,6 @@ test("initCloudSync skips auto initialization during build and test processes un
);
});
test("proxy: internal model sync token is only allowed for provider model sync routes", () => {
const filePath = path.join(process.cwd(), "src/proxy.ts");
const source = fs.readFileSync(filePath, "utf8");
assert.match(source, /isModelSyncInternalRequest/);
assert.match(source, /sync-models\|models/);
});
test("modelSyncScheduler starts once, honors env interval and syncs only active autoSync connections", async () => {
await providersDb.createProviderConnection({
provider: "openai",

View File

@@ -70,7 +70,8 @@ test("handleModeration proxies successful requests with default model and access
input: "all clear",
});
assert.equal(response.status, 200);
assert.equal(response.headers.get("access-control-allow-origin"), "*");
assert.equal(response.headers.get("access-control-allow-origin"), null);
assert.match(response.headers.get("access-control-allow-methods") || "", /OPTIONS/);
assert.deepEqual(await response.json(), {
id: "modr-1",
results: [{ flagged: false }],
@@ -92,7 +93,8 @@ test("handleModeration returns upstream error payloads with CORS headers", async
assert.equal(response.status, 429);
assert.equal(await response.text(), '{"error":"busy"}');
assert.equal(response.headers.get("content-type"), "application/json");
assert.equal(response.headers.get("access-control-allow-origin"), "*");
assert.equal(response.headers.get("access-control-allow-origin"), null);
assert.match(response.headers.get("access-control-allow-methods") || "", /OPTIONS/);
});
test("handleModeration returns a 500 when the upstream request throws", async () => {

View File

@@ -1,74 +0,0 @@
import test from "node:test";
import assert from "node:assert/strict";
import path from "node:path";
import { pathToFileURL } from "node:url";
const originalEnv = {
NEXT_PUBLIC_OMNIROUTE_E2E_MODE: process.env.NEXT_PUBLIC_OMNIROUTE_E2E_MODE,
};
function restoreEnv() {
for (const [key, value] of Object.entries(originalEnv)) {
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
}
async function importFresh(modulePath) {
const url = pathToFileURL(path.resolve(modulePath)).href;
return import(`${url}?test=${Date.now()}-${Math.random().toString(16).slice(2)}`);
}
function makeRequest(pathname) {
return {
nextUrl: {
pathname,
protocol: "http:",
},
method: "GET",
cookies: {
get() {
return undefined;
},
},
headers: new Headers(),
url: `http://localhost:20128${pathname}`,
};
}
test.beforeEach(() => {
restoreEnv();
});
test.afterEach(() => {
restoreEnv();
});
test.after(() => {
restoreEnv();
});
test("proxy bypasses dashboard auth in Playwright e2e mode", async () => {
process.env.NEXT_PUBLIC_OMNIROUTE_E2E_MODE = "1";
const { proxy } = await importFresh("src/proxy.ts");
const response = await proxy(makeRequest("/dashboard/combos"));
assert.equal(response.status, 200);
assert.equal(response.headers.get("location"), null);
assert.ok(response.headers.get("x-request-id"));
});
test("proxy bypasses management API auth in Playwright e2e mode", async () => {
process.env.NEXT_PUBLIC_OMNIROUTE_E2E_MODE = "1";
const { proxy } = await importFresh("src/proxy.ts");
const response = await proxy(makeRequest("/api/combos"));
assert.equal(response.status, 200);
assert.equal(response.headers.get("location"), null);
assert.ok(response.headers.get("x-request-id"));
});

View File

@@ -1,111 +0,0 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-proxy-bootstrap-"));
process.env.DATA_DIR = TEST_DATA_DIR;
const core = await import("../../src/lib/db/core.ts");
const settingsDb = await import("../../src/lib/db/settings.ts");
async function importFreshProxy() {
const url = pathToFileURL(path.resolve("src/proxy.ts")).href;
return import(`${url}?test=${Date.now()}-${Math.random().toString(16).slice(2)}`);
}
async function resetStorage() {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
}
function makeRequest(pathname, method = "GET", headers) {
return {
nextUrl: {
pathname,
protocol: "http:",
},
method,
cookies: {
get() {
return undefined;
},
},
headers: new Headers(headers),
url: `http://localhost:20128${pathname}`,
};
}
test.beforeEach(async () => {
delete process.env.NEXT_PUBLIC_OMNIROUTE_E2E_MODE;
delete process.env.INITIAL_PASSWORD;
await resetStorage();
});
test.after(() => {
delete process.env.NEXT_PUBLIC_OMNIROUTE_E2E_MODE;
delete process.env.INITIAL_PASSWORD;
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
test("proxy keeps GET /api/settings/require-login public after setup", async () => {
await settingsDb.updateSettings({
requireLogin: true,
password: "hashed-password",
setupComplete: true,
});
const { proxy } = await importFreshProxy();
const response = await proxy(makeRequest("/api/settings/require-login"));
assert.equal(response.status, 200);
assert.equal(response.headers.get("location"), null);
assert.ok(response.headers.get("x-request-id"));
});
test("proxy requires auth for POST /api/settings/require-login after setup", async () => {
await settingsDb.updateSettings({
requireLogin: true,
password: "hashed-password",
setupComplete: true,
});
const { proxy } = await importFreshProxy();
const response = await proxy(makeRequest("/api/settings/require-login", "POST"));
const body = (await response.json()) as any;
assert.equal(response.status, 401);
assert.equal(body.error.code, "AUTH_001");
});
test("proxy rejects bearer tokens for POST /api/settings/require-login after setup", async () => {
await settingsDb.updateSettings({
requireLogin: true,
password: "hashed-password",
setupComplete: true,
});
const { proxy } = await importFreshProxy();
const response = await proxy(
makeRequest("/api/settings/require-login", "POST", {
authorization: "Bearer sk-invalid",
})
);
const body = (await response.json()) as any;
assert.equal(response.status, 403);
assert.equal(body.error.code, "AUTH_001");
});
test("proxy still allows POST /api/settings/require-login during bootstrap", async () => {
const { proxy } = await importFreshProxy();
const response = await proxy(makeRequest("/api/settings/require-login", "POST"));
assert.equal(response.status, 200);
assert.equal(response.headers.get("location"), null);
assert.ok(response.headers.get("x-request-id"));
});

View File

@@ -0,0 +1,51 @@
import assert from "node:assert/strict";
import test from "node:test";
import { fetchRemoteImage } from "@/shared/network/remoteImageFetch";
test("fetchRemoteImage reads public image bytes", async () => {
const result = await fetchRemoteImage("https://cdn.example.com/image.png", {
fetchImpl: async () =>
new Response(new Uint8Array([1, 2, 3]), {
status: 200,
headers: { "content-type": "image/png" },
}),
guard: "public-only",
});
assert.equal(result.buffer.toString("base64"), "AQID");
assert.equal(result.contentType, "image/png");
});
test("fetchRemoteImage blocks private image hosts before fetch", async () => {
let called = false;
await assert.rejects(
() =>
fetchRemoteImage("http://127.0.0.1:20128/private.png", {
fetchImpl: async () => {
called = true;
return new Response("unexpected");
},
guard: "public-only",
}),
/Blocked private or local provider URL/
);
assert.equal(called, false);
});
test("fetchRemoteImage blocks redirects to private image hosts", async () => {
await assert.rejects(
() =>
fetchRemoteImage("https://cdn.example.com/redirect.png", {
fetchImpl: async () =>
new Response(null, {
status: 302,
headers: { location: "http://169.254.169.254/latest/meta-data" },
}),
guard: "public-only",
}),
/Blocked private or local provider URL/
);
});

View File

@@ -660,7 +660,8 @@ test("embeddings route covers options, custom-model listing and defensive POST b
const validationFailureBody = (await validationFailure.json()) as any;
const invalidModelBody = (await invalidModel.json()) as any;
assert.equal(optionsHeaders["access-control-allow-origin"], "*");
assert.equal(optionsHeaders["access-control-allow-origin"], undefined);
assert.match(optionsHeaders["access-control-allow-methods"] || "", /OPTIONS/);
assert.equal(getResponse.status, 200);
assert.equal(
getBody.data.some((model) => model.id === "custom-embedder/text-embed-1"),
@@ -677,27 +678,7 @@ test("embeddings route covers options, custom-model listing and defensive POST b
);
});
test("embeddings route enforces caller auth, missing credentials and provider rate limits", async () => {
process.env.REQUIRE_API_KEY = "true";
const missingKey = await embeddingsRoute.POST(
makeRequest("http://localhost/v1/embeddings", {
method: "POST",
body: { model: "openai/text-embedding-3-small", input: "hello" },
})
);
const invalidKey = await embeddingsRoute.POST(
new Request("http://localhost/v1/embeddings", {
method: "POST",
headers: {
"content-type": "application/json",
authorization: "Bearer sk-invalid",
},
body: JSON.stringify({ model: "openai/text-embedding-3-small", input: "hello" }),
})
);
test("embeddings route surfaces missing-credentials and provider-rate-limit errors", async () => {
const validApiKey = await apiKeysDb.createApiKey("caller", MACHINE_ID);
const missingCredentials = await embeddingsRoute.POST(
new Request("http://localhost/v1/embeddings", {
@@ -726,15 +707,9 @@ test("embeddings route enforces caller auth, missing credentials and provider ra
})
);
const missingKeyBody = (await missingKey.json()) as any;
const invalidKeyBody = (await invalidKey.json()) as any;
const missingCredentialsBody = (await missingCredentials.json()) as any;
const allRateLimitedBody = (await allRateLimited.json()) as any;
assert.equal(missingKey.status, 401);
assert.equal(missingKeyBody.error.message, "Missing API key");
assert.equal(invalidKey.status, 401);
assert.equal(invalidKeyBody.error.message, "Invalid API key");
assert.equal(missingCredentials.status, 400);
assert.match(missingCredentialsBody.error.message, /No credentials for embedding provider/);
assert.equal(allRateLimited.status, 429);

View File

@@ -14,7 +14,7 @@ test("T20: antigravity config has updated User-Agent and sandbox fallback URL",
assert.equal(antigravity.headers["User-Agent"], antigravityUserAgent());
});
test("T20: gemini CLI fingerprint uses 0.31.0 and preserves darwin platform name", () => {
test("T20: gemini CLI fingerprint uses 0.31.0 and normalizes darwin to macos", () => {
assert.equal(GEMINI_CLI_VERSION, "0.31.0");
const descriptor = Object.getOwnPropertyDescriptor(process, "platform");
@@ -22,7 +22,7 @@ test("T20: gemini CLI fingerprint uses 0.31.0 and preserves darwin platform name
try {
assert.match(
geminiCLIUserAgent("gemini-3-flash"),
/^GeminiCLI\/0\.31\.0\/gemini-3-flash \(darwin; /
/^GeminiCLI\/0\.31\.0\/gemini-3-flash \(macos; /
);
} finally {
if (descriptor) {