mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
Compare commits
2 Commits
647afe327b
...
release/v3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dbdfb8a328 | ||
|
|
5026aaaabb |
@@ -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
|
||||
|
||||
5
.github/workflows/electron-release.yml
vendored
5
.github/workflows/electron-release.yml
vendored
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -42,6 +42,8 @@ function getPlatform(): string {
|
||||
switch (p) {
|
||||
case "win32":
|
||||
return "windows";
|
||||
case "darwin":
|
||||
return "macos";
|
||||
default:
|
||||
return p; // "linux", etc.
|
||||
}
|
||||
|
||||
@@ -22,6 +22,13 @@ let exitHookInstalled = false;
|
||||
|
||||
const CHATGPT_PROFILE = "firefox_148"; // matches the Firefox 150 UA we send
|
||||
const DEFAULT_TIMEOUT_MS = 60_000;
|
||||
// Grace period added to the binding's wire-level timeout before our JS-level
|
||||
// hard timeout fires. Under healthy operation `tls-client-node` honors
|
||||
// `timeoutMilliseconds` and rejects on its own; the JS-level race only wins
|
||||
// when the koffi-loaded native library is wedged (which the binding's own
|
||||
// timer can't escape). Keep the grace small so users don't wait noticeably
|
||||
// longer than the configured timeout when the binding is dead.
|
||||
const HARD_TIMEOUT_GRACE_MS = 10_000;
|
||||
|
||||
function installExitHook(): void {
|
||||
if (exitHookInstalled) return;
|
||||
@@ -44,6 +51,69 @@ function installExitHook(): void {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the cached client so the next `getClient()` call respawns it. Called
|
||||
* when a request observes the native binding has wedged — releasing the
|
||||
* reference lets a fresh TLSClient (and a fresh koffi load) take over without
|
||||
* a process restart.
|
||||
*/
|
||||
function resetClientCache(): void {
|
||||
clientPromise = null;
|
||||
}
|
||||
|
||||
export class TlsClientHangError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "TlsClientHangError";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Race a `client.request()` promise against (a) a JS-level hard timeout and
|
||||
* (b) the caller's abort signal. The native binding's `timeoutMilliseconds`
|
||||
* already covers the wire path; this guards the case where the koffi binding
|
||||
* itself deadlocks (observed after sustained load), where neither the
|
||||
* binding's own timer nor a post-call `signal.aborted` re-check can recover.
|
||||
*/
|
||||
async function raceWithTimeout<T>(
|
||||
promise: Promise<T>,
|
||||
timeoutMs: number,
|
||||
signal: AbortSignal | null | undefined
|
||||
): Promise<T> {
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
let abortListener: (() => void) | null = null;
|
||||
try {
|
||||
const racers: Promise<T>[] = [
|
||||
promise,
|
||||
new Promise<T>((_, reject) => {
|
||||
timer = setTimeout(() => {
|
||||
reject(
|
||||
new TlsClientHangError(
|
||||
`tls-client-node call exceeded ${timeoutMs}ms — native binding likely deadlocked`
|
||||
)
|
||||
);
|
||||
}, timeoutMs);
|
||||
}),
|
||||
];
|
||||
if (signal) {
|
||||
racers.push(
|
||||
new Promise<T>((_, reject) => {
|
||||
if (signal.aborted) {
|
||||
reject(makeAbortError(signal));
|
||||
return;
|
||||
}
|
||||
abortListener = () => reject(makeAbortError(signal));
|
||||
signal.addEventListener("abort", abortListener, { once: true });
|
||||
})
|
||||
);
|
||||
}
|
||||
return await Promise.race(racers);
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
if (signal && abortListener) signal.removeEventListener("abort", abortListener);
|
||||
}
|
||||
}
|
||||
|
||||
async function getClient(): Promise<{
|
||||
request: (url: string, opts: Record<string, unknown>) => Promise<TlsResponseLike>;
|
||||
}> {
|
||||
@@ -178,11 +248,26 @@ export async function tlsFetchChatGpt(
|
||||
url,
|
||||
requestOptions,
|
||||
options.streamEofSymbol,
|
||||
options.signal ?? null
|
||||
options.signal ?? null,
|
||||
(options.timeoutMs ?? DEFAULT_TIMEOUT_MS) + HARD_TIMEOUT_GRACE_MS
|
||||
);
|
||||
}
|
||||
|
||||
const tlsResponse = await client.request(url, requestOptions);
|
||||
let tlsResponse: TlsResponseLike;
|
||||
try {
|
||||
tlsResponse = await raceWithTimeout(
|
||||
client.request(url, requestOptions),
|
||||
(options.timeoutMs ?? DEFAULT_TIMEOUT_MS) + HARD_TIMEOUT_GRACE_MS,
|
||||
options.signal ?? null
|
||||
);
|
||||
} catch (err) {
|
||||
if (err instanceof TlsClientHangError) {
|
||||
// The native binding is wedged — drop the singleton so the next
|
||||
// request respawns a fresh client (and a fresh koffi load).
|
||||
resetClientCache();
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
if (options.signal?.aborted) {
|
||||
throw makeAbortError(options.signal);
|
||||
}
|
||||
@@ -220,7 +305,8 @@ async function tlsFetchStreaming(
|
||||
url: string,
|
||||
requestOptions: Record<string, unknown>,
|
||||
eofSymbol = "[DONE]",
|
||||
signal: AbortSignal | null = null
|
||||
signal: AbortSignal | null = null,
|
||||
hardTimeoutMs: number = DEFAULT_TIMEOUT_MS + HARD_TIMEOUT_GRACE_MS
|
||||
): Promise<TlsFetchResult> {
|
||||
const dir = await mkdtemp(join(tmpdir(), "cgpt-stream-"));
|
||||
const path = join(dir, `${randomUUID()}.sse`);
|
||||
@@ -234,8 +320,24 @@ async function tlsFetchStreaming(
|
||||
|
||||
// Kick off the request without awaiting — tls-client writes the body to
|
||||
// `path` chunk-by-chunk while the call runs. The Promise resolves when the
|
||||
// request fully completes (full body written).
|
||||
const requestPromise = client.request(url, streamOpts);
|
||||
// request fully completes (full body written). Wrapping in raceWithTimeout
|
||||
// guarantees this promise eventually settles even if the koffi binding
|
||||
// wedges; on hang we reset the singleton so the next request respawns.
|
||||
let resetOnHang = true;
|
||||
const requestPromise = raceWithTimeout(
|
||||
client.request(url, streamOpts),
|
||||
hardTimeoutMs,
|
||||
signal
|
||||
).catch((err: unknown) => {
|
||||
if (resetOnHang && err instanceof TlsClientHangError) {
|
||||
resetClientCache();
|
||||
resetOnHang = false;
|
||||
}
|
||||
// Re-throw so downstream consumers (waitForContent, tailFile) observe
|
||||
// the rejection and surface it instead of treating the stream as having
|
||||
// ended cleanly.
|
||||
throw err;
|
||||
});
|
||||
|
||||
// Wait for the file to exist AND have at least one byte. tls-client-node
|
||||
// creates the output file when the request starts, but the file can be
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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": "*",
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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": "*",
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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": "*",
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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": "*",
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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": "*",
|
||||
},
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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": "*",
|
||||
},
|
||||
|
||||
@@ -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": "*",
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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": "*",
|
||||
},
|
||||
|
||||
@@ -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": "*",
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
24
src/lib/db/migrations/032_apikey_lifecycle.sql
Normal file
24
src/lib/db/migrations/032_apikey_lifecycle.sql
Normal 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);
|
||||
1
src/lib/env/runtimeEnv.ts
vendored
1
src/lib/env/runtimeEnv.ts
vendored
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
225
src/proxy.ts
225
src/proxy.ts
@@ -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",
|
||||
],
|
||||
};
|
||||
|
||||
106
src/server/auth/loginGuard.ts
Normal file
106
src/server/auth/loginGuard.ts
Normal 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,
|
||||
});
|
||||
88
src/server/authz/assertAuth.ts
Normal file
88
src/server/authz/assertAuth.ts
Normal 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;
|
||||
}
|
||||
122
src/server/authz/classify.ts
Normal file
122
src/server/authz/classify.ts
Normal 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";
|
||||
}
|
||||
42
src/server/authz/context.ts
Normal file
42
src/server/authz/context.ts
Normal 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 };
|
||||
}
|
||||
34
src/server/authz/headers.ts
Normal file
34
src/server/authz/headers.ts
Normal 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,
|
||||
];
|
||||
243
src/server/authz/pipeline.ts
Normal file
243
src/server/authz/pipeline.ts
Normal 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;
|
||||
}
|
||||
33
src/server/authz/policies/clientApi.ts
Normal file
33
src/server/authz/policies/clientApi.ts
Normal 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) });
|
||||
},
|
||||
};
|
||||
40
src/server/authz/policies/management.ts
Normal file
40
src/server/authz/policies/management.ts
Normal 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"
|
||||
);
|
||||
},
|
||||
};
|
||||
9
src/server/authz/policies/public.ts
Normal file
9
src/server/authz/policies/public.ts
Normal 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
74
src/server/authz/types.ts
Normal 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
124
src/server/cors/origins.ts
Normal 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,
|
||||
});
|
||||
@@ -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 || "*",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
118
src/shared/network/remoteImageFetch.ts
Normal file
118
src/shared/network/remoteImageFetch.ts
Normal 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`);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
1
src/types/global.d.ts
vendored
1
src/types/global.d.ts
vendored
@@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
119
tests/unit/api-key-lifecycle.test.ts
Normal file
119
tests/unit/api-key-lifecycle.test.ts
Normal 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");
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
65
tests/unit/auth/loginGuard.test.ts
Normal file
65
tests/unit/auth/loginGuard.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
190
tests/unit/authz/classify.test.ts
Normal file
190
tests/unit/authz/classify.test.ts
Normal 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");
|
||||
});
|
||||
112
tests/unit/authz/client-api-policy.test.ts
Normal file
112
tests/unit/authz/client-api-policy.test.ts
Normal 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");
|
||||
}
|
||||
});
|
||||
121
tests/unit/authz/management-policy.test.ts
Normal file
121
tests/unit/authz/management-policy.test.ts
Normal 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);
|
||||
});
|
||||
141
tests/unit/authz/pipeline.test.ts
Normal file
141
tests/unit/authz/pipeline.test.ts
Normal 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=/);
|
||||
});
|
||||
22
tests/unit/authz/public-policy.test.ts
Normal file
22
tests/unit/authz/public-policy.test.ts
Normal 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");
|
||||
}
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
154
tests/unit/cors/origins.test.ts
Normal file
154
tests/unit/cors/origins.test.ts
Normal 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/);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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"));
|
||||
});
|
||||
@@ -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"));
|
||||
});
|
||||
51
tests/unit/remote-image-fetch.test.ts
Normal file
51
tests/unit/remote-image-fetch.test.ts
Normal 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/
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user