fix(playground): guard ChatPlayground filteredModels for non-string ids

Same root cause as commit 49fe356b9: ChatPlayground filtered models
with m.id.startsWith(...) which crashed on null/undefined ids returned
by /v1/models (synthetic combo entries). Apply the same defensive guard
and dedupe used in the parent page.
This commit is contained in:
diegosouzapw 2026-05-20 00:43:15 -03:00
parent 49fe356b91
commit 3ff3e3dd15
22 changed files with 328 additions and 274 deletions

View file

@ -4,9 +4,9 @@ rules:
- pattern: new Database(...)
paths:
include:
- "bin/**"
- "/bin/**"
exclude:
- "bin/cli/sqlite.mjs"
- "/bin/cli/sqlite.mjs"
message: >
Direct SQLite access in bin/ is banned. Use src/lib/db/* helpers or
withRuntime() from bin/cli/runtime.mjs. See CLAUDE.md hard rule #5 and
@ -21,9 +21,9 @@ rules:
- pattern: $DB.prepare("UPDATE $TABLE SET ...")
paths:
include:
- "bin/**"
- "/bin/**"
exclude:
- "bin/cli/sqlite.mjs"
- "/bin/cli/sqlite.mjs"
message: >
Raw SQL in bin/ is banned. Use src/lib/db/* helpers. See CLAUDE.md
hard rule #5 and bin/cli/CONVENTIONS.md.

View file

@ -4,6 +4,7 @@ import {
mkdirSync,
readdirSync,
readFileSync,
unlinkSync,
writeFileSync,
} from "node:fs";
import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from "node:crypto";
@ -11,6 +12,7 @@ import { dirname, join, extname, basename } from "node:path";
import { resolveDataDir } from "../data-dir.mjs";
import { apiFetch, isServerUp } from "../api.mjs";
import { t } from "../i18n.mjs";
import { backupSqliteFile } from "../sqlite.mjs";
function getBackupDir() {
return join(resolveDataDir(), "backups");
@ -187,13 +189,6 @@ export async function runBackupCommand(opts = {}) {
try {
if (!existsSync(backupDir)) mkdirSync(backupDir, { recursive: true });
let Database;
try {
Database = (await import("better-sqlite3")).default;
} catch {
Database = null;
}
let backedUp = 0;
let skipped = 0;
@ -207,14 +202,11 @@ export async function runBackupCommand(opts = {}) {
const destName = opts.encrypt ? `${file.name}.enc` : file.name;
const destPath = join(backupPath, destName);
mkdirSync(dirname(destPath), { recursive: true });
if (file.name.endsWith(".sqlite") && Database) {
const db = new Database(sourcePath, { readonly: true });
if (file.name.endsWith(".sqlite")) {
const tmpPath = destPath.replace(/\.enc$/, "");
await db.backup(tmpPath);
db.close();
await backupSqliteFile(sourcePath, tmpPath);
if (opts.encrypt) {
encryptFile(tmpPath, destPath, passphrase);
const { unlinkSync } = await import("node:fs");
unlinkSync(tmpPath);
}
} else if (opts.encrypt) {

View file

@ -7,6 +7,7 @@ import { pathToFileURL } from "node:url";
import { resolveDataDir, resolveStoragePath } from "../data-dir.mjs";
import { printHeading } from "../io.mjs";
import { t } from "../i18n.mjs";
import { readDatabaseHealth, readEncryptedCredentialSamples } from "../sqlite.mjs";
const STATIC_SALT = "omniroute-field-encryption-v1";
const KEY_LENGTH = 32;
@ -78,14 +79,6 @@ function checkConfig(dataDir) {
return ok("Config", `.env found at ${envFile}`, { envFile });
}
async function loadBetterSqlite() {
try {
return (await import("better-sqlite3")).default;
} catch (error) {
return { error };
}
}
function resolveMigrationsDir(rootDir) {
const configured = process.env.OMNIROUTE_MIGRATIONS_DIR;
const candidates = [
@ -115,18 +108,9 @@ async function checkDatabase(dbPath, rootDir) {
return warn("Database", `SQLite database not found at ${dbPath}`, { dbPath });
}
const Database = await loadBetterSqlite();
if (Database.error) {
return fail("Database", "better-sqlite3 could not be loaded", {
error: Database.error instanceof Error ? Database.error.message : String(Database.error),
});
}
let db;
try {
db = new Database(dbPath, { readonly: true, fileMustExist: true });
const quickCheck = db.prepare("PRAGMA quick_check").get();
const quickCheckValue = Object.values(quickCheck || {})[0];
const { quickCheckValue, hasMigrationTable, appliedMigrationVersions } =
await readDatabaseHealth(dbPath);
if (quickCheckValue !== "ok") {
return fail("Database", `SQLite quick_check failed: ${quickCheckValue}`, { dbPath });
}
@ -137,18 +121,11 @@ async function checkDatabase(dbPath, rootDir) {
return ok("Database", "SQLite quick_check passed", { dbPath, migrations: "not_checked" });
}
const table = db
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
.get("_omniroute_migrations");
if (!table) {
if (!hasMigrationTable) {
return warn("Database", "SQLite is readable, but migration table is missing", { dbPath });
}
const appliedRows = db
.prepare("SELECT version FROM _omniroute_migrations")
.all()
.map((row) => row.version);
const applied = new Set(appliedRows);
const applied = new Set(appliedMigrationVersions);
const pending = migrationFiles.filter((migration) => !applied.has(migration.version));
if (pending.length > 0) {
@ -164,8 +141,6 @@ async function checkDatabase(dbPath, rootDir) {
dbPath,
error: error instanceof Error ? error.message : String(error),
});
} finally {
if (db) db.close();
}
}
@ -203,42 +178,14 @@ async function checkStorageEncryption(dbPath) {
: warn("Storage/encryption", "No STORAGE_ENCRYPTION_KEY configured; passthrough mode");
}
const Database = await loadBetterSqlite();
if (Database.error) {
return fail("Storage/encryption", "Could not inspect encrypted credentials", {
error: Database.error instanceof Error ? Database.error.message : String(Database.error),
});
}
let db;
try {
db = new Database(dbPath, { readonly: true, fileMustExist: true });
const hasProviderTable = db
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
.get("provider_connections");
const { hasProviderTable, encryptedValues } = await readEncryptedCredentialSamples(dbPath);
if (!hasProviderTable) {
return secret
? ok("Storage/encryption", "Encryption key is configured; provider table not initialized")
: warn("Storage/encryption", "No STORAGE_ENCRYPTION_KEY configured; passthrough mode");
}
const rows = db
.prepare(
`SELECT api_key, access_token, refresh_token, id_token
FROM provider_connections
WHERE api_key LIKE 'enc:v1:%'
OR access_token LIKE 'enc:v1:%'
OR refresh_token LIKE 'enc:v1:%'
OR id_token LIKE 'enc:v1:%'
LIMIT 20`
)
.all();
const encryptedValues = rows.flatMap((row) =>
["api_key", "access_token", "refresh_token", "id_token"]
.filter((key) => typeof row[key] === "string" && row[key].startsWith("enc:v1:"))
.map((key) => row[key])
);
if (encryptedValues.length === 0) {
return secret
? ok("Storage/encryption", "Encryption key is configured; no encrypted samples found")
@ -268,8 +215,6 @@ async function checkStorageEncryption(dbPath) {
return fail("Storage/encryption", "Encrypted credential check failed", {
error: error instanceof Error ? error.message : String(error),
});
} finally {
if (db) db.close();
}
}

View file

@ -1,33 +1,42 @@
import fs from "node:fs";
import { resolveDataDir, resolveStoragePath } from "./data-dir.mjs";
import { ensureProviderSchema } from "./provider-store.mjs";
import { ensureSettingsSchema } from "./settings-store.mjs";
import { ensureSettingsSchema, hashManagementPassword, updateSettings } from "./settings-store.mjs";
async function loadBetterSqlite() {
try {
return (await import("better-sqlite3")).default;
} catch {
throw new Error("better-sqlite3 is not installed. Run npm install before using setup.");
}
}
function createSqliteNativeError(error) {
const message = error instanceof Error ? error.message : String(error);
if (message.includes("NODE_MODULE_VERSION") || message.includes("ERR_DLOPEN_FAILED")) {
return new Error(
"better-sqlite3 native binding is incompatible with this Node.js runtime. " +
"Run `npm rebuild better-sqlite3` in the OmniRoute project and try again."
);
}
return error;
}
async function openSqliteDatabase(dbPath, options = {}) {
const Database = await loadBetterSqlite();
try {
return new Database(dbPath, options);
} catch (error) {
throw createSqliteNativeError(error);
}
}
export async function openOmniRouteDb() {
const dataDir = resolveDataDir();
const dbPath = resolveStoragePath(dataDir);
fs.mkdirSync(dataDir, { recursive: true });
let Database;
try {
Database = (await import("better-sqlite3")).default;
} catch {
throw new Error("better-sqlite3 is not installed. Run npm install before using setup.");
}
let db;
try {
db = new Database(dbPath);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (message.includes("NODE_MODULE_VERSION") || message.includes("ERR_DLOPEN_FAILED")) {
throw new Error(
"better-sqlite3 native binding is incompatible with this Node.js runtime. " +
"Run `npm rebuild better-sqlite3` in the OmniRoute project and try again."
);
}
throw error;
}
const db = await openSqliteDatabase(dbPath);
db.pragma("journal_mode = WAL");
ensureSettingsSchema(db);
@ -35,3 +44,113 @@ export async function openOmniRouteDb() {
return { db, dataDir, dbPath };
}
export async function withReadonlySqlite(dbPath, callback) {
const db = await openSqliteDatabase(dbPath, { readonly: true, fileMustExist: true });
try {
return await callback(db);
} finally {
db.close();
}
}
export async function backupSqliteFile(sourcePath, destPath) {
const db = await openSqliteDatabase(sourcePath, { readonly: true });
try {
await db.backup(destPath);
} finally {
db.close();
}
}
export async function readDatabaseHealth(dbPath) {
return withReadonlySqlite(dbPath, (db) => {
const quickCheck = db.prepare("PRAGMA quick_check").get();
const quickCheckValue = Object.values(quickCheck || {})[0];
const hasMigrationTable = !!db
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
.get("_omniroute_migrations");
const appliedMigrationVersions = hasMigrationTable
? db
.prepare("SELECT version FROM _omniroute_migrations")
.all()
.map((row) => row.version)
: [];
return { quickCheckValue, hasMigrationTable, appliedMigrationVersions };
});
}
export async function readEncryptedCredentialSamples(dbPath) {
return withReadonlySqlite(dbPath, (db) => {
const hasProviderTable = !!db
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
.get("provider_connections");
if (!hasProviderTable) {
return { hasProviderTable: false, encryptedValues: [] };
}
const rows = db
.prepare(
`SELECT api_key, access_token, refresh_token, id_token
FROM provider_connections
WHERE api_key LIKE 'enc:v1:%'
OR access_token LIKE 'enc:v1:%'
OR refresh_token LIKE 'enc:v1:%'
OR id_token LIKE 'enc:v1:%'
LIMIT 20`
)
.all();
const encryptedValues = rows.flatMap((row) =>
["api_key", "access_token", "refresh_token", "id_token"]
.filter((key) => typeof row[key] === "string" && row[key].startsWith("enc:v1:"))
.map((key) => row[key])
);
return { hasProviderTable: true, encryptedValues };
});
}
export async function readManagementPasswordState(dbPath = resolveStoragePath(resolveDataDir())) {
if (!fs.existsSync(dbPath)) {
return { exists: false, hasPassword: false };
}
return withReadonlySqlite(dbPath, (db) => {
const hasSettingsTable = !!db
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
.get("key_value");
if (!hasSettingsTable) {
return { exists: true, hasPassword: false };
}
const row = db
.prepare("SELECT value FROM key_value WHERE namespace = 'settings' AND key = ?")
.get("password");
let password = row?.value;
if (typeof password === "string") {
try {
password = JSON.parse(password);
} catch {}
}
return {
exists: true,
hasPassword: typeof password === "string" && password.length > 0,
};
});
}
export async function resetManagementPassword(
password,
dbPath = resolveStoragePath(resolveDataDir())
) {
const db = await openSqliteDatabase(dbPath);
try {
db.pragma("journal_mode = WAL");
ensureSettingsSchema(db);
const hashedPassword = await hashManagementPassword(password);
updateSettings(db, { password: hashedPassword, requireLogin: true });
} finally {
db.close();
}
}

View file

@ -14,16 +14,12 @@
*/
import { createInterface } from "node:readline";
import { resolve, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { existsSync } from "node:fs";
import bcrypt from "bcryptjs";
const __dirname = dirname(fileURLToPath(import.meta.url));
import { resolveDataDir, resolveStoragePath } from "./cli/data-dir.mjs";
import { readManagementPasswordState, resetManagementPassword } from "./cli/sqlite.mjs";
// Resolve data directory — same logic as the server
const DATA_DIR = process.env.DATA_DIR || resolve(__dirname, "..", "data");
const DB_PATH = resolve(DATA_DIR, "settings.db");
const DATA_DIR = resolveDataDir();
const DB_PATH = resolveStoragePath(DATA_DIR);
const rl = createInterface({
input: process.stdin,
@ -34,37 +30,19 @@ function ask(question) {
return new Promise((resolve) => rl.question(question, resolve));
}
function generateSecretDigest(input) {
// Use bcrypt with a salt round of 10 to match login/route.ts expectations
// and resolve CodeQL js/insufficient-password-hash warning.
return bcrypt.hashSync(input, 10);
}
console.log("\n🔑 OmniRoute — Password Reset\n");
async function main() {
// Check if database exists
if (!existsSync(DB_PATH)) {
const passwordState = await readManagementPasswordState(DB_PATH);
if (!passwordState.exists) {
console.error(`❌ Database not found at: ${DB_PATH}`);
console.error(` Make sure OmniRoute has been started at least once.`);
console.error(` Or set DATA_DIR env var to your data directory.\n`);
process.exit(1);
}
let Database;
try {
Database = (await import("better-sqlite3")).default;
} catch {
console.error("❌ better-sqlite3 not installed. Run: npm install");
process.exit(1);
}
const db = new Database(DB_PATH);
// Check current settings
const row = db.prepare("SELECT value FROM settings WHERE key = 'password'").get();
if (row) {
if (passwordState.hasPassword) {
console.log(" A password is currently set.");
} else {
console.log(" No password is currently set.");
@ -74,7 +52,6 @@ async function main() {
if (!password || password.length < 8) {
console.error("\n❌ Password must be at least 8 characters.\n");
db.close();
rl.close();
process.exit(1);
}
@ -83,28 +60,11 @@ async function main() {
if (password !== confirm) {
console.error("\n❌ Passwords do not match.\n");
db.close();
rl.close();
process.exit(1);
}
const hashed = generateSecretDigest(password);
// Upsert the password
const stmt = db.prepare(`
INSERT INTO settings (key, value) VALUES ('password', ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value
`);
stmt.run(hashed);
// Also ensure requireLogin is true
const loginStmt = db.prepare(`
INSERT INTO settings (key, value) VALUES ('requireLogin', 'true')
ON CONFLICT(key) DO UPDATE SET value = excluded.value
`);
loginStmt.run();
db.close();
await resetManagementPassword(password, DB_PATH);
rl.close();
console.log("\n✅ Password reset successfully!");

View file

@ -14,7 +14,7 @@
services:
# ── Redis (Rate Limiter Backend) ──────────────────────────────────
redis:
image: redis:8.6.2
image: redis:8.6.2-alpine
container_name: omniroute-redis-prod
restart: unless-stopped
volumes:

View file

@ -6,14 +6,11 @@
# base → minimal image, no CLI tools
# cli → CLIs installed inside the container (portable)
# host → runner-base + host-mounted CLI binaries (Linux-first)
# cliproxyapi → CLIProxyAPI sidecar on port 8317
#
# Usage:
# docker compose --profile base up -d
# docker compose --profile cli up -d
# docker compose --profile host up -d
# docker compose --profile cliproxyapi up -d
# docker compose --profile cli --profile cliproxyapi up -d
#
# Before first run, copy .env.example → .env and edit your secrets.
# ──────────────────────────────────────────────────────────────────────
@ -130,30 +127,6 @@ services:
profiles:
- host
# ── Profile: cliproxyapi (CLIProxyAPI as sidecar) ─────────────────
cliproxyapi:
container_name: cliproxyapi
image: ghcr.io/router-for-me/cliproxyapi:v6.9.7
restart: unless-stopped
ports:
- "${CLIPROXYAPI_PORT:-8317}:${CLIPROXYAPI_PORT:-8317}"
volumes:
- cliproxyapi-data:/root/.cli-proxy-api
environment:
- PORT=${CLIPROXYAPI_PORT:-8317}
- HOST=0.0.0.0
healthcheck:
test:
["CMD", "wget", "--spider", "-q", "http://127.0.0.1:${CLIPROXYAPI_PORT:-8317}/v1/models"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
profiles:
- cliproxyapi
volumes:
cliproxyapi-data:
name: cliproxyapi-data
redis-data:
name: omniroute-redis-data

View file

@ -64,23 +64,17 @@ docker compose --profile cli up -d
# Host profile (Linux-first; mounts host CLI binaries read-only)
docker compose --profile host up -d
# Combine CLI + CLIProxyAPI sidecar
docker compose --profile cli --profile cliproxyapi up -d
```
## Available Profiles
OmniRoute ships four Compose profiles. Pick the one that matches your environment.
OmniRoute ships three Compose profiles. Pick the one that matches your environment.
| Profile | Service | When to use | Command |
| ---------------- | ---------------- | --------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------- |
| `base` (default) | `omniroute-base` | Headless server / minimal runtime, no provider CLIs bundled | `docker compose --profile base up -d` |
| `cli` | `omniroute-cli` | Agentic workflows that call `omniroute providers/setup/doctor` and bundled CLIs (Codex, Claude Code, Droid, OpenClaw) | `docker compose --profile cli up -d` |
| `host` | `omniroute-host` | Linux hosts that want `network_mode`-like access to host CLIs by mounting `~/.local/bin`, `~/.codex`, `~/.claude`, etc. read-only | `docker compose --profile host up -d` |
| `cliproxyapi` | `cliproxyapi` | Run the [CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI) sidecar on port `8317` for upstream CLI proxying | `docker compose --profile cliproxyapi up -d` |
> Multiple profiles can be combined: `docker compose --profile cli --profile cliproxyapi up -d`.
| Profile | Service | When to use | Command |
| ---------------- | ---------------- | --------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------- |
| `base` (default) | `omniroute-base` | Headless server / minimal runtime, no provider CLIs bundled | `docker compose --profile base up -d` |
| `cli` | `omniroute-cli` | Agentic workflows that call `omniroute providers/setup/doctor` and bundled CLIs (Codex, Claude Code, Droid, OpenClaw) | `docker compose --profile cli up -d` |
| `host` | `omniroute-host` | Linux hosts that want `network_mode`-like access to host CLIs by mounting `~/.local/bin`, `~/.codex`, `~/.claude`, etc. read-only | `docker compose --profile host up -d` |
## Redis Sidecar
@ -167,7 +161,6 @@ Beyond the defaults documented in [ENVIRONMENT.md](../reference/ENVIRONMENT.md),
| `OMNIROUTE_MEMORY_MB` | Node heap ceiling (`NODE_OPTIONS=--max-old-space-size`) baked into the image | `256` (set in Dockerfile) |
| `DASHBOARD_PORT` / `API_PORT` | Override exposed ports for dashboard (20128) and API (20129) | `20128` / `20129` |
| `PROD_DASHBOARD_PORT` | Host-side dashboard port for `docker-compose.prod.yml` | `20130` |
| `CLIPROXYAPI_PORT` | Host-side port for the `cliproxyapi` sidecar | `8317` |
## Docker Compose with Caddy (HTTPS Auto-TLS)

View file

@ -50,6 +50,8 @@ const eslintConfig = [
"bin/**",
// Dependencies
"node_modules/**",
".worktrees/**",
".omnivscodeagent/**",
// VS Code extension and its large test fixtures
"vscode-extension/**",
"_references/**",

View file

@ -1,6 +1,7 @@
#!/usr/bin/env node
import { execFileSync } from "node:child_process";
import { existsSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
@ -17,22 +18,29 @@ const __dirname: string = dirname(__filename);
const ROOT: string = join(__dirname, "..", "..");
const npmCommand: string = process.platform === "win32" ? "npm.cmd" : "npm";
function runPackDryRun(): any {
function runNpm(args: string[], stdio: "inherit" | "pipe" = "pipe"): string {
const npmExecPath = process.env.npm_execpath;
const command = npmExecPath ? process.execPath : npmCommand;
const args = [
...(npmExecPath ? [npmExecPath] : []),
"pack",
"--dry-run",
"--json",
"--ignore-scripts",
];
const output = execFileSync(command, args, {
return execFileSync(command, [...(npmExecPath ? [npmExecPath] : []), ...args], {
cwd: ROOT,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
stdio: stdio === "inherit" ? "inherit" : ["ignore", "pipe", "pipe"],
});
}
function ensureAppStagingReady(): void {
const missingAppRequiredPaths = PACK_ARTIFACT_REQUIRED_PATHS.filter((requiredPath) =>
requiredPath.startsWith("app/")
).filter((requiredPath) => !existsSync(join(ROOT, requiredPath)));
if (missingAppRequiredPaths.length === 0) return;
console.log("📦 app/ staging is missing required runtime files; running npm run build:cli...");
runNpm(["run", "build:cli"], "inherit");
}
function runPackDryRun(): any {
const output = runNpm(["pack", "--dry-run", "--json", "--ignore-scripts"]);
const jsonStart = output.indexOf("[");
const jsonEnd = output.lastIndexOf("]");
@ -66,6 +74,7 @@ function formatBytes(bytes: number): string {
}
try {
ensureAppStagingReady();
const packReport = runPackDryRun();
const artifactPaths: string[] = packReport.files.map((file: any) => file.path);
const unexpectedPaths: string[] = findUnexpectedArtifactPaths(artifactPaths, {

View file

@ -48,9 +48,18 @@ export default function ChatPlayground({
const messagesEndRef = useRef<HTMLDivElement>(null);
const abortRef = useRef<AbortController | null>(null);
const filteredModels = models
.filter((m) => !selectedProvider || m.id.startsWith(selectedProvider + "/"))
.map((m) => ({ value: m.id, label: m.id }));
const filteredModels = (() => {
const seen = new Set<string>();
const out: Array<{ value: string; label: string }> = [];
for (const m of models) {
if (typeof m?.id !== "string") continue;
if (selectedProvider && !m.id.startsWith(selectedProvider + "/")) continue;
if (seen.has(m.id)) continue;
seen.add(m.id);
out.push({ value: m.id, label: m.id });
}
return out;
})();
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });

View file

@ -3,6 +3,7 @@
export const runtime = "nodejs";
import { NextResponse } from "next/server";
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
import { requireCliToolsAuth } from "@/lib/api/requireCliToolsAuth";
import { cliMitmStartSchema, cliMitmStopSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
@ -25,7 +26,7 @@ export async function GET(request) {
hasCachedPassword: !!getCachedPassword(),
});
} catch (error) {
console.log("Error getting MITM status:", error.message);
console.log("Error getting MITM status:", sanitizeErrorMessage(error));
return NextResponse.json({ error: "Failed to get MITM status" }, { status: 500 });
}
}
@ -81,9 +82,9 @@ export async function POST(request) {
pid: result.pid,
});
} catch (error) {
console.log("Error starting MITM:", error.message);
console.log("Error starting MITM:", sanitizeErrorMessage(error));
return NextResponse.json(
{ error: error.message || "Failed to start MITM proxy" },
{ error: sanitizeErrorMessage(error) || "Failed to start MITM proxy" },
{ status: 500 }
);
}
@ -130,9 +131,9 @@ export async function DELETE(request) {
return NextResponse.json({ success: true, running: false });
} catch (error) {
console.log("Error stopping MITM:", error.message);
console.log("Error stopping MITM:", sanitizeErrorMessage(error));
return NextResponse.json(
{ error: error.message || "Failed to stop MITM proxy" },
{ error: sanitizeErrorMessage(error) || "Failed to stop MITM proxy" },
{ status: 500 }
);
}

View file

@ -5,6 +5,7 @@
import { z } from "zod";
import { NextRequest, NextResponse } from "next/server";
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { validateBody, isValidationFailure } from "@/shared/validation/helpers";
@ -138,7 +139,7 @@ export async function POST(request: NextRequest) {
status: 0,
statusText: "Network Error",
headers: {},
body: { error: error.message || "Request failed" },
body: { error: sanitizeErrorMessage(error) || "Request failed" },
latencyMs: 0,
contentType: "application/json",
},

View file

@ -1,5 +1,6 @@
import { NextResponse } from "next/server";
import { getSettings } from "@/lib/db/settings";
import { SAFE_OUTBOUND_FETCH_PRESETS, safeOutboundFetch } from "@/shared/network/safeOutboundFetch";
export const dynamic = "force-dynamic";
@ -15,33 +16,6 @@ const MAX_FAVICON_SIZE = 50 * 1024; // 50KB
const FETCH_TIMEOUT = 5000; // 5 seconds
const CACHE_DURATION = 300; // 5 minutes
function isAllowedUrl(url: string): boolean {
try {
const parsedUrl = new URL(url);
// Only allow https (or http for local development)
if (parsedUrl.protocol !== "https:" && parsedUrl.protocol !== "http:") {
return false;
}
// Block private/internal IPs
const hostname = parsedUrl.hostname;
if (
hostname === "localhost" ||
hostname === "127.0.0.1" ||
hostname === "0.0.0.0" ||
hostname.startsWith("192.168.") ||
hostname.startsWith("10.") ||
hostname.startsWith("172.") ||
hostname.endsWith(".local") ||
hostname === "localhost"
) {
return false;
}
return true;
} catch {
return false;
}
}
function validateImageData(base64Data: string, contentType: string): boolean {
if (!ALLOWED_IMAGE_TYPES.includes(contentType)) {
console.error("Invalid content type:", contentType);
@ -76,42 +50,35 @@ export async function GET() {
faviconData = customFaviconBase64;
}
} else if (customFaviconUrl) {
// Validate URL before fetching (SSRF protection)
if (!isAllowedUrl(customFaviconUrl)) {
console.error("Blocked invalid favicon URL:", customFaviconUrl);
} else {
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT);
try {
const response = await safeOutboundFetch(customFaviconUrl, {
...SAFE_OUTBOUND_FETCH_PRESETS.validationRead,
guard: "public-only",
timeoutMs: FETCH_TIMEOUT,
headers: {
"User-Agent": "OmniRoute/2.0",
},
});
const response = await fetch(customFaviconUrl, {
signal: controller.signal,
headers: {
"User-Agent": "OmniRoute/2.0",
},
});
clearTimeout(timeoutId);
if (response.ok) {
const contentType = response.headers.get("content-type") || "";
const arrayBuffer = await response.arrayBuffer();
const uint8Array = new Uint8Array(arrayBuffer);
if (response.ok) {
const contentType = response.headers.get("content-type") || "";
const arrayBuffer = await response.arrayBuffer();
const uint8Array = new Uint8Array(arrayBuffer);
// Validate size before processing
if (uint8Array.length > MAX_FAVICON_SIZE) {
console.error("Favicon exceeds max size:", uint8Array.length);
} else {
const base64 = Buffer.from(uint8Array).toString("base64");
const fullData = `data:${contentType};base64,${base64}`;
// Validate size before processing
if (uint8Array.length > MAX_FAVICON_SIZE) {
console.error("Favicon exceeds max size:", uint8Array.length);
} else {
const base64 = Buffer.from(uint8Array).toString("base64");
const fullData = `data:${contentType};base64,${base64}`;
if (validateImageData(fullData, contentType)) {
faviconData = fullData;
}
if (validateImageData(fullData, contentType)) {
faviconData = fullData;
}
}
} catch (error) {
console.error("Failed to fetch custom favicon:", error);
}
} catch (error) {
console.error("Failed to fetch custom favicon:", error);
}
}

View file

@ -1,5 +1,6 @@
import { buildClientRawRequest, handleChat } from "@/sse/handlers/chat";
import { initTranslators } from "@omniroute/open-sse/translator/index.ts";
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
import { v1betaGeminiGenerateSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
@ -88,7 +89,10 @@ export async function POST(request, { params }) {
return await handleChat(newRequest, buildClientRawRequest(request, rawBody));
} catch (error) {
console.log("Error handling Gemini request:", error);
return Response.json({ error: { message: error.message, code: 500 } }, { status: 500 });
return Response.json(
{ error: { message: sanitizeErrorMessage(error), code: 500 } },
{ status: 500 }
);
}
}

View file

@ -1,4 +1,5 @@
import { PROVIDER_MODELS } from "@/shared/constants/models";
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
import {
getAllCustomModels,
getAllSyncedAvailableModels,
@ -156,6 +157,6 @@ export async function GET() {
return Response.json({ models });
} catch (error: any) {
console.log("Error fetching models:", error);
return Response.json({ error: { message: error.message } }, { status: 500 });
return Response.json({ error: { message: sanitizeErrorMessage(error) } }, { status: 500 });
}
}

View file

@ -7,6 +7,7 @@
import { z } from "zod";
import { NextResponse } from "next/server";
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
import { getWebhook, updateWebhookRecord, deleteWebhook } from "@/lib/localDb";
import { validateBody, isValidationFailure } from "@/shared/validation/helpers";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
@ -33,7 +34,7 @@ export async function GET(_: Request, { params }: { params: Promise<{ id: string
}
return NextResponse.json({ webhook });
} catch (error: any) {
return NextResponse.json({ error: error.message }, { status: 500 });
return NextResponse.json({ error: sanitizeErrorMessage(error) }, { status: 500 });
}
}
@ -55,7 +56,7 @@ export async function PUT(request: Request, { params }: { params: Promise<{ id:
}
return NextResponse.json({ webhook });
} catch (error: any) {
return NextResponse.json({ error: error.message }, { status: 500 });
return NextResponse.json({ error: sanitizeErrorMessage(error) }, { status: 500 });
}
}
@ -71,6 +72,6 @@ export async function DELETE(_: Request, { params }: { params: Promise<{ id: str
}
return NextResponse.json({ success: true });
} catch (error: any) {
return NextResponse.json({ error: error.message }, { status: 500 });
return NextResponse.json({ error: sanitizeErrorMessage(error) }, { status: 500 });
}
}

View file

@ -4,6 +4,7 @@
*/
import { NextResponse } from "next/server";
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
import { getWebhook, recordWebhookDelivery } from "@/lib/localDb";
import { deliverWebhook } from "@/lib/webhookDispatcher";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
@ -38,9 +39,9 @@ export async function POST(_: Request, { params }: { params: Promise<{ id: strin
return NextResponse.json({
delivered: result.success,
status: result.status,
error: result.error || null,
error: result.error ? sanitizeErrorMessage(result.error) : null,
});
} catch (error: any) {
return NextResponse.json({ error: error.message }, { status: 500 });
return NextResponse.json({ error: sanitizeErrorMessage(error) }, { status: 500 });
}
}

View file

@ -6,6 +6,7 @@
import { z } from "zod";
import { NextResponse } from "next/server";
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
import { getWebhooks, createWebhook } from "@/lib/localDb";
import { validateBody, isValidationFailure } from "@/shared/validation/helpers";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
@ -31,7 +32,7 @@ export async function GET(request: Request) {
return NextResponse.json({ webhooks: masked });
} catch (error: any) {
return NextResponse.json(
{ error: error.message || "Failed to list webhooks" },
{ error: sanitizeErrorMessage(error) || "Failed to list webhooks" },
{ status: 500 }
);
}
@ -59,7 +60,7 @@ export async function POST(request: Request) {
return NextResponse.json({ webhook }, { status: 201 });
} catch (error: any) {
return NextResponse.json(
{ error: error.message || "Failed to create webhook" },
{ error: sanitizeErrorMessage(error) || "Failed to create webhook" },
{ status: 500 }
);
}

View file

@ -457,6 +457,24 @@ export function handleNoCredentials(
credentials.retryAfterHuman
);
}
if (credentials?.allExpired) {
// Every connection for this provider is in a terminal state (expired,
// banned, or credits_exhausted). Surface as 401 with a re-auth hint
// instead of the generic 400 "No credentials", so dashboards/CLIs can
// distinguish "never configured" from "needs to reconnect".
const status = credentials.expiredStatus || "expired";
const count = credentials.expiredCount || 1;
const reason =
status === "credits_exhausted"
? "credits exhausted"
: status === "banned"
? "banned by upstream"
: "authentication expired";
const message = `[${provider}] All ${count} connection(s) ${reason} — please reconnect in the dashboard`;
log.warn("CHAT", message);
return errorResponse(HTTP_STATUS.UNAUTHORIZED, message);
}
if (lastError && lastStatus) {
log.warn("CHAT", "Preserving last upstream error after credential exhaustion", {
provider,

View file

@ -885,6 +885,28 @@ export async function getProviderCredentials(
`${c.id?.slice(0, 8)} | isActive=${c.isActive} | rateLimitedUntil=${c.rateLimitedUntil || "none"} | testStatus=${c.testStatus}`
);
});
// If every existing connection is in a terminal state (expired/banned/
// credits_exhausted), surface that as a re-auth signal instead of the
// generic "No credentials" 400. The classic case is AWS SSO/Kiro
// refresh tokens hitting their 90-day TTL: all connections flip to
// is_active=0 with testStatus=banned|expired, and without this branch
// the dashboard sees a misleading "bad_request" code.
const terminalConnections = allConnections.filter(isTerminalConnectionStatus);
if (terminalConnections.length === allConnections.length) {
const statusCounts = new Map<string, number>();
for (const c of terminalConnections) {
const key = normalizeStatus(c.testStatus) || "expired";
statusCounts.set(key, (statusCounts.get(key) || 0) + 1);
}
const dominantStatus =
[...statusCounts.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] || "expired";
return {
allExpired: true,
expiredCount: terminalConnections.length,
expiredStatus: dominantStatus,
};
}
}
log.warn("AUTH", `No credentials for ${provider}`);
return null;
@ -1390,7 +1412,7 @@ export async function getProviderCredentialsWithQuotaPreflight(
return null;
}
if (credentials.allRateLimited) {
if (credentials.allRateLimited || credentials.allExpired) {
return credentials;
}

View file

@ -257,6 +257,41 @@ test("handleNoCredentials returns structured model_cooldown when every credentia
assert.match(json.error.message, /cooling down/i);
});
test("handleNoCredentials returns 401 with re-auth hint when every connection is in a terminal state", async () => {
// Classic scenario: AWS SSO refresh tokens hit their 90-day TTL, every Kiro
// connection flips to is_active=0 + testStatus=banned/expired. Surface as
// 401 with a reconnect hint instead of the misleading 400 "No credentials".
const response = handleNoCredentials(
{ allExpired: true, expiredCount: 1, expiredStatus: "banned" },
null,
"kiro",
"claude-sonnet-4.6",
null,
null
);
const json = (await response.json()) as any;
assert.equal(response.status, 401);
assert.match(json.error.message, /\[kiro\]/);
assert.match(json.error.message, /banned by upstream/);
assert.match(json.error.message, /please reconnect/i);
});
test("handleNoCredentials maps allExpired status='expired' to the 'authentication expired' reason", async () => {
const response = handleNoCredentials(
{ allExpired: true, expiredCount: 3, expiredStatus: "expired" },
null,
"cline",
"claude-sonnet-4.6",
null,
null
);
const json = (await response.json()) as any;
assert.equal(response.status, 401);
assert.match(json.error.message, /3 connection\(s\) authentication expired/);
});
test("safeResolveProxy returns the direct route when no proxy config is present", async () => {
const connection = await seedConnection("openai", { apiKey: "sk-openai-direct" });