diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9040ba915a..0921b54954 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -108,3 +108,39 @@ jobs: - run: npx playwright install --with-deps chromium - run: npm run build - run: npm run test:e2e + + test-integration: + name: Integration Tests + runs-on: ubuntu-latest + needs: build + env: + JWT_SECRET: ci-test-secret-with-sufficient-length-for-validation + API_KEY_SECRET: ci-test-api-key-secret-long + INITIAL_PASSWORD: ci-test-password-for-integration + DATA_DIR: /tmp/omniroute-ci + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: npm run test:integration + continue-on-error: true + + test-security: + name: Security Tests + runs-on: ubuntu-latest + needs: build + env: + JWT_SECRET: ci-test-secret-with-sufficient-length-for-validation + API_KEY_SECRET: ci-test-api-key-secret-long + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: npm run test:security + continue-on-error: true diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 014e4c7c44..8b20d06fe2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -159,7 +159,7 @@ src/ # TypeScript (.ts / .tsx) │ ├── cacheLayer.ts # LRU cache │ ├── semanticCache.ts # Semantic response cache │ ├── idempotencyLayer.ts # Request deduplication -│ └── localDb.ts # LowDB (JSON) storage +│ └── localDb.ts # Settings facade (LowDB for config, SQLite for domain data) ├── shared/ │ ├── components/ # React components (.tsx) │ ├── middleware/ # Correlation IDs, etc. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 5ceb57ebe5..3e0b9fd41a 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -771,8 +771,8 @@ Environment variables actively used by code: ## Operational Verification Checklist -- Build from source: `cd /root/dev/omniroute && npm run build` -- Build Docker image: `cd /root/dev/omniroute && docker build -t omniroute .` +- Build from source: `npm run build` +- Build Docker image: `docker build -t omniroute .` - Start service and verify: - `GET /api/settings` - `GET /api/v1/models` diff --git a/docs/adr/ADR-001-nextjs-foundation.md b/docs/adr/ADR-001-nextjs-foundation.md new file mode 100644 index 0000000000..8bb7637b18 --- /dev/null +++ b/docs/adr/ADR-001-nextjs-foundation.md @@ -0,0 +1,37 @@ +# ADR-001: Next.js as the Foundation for an AI Gateway + +## Status: Accepted + +## Context + +OmniRoute is an AI routing gateway that translates, forwards, and manages requests across 20+ LLM providers. We needed a framework that could serve both the API proxy layer and a management dashboard from a single codebase. + +**Alternatives considered:** + +- **Express.js only** — Simpler proxy, but requires separate frontend tooling +- **Fastify** — Fast, but no built-in SSR/dashboard support +- **Next.js** — Unified full-stack framework with API routes, SSR, and static pages + +## Decision + +We chose Next.js because: + +1. **Single deployment** — API routes (`/api/*`) and dashboard UI in one process +2. **Middleware layer** — Native request interception for auth guards and request tracing +3. **File-based routing** — Easy to map provider endpoints to handlers +4. **Built-in TypeScript** — Type safety across the entire codebase + +## Consequences + +**Positive:** + +- One `npm run build` produces both API and UI +- Middleware provides centralized auth and request tracing +- Dashboard gets automatic code splitting and optimization + +**Negative:** + +- Next.js middleware has limitations (no heavy imports, edge runtime constraints) +- Serverless deployment model doesn't align with persistent WebSocket/SSE connections +- Build times are longer than Express-only setups +- The SSE proxy layer (`open-sse/`) operates outside Next.js conventions diff --git a/docs/adr/ADR-002-hub-spoke-translation.md b/docs/adr/ADR-002-hub-spoke-translation.md new file mode 100644 index 0000000000..bca315769c --- /dev/null +++ b/docs/adr/ADR-002-hub-spoke-translation.md @@ -0,0 +1,37 @@ +# ADR-002: Hub-and-Spoke Translation with OpenAI as Intermediate Format + +## Status: Accepted + +## Context + +OmniRoute routes requests across 20+ providers, each with its own API format (OpenAI, Anthropic Messages, Google Gemini, AWS Bedrock, etc.). Direct provider-to-provider translation would require O(n²) translators. + +**Alternatives considered:** + +- **Direct translation** — Each pair needs a dedicated translator (n² complexity) +- **Common intermediate format** — Translate to/from a canonical format (2n complexity) +- **Protocol buffers** — Strong typing but heavy overhead for a proxy + +## Decision + +We use the **OpenAI Chat Completions format** as the canonical intermediate representation. All incoming requests are normalized to OpenAI format, processed, then translated to the target provider's format. + +``` +Client → [any format] → OpenAI canonical → [target format] → Provider +Provider → [response] → OpenAI canonical → [original format] → Client +``` + +## Consequences + +**Positive:** + +- Only 2 translators per provider (inbound + outbound) instead of n² pairs +- OpenAI format is the de facto standard — most clients already use it +- Adding a new provider requires only implementing one translator pair +- Streaming (SSE) works consistently through the canonical format + +**Negative:** + +- Some provider-specific features may be lost in translation +- The double translation adds latency (typically < 5ms) +- OpenAI format changes require updating the canonical representation diff --git a/docs/adr/ADR-003-dual-storage-sqlite.md b/docs/adr/ADR-003-dual-storage-sqlite.md new file mode 100644 index 0000000000..5b68c15fbb --- /dev/null +++ b/docs/adr/ADR-003-dual-storage-sqlite.md @@ -0,0 +1,39 @@ +# ADR-003: Dual Storage — SQLite Primary with JSON Migration Path + +## Status: Accepted + +## Context + +OmniRoute originally used LowDB (JSON file) for all persistence. As the project grew, JSON-based storage became a bottleneck for concurrent access, querying, and data integrity. + +**Alternatives considered:** + +- **LowDB only** — Simple but no concurrent access, no ACID, no querying +- **SQLite only** — Fast, ACID-compliant, but breaks existing deployments +- **PostgreSQL** — Production-grade but requires external dependency +- **Dual storage with migration** — SQLite primary + automatic JSON migration + +## Decision + +We migrated to **SQLite as the primary store** with an automatic one-time migration from `db.json`: + +1. On startup, if `db.json` exists and SQLite is empty, auto-migrate all data +2. All new reads/writes go through SQLite +3. The `db.json` file is preserved but no longer written to + +Settings remain in a hybrid model where LowDB handles simple key-value configuration for backward compatibility. + +## Consequences + +**Positive:** + +- ACID transactions for provider connections, API keys, and usage data +- Proper SQL queries for analytics and log filtering +- Concurrent read/write safety via WAL mode +- Zero-downtime migration from JSON — users upgrade transparently + +**Negative:** + +- Two storage engines to maintain (SQLite + LowDB for settings) +- Migration code must handle edge cases and partial data +- SQLite binary dependency needed in deployment environments diff --git a/eslint.config.mjs b/eslint.config.mjs index 0ec4f3d652..62d334fcb4 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -3,7 +3,7 @@ import nextVitals from "eslint-config-next/core-web-vitals"; /** @type {import("eslint").Linter.Config[]} */ const eslintConfig = [ ...nextVitals, - // FASE-02: Security rules + // FASE-02: Security rules (strict everywhere) { rules: { "no-eval": "error", @@ -11,18 +11,25 @@ const eslintConfig = [ "no-new-func": "error", }, }, - // Global ignores + // Relaxed rules for open-sse and tests (incremental adoption) + { + files: ["open-sse/**/*.ts", "tests/**/*.mjs", "tests/**/*.ts"], + rules: { + "@typescript-eslint/no-explicit-any": "warn", + "@next/next/no-assign-module-variable": "off", + "react-hooks/rules-of-hooks": "off", + }, + }, + // Global ignores (open-sse and tests REMOVED — now linted) { ignores: [ ".next/**", "out/**", "build/**", "next-env.d.ts", - "tests/**", "scripts/**", "bin/**", "node_modules/**", - "open-sse/**", ], }, ]; diff --git a/src/app/(dashboard)/dashboard/analytics/loading.tsx b/src/app/(dashboard)/dashboard/analytics/loading.tsx new file mode 100644 index 0000000000..167555442d --- /dev/null +++ b/src/app/(dashboard)/dashboard/analytics/loading.tsx @@ -0,0 +1,15 @@ +"use client"; + +export default function AnalyticsLoading() { + return ( +
+
+
+ {[1, 2, 3, 4].map((i) => ( +
+ ))} +
+
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/providers/error.tsx b/src/app/(dashboard)/dashboard/providers/error.tsx new file mode 100644 index 0000000000..02421e870b --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/error.tsx @@ -0,0 +1,28 @@ +"use client"; + +export default function ProvidersError({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + return ( +
+
+

+ Failed to load providers +

+

+ {error.message || "An unexpected error occurred while loading provider data."} +

+ +
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/providers/loading.tsx b/src/app/(dashboard)/dashboard/providers/loading.tsx new file mode 100644 index 0000000000..a430291bdd --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/loading.tsx @@ -0,0 +1,14 @@ +"use client"; + +export default function ProvidersLoading() { + return ( +
+
+
+ {[1, 2, 3].map((i) => ( +
+ ))} +
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/settings/error.tsx b/src/app/(dashboard)/dashboard/settings/error.tsx new file mode 100644 index 0000000000..777e7c62af --- /dev/null +++ b/src/app/(dashboard)/dashboard/settings/error.tsx @@ -0,0 +1,28 @@ +"use client"; + +export default function SettingsError({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + return ( +
+
+

+ Failed to load settings +

+

+ {error.message || "An unexpected error occurred while loading settings."} +

+ +
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/settings/loading.tsx b/src/app/(dashboard)/dashboard/settings/loading.tsx new file mode 100644 index 0000000000..1ac2ced543 --- /dev/null +++ b/src/app/(dashboard)/dashboard/settings/loading.tsx @@ -0,0 +1,17 @@ +"use client"; + +export default function SettingsLoading() { + return ( +
+
+
+ {[1, 2, 3, 4].map((i) => ( +
+
+
+
+ ))} +
+
+ ); +} diff --git a/src/lib/usage/callLogs.ts b/src/lib/usage/callLogs.ts index 25ac374178..4c87229378 100644 --- a/src/lib/usage/callLogs.ts +++ b/src/lib/usage/callLogs.ts @@ -14,6 +14,47 @@ import { getDbInstance } from "../db/core"; import { shouldPersistToDisk, CALL_LOGS_DIR } from "./migrations"; const CALL_LOGS_MAX = 500; +const LOG_RETENTION_DAYS = parseInt(process.env.LOG_RETENTION_DAYS || "7", 10); + +/** Fields that should always be redacted from logged payloads */ +const SENSITIVE_KEYS = new Set([ + "api_key", + "apiKey", + "api-key", + "authorization", + "Authorization", + "x-api-key", + "X-Api-Key", + "access_token", + "accessToken", + "refresh_token", + "refreshToken", + "password", + "secret", + "token", +]); + +/** + * Redact sensitive fields from a payload before persistence. + */ +function redactPayload(obj: any): any { + if (!obj || typeof obj !== "object") return obj; + if (Array.isArray(obj)) return obj.map(redactPayload); + + const redacted: Record = {}; + for (const [key, value] of Object.entries(obj)) { + if (SENSITIVE_KEYS.has(key)) { + redacted[key] = "[REDACTED]"; + } else if (typeof value === "string" && value.startsWith("Bearer ")) { + redacted[key] = "Bearer [REDACTED]"; + } else if (typeof value === "object" && value !== null) { + redacted[key] = redactPayload(value); + } else { + redacted[key] = value; + } + } + return redacted; +} let logIdCounter = 0; function generateLogId() { @@ -38,9 +79,11 @@ export async function saveCallLog(entry: any) { } catch {} // Truncate large payloads for DB storage (keep under 8KB each) + // Also redact sensitive fields before persistence const truncatePayload = (obj: any) => { if (!obj) return null; - const str = JSON.stringify(obj); + const redacted = redactPayload(obj); + const str = JSON.stringify(redacted); if (str.length <= 8192) return str; try { return JSON.stringify({ @@ -150,12 +193,12 @@ export function rotateCallLogs() { try { const entries = fs.readdirSync(CALL_LOGS_DIR); const now = Date.now(); - const sevenDays = 7 * 24 * 60 * 60 * 1000; + const retentionMs = LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000; for (const entry of entries) { const entryPath = path.join(CALL_LOGS_DIR, entry); const stat = fs.statSync(entryPath); - if (stat.isDirectory() && now - stat.mtimeMs > sevenDays) { + if (stat.isDirectory() && now - stat.mtimeMs > retentionMs) { fs.rmSync(entryPath, { recursive: true, force: true }); console.log(`[callLogs] Rotated old logs: ${entry}`); } diff --git a/src/shared/utils/apiResponse.ts b/src/shared/utils/apiResponse.ts new file mode 100644 index 0000000000..e0042a28ae --- /dev/null +++ b/src/shared/utils/apiResponse.ts @@ -0,0 +1,79 @@ +/** + * Canonical API Response Helpers — P1-02 + * + * Provides a consistent error and success response contract: + * { error: { code, message, correlation_id?, details? } } + * + * All management API routes should use these helpers instead of + * ad-hoc NextResponse.json({ error: "..." }) calls. + * + * @module shared/utils/apiResponse + */ + +import { NextResponse } from "next/server"; + +interface ApiError { + code: string; + message: string; + correlation_id?: string; + details?: Record; +} + +/** + * Return a structured error response. + * + * @param code Machine-readable error code (e.g. AUTH_001, VALIDATION_001) + * @param message Human-readable message + * @param status HTTP status code + * @param opts Optional correlation_id and details + */ +export function errorResponse( + code: string, + message: string, + status: number, + opts?: { correlationId?: string; details?: Record } +): NextResponse { + const body: { error: ApiError } = { + error: { + code, + message, + ...(opts?.correlationId ? { correlation_id: opts.correlationId } : {}), + ...(opts?.details ? { details: opts.details } : {}), + }, + }; + return NextResponse.json(body, { status }); +} + +/** + * Return a structured success response. + */ +export function successResponse(data: unknown, status = 200): NextResponse { + return NextResponse.json(data, { status }); +} + +/** + * Standard error codes for consistent client handling. + */ +export const ErrorCodes = { + // Auth + AUTH_001: "AUTH_001", // Authentication required + AUTH_002: "AUTH_002", // Invalid credentials + AUTH_003: "AUTH_003", // Token expired + + // Validation + VALIDATION_001: "VALIDATION_001", // Invalid request body + VALIDATION_002: "VALIDATION_002", // Missing required field + + // Server + SERVER_001: "SERVER_001", // Internal server error + SERVER_002: "SERVER_002", // Service unavailable + SERVER_003: "SERVER_003", // Configuration error + + // Security + SECURITY_001: "SECURITY_001", // Prompt injection detected + SECURITY_002: "SECURITY_002", // Rate limit exceeded + + // Resource + RESOURCE_001: "RESOURCE_001", // Not found + RESOURCE_002: "RESOURCE_002", // Conflict +} as const;