diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ce3b2c106d..e4b57c85d7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,6 +23,21 @@ jobs: - run: npm ci - run: npm run lint + security: + name: Security Audit + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - run: npm ci + - name: Dependency audit + run: npm audit --audit-level=high --omit=dev + - name: Check for known vulnerabilities + run: npx is-my-node-vulnerable || true + build: name: Build runs-on: ubuntu-latest diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1e64d18ab1..3d72cd4e44 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -258,10 +258,10 @@ Write unit tests in `tests/unit/` covering at minimum: ## Releasing -When a new GitHub Release is created (e.g. `v0.3.0`), the package is **automatically published to npm** via GitHub Actions: +When a new GitHub Release is created (e.g. `v0.4.0`), the package is **automatically published to npm** via GitHub Actions: ```bash -gh release create v0.3.0 --title "v0.3.0" --generate-notes +gh release create v0.4.0 --title "v0.4.0" --generate-notes ``` --- diff --git a/README.md b/README.md index b0fc4a8e46..c950b7311a 100644 --- a/README.md +++ b/README.md @@ -189,7 +189,7 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed guidelines. ```bash # Create a release — npm publish happens automatically -gh release create v0.3.0 --title "v0.3.0" --generate-notes +gh release create v0.4.0 --title "v0.4.0" --generate-notes ``` --- diff --git a/SECURITY.md b/SECURITY.md index 28ab4c29a3..e4b948831b 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -20,8 +20,9 @@ If you discover a security vulnerability in OmniRoute, please report it responsi | Version | Support Status | | ------- | -------------- | -| 0.2.x | ✅ Active | -| < 0.2.0 | ❌ Unsupported | +| 0.4.x | ✅ Active | +| 0.3.x | ✅ Active | +| < 0.3.0 | ❌ Unsupported | ## Security Best Practices diff --git a/docs/openapi.yaml b/docs/openapi.yaml index f2b14d6cd6..13c500aa1e 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: OmniRoute API - version: 0.0.1 + version: 0.4.0 description: | OmniRoute is a local-first AI API proxy router. It provides an OpenAI-compatible endpoint that routes requests to multiple AI providers with load balancing, @@ -641,11 +641,12 @@ paths: /api/tags: get: - tags: [Settings] - summary: List all tags + tags: [System] + summary: List Ollama-compatible model tags + description: Returns models in Ollama /api/tags format for Ollama client compatibility responses: "200": - description: Tag list + description: Ollama model tags # ─── Usage & Analytics ───────────────────────────────────────── @@ -1017,6 +1018,173 @@ paths: "200": description: Sync initialized + # ─── Resilience & Monitoring ──────────────────────────────────── + + /api/resilience: + get: + tags: [System] + summary: Get resilience profiles and circuit breaker states + responses: + "200": + description: Resilience configuration and current states + put: + tags: [System] + summary: Update resilience profiles + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + "200": + description: Updated resilience profiles + + /api/resilience/reset: + post: + tags: [System] + summary: Reset circuit breakers + responses: + "200": + description: Circuit breakers reset + + /api/monitoring/health: + get: + tags: [System] + summary: System health check + description: Returns system health including uptime, memory, circuit breakers, rate limits + responses: + "200": + description: Health status + + /api/rate-limits: + get: + tags: [System] + summary: Get per-account rate limit status + responses: + "200": + description: Rate limit status by account + + /api/sessions: + get: + tags: [System] + summary: Get active sessions + responses: + "200": + description: Active session list + + /api/cache: + get: + tags: [System] + summary: Get cache statistics + responses: + "200": + description: Semantic cache and idempotency stats + delete: + tags: [System] + summary: Clear all caches + responses: + "200": + description: Caches cleared + + # ─── Evals & Policies ────────────────────────────────────────── + + /api/evals: + get: + tags: [System] + summary: List eval suites + responses: + "200": + description: Eval suite list + post: + tags: [System] + summary: Run evaluation + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + "200": + description: Eval results + + /api/policies: + get: + tags: [System] + summary: List routing policies + responses: + "200": + description: Policy list + post: + tags: [System] + summary: Create routing policy + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + "201": + description: Created policy + delete: + tags: [System] + summary: Delete routing policy + responses: + "200": + description: Policy deleted + + /api/compliance/audit-log: + get: + tags: [System] + summary: Get compliance audit log + parameters: + - name: limit + in: query + schema: + type: integer + default: 100 + responses: + "200": + description: Audit log entries + + # ─── v1beta (Gemini-Compatible) ───────────────────────────────── + + /api/v1beta/models: + get: + tags: [Models] + summary: List models (Gemini format) + description: Returns models in Gemini v1beta format for native SDK compatibility + security: + - BearerAuth: [] + responses: + "200": + description: Model list in Gemini format + + /api/v1beta/models/{path}: + post: + tags: [Models] + summary: Gemini generateContent + description: Gemini-compatible generateContent endpoint + security: + - BearerAuth: [] + parameters: + - name: path + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + "200": + description: Generated content + components: securitySchemes: BearerAuth: diff --git a/package-lock.json b/package-lock.json index cd4c822370..f5508831c9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "omniroute", - "version": "0.2.0", + "version": "0.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "omniroute", - "version": "0.2.0", + "version": "0.4.0", "license": "MIT", "workspaces": [ "open-sse" diff --git a/package.json b/package.json index 862bdb2d9c..2f3979aac5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "omniroute", - "version": "0.2.0", + "version": "0.4.0", "description": "Smart AI Router with auto fallback — route to FREE & cheap models, zero downtime. Works with Cursor, Cline, Claude Desktop, Codex, and any OpenAI-compatible tool.", "type": "module", "bin": { @@ -53,7 +53,7 @@ "test:fixes": "node --test tests/unit/fixes-p1.test.mjs", "test:security": "node --test tests/unit/security-fase01.test.mjs", "test:e2e": "npx playwright test", - "test:coverage": "npx c8 --check-coverage --lines 40 --functions 30 --branches 30 node --test tests/unit/*.test.mjs", + "test:coverage": "npx c8 --check-coverage --lines 60 --functions 50 --branches 50 node --test tests/unit/*.test.mjs", "test:all": "npm run test:unit && npm run test:e2e", "check": "npm run lint && npm run test", "prepublishOnly": "npm run build:cli", diff --git a/src/app/(dashboard)/dashboard/health/page.js b/src/app/(dashboard)/dashboard/health/page.js index b2d8bfa842..6d82aa7523 100644 --- a/src/app/(dashboard)/dashboard/health/page.js +++ b/src/app/(dashboard)/dashboard/health/page.js @@ -118,6 +118,8 @@ export default function HealthPage() { {/* Status Banner */}
{/* Provider Health */} - +

health_and_safety diff --git a/src/app/(dashboard)/dashboard/providers/page.js b/src/app/(dashboard)/dashboard/providers/page.js index 3babdb84ad..478c63fcd8 100644 --- a/src/app/(dashboard)/dashboard/providers/page.js +++ b/src/app/(dashboard)/dashboard/providers/page.js @@ -212,6 +212,7 @@ export default function ProvidersPage() { : "bg-bg-subtle border-border text-text-muted hover:text-text-primary hover:border-primary/40" }`} title="Test all OAuth connections" + aria-label="Test all OAuth connections" > {testingMode === "oauth" ? "sync" : "play_arrow"} @@ -244,6 +245,7 @@ export default function ProvidersPage() { : "bg-bg-subtle border-border text-text-muted hover:text-text-primary hover:border-primary/40" }`} title="Test all Free connections" + aria-label="Test all Free provider connections" > {testingMode === "free" ? "sync" : "play_arrow"} @@ -276,6 +278,7 @@ export default function ProvidersPage() { : "bg-bg-subtle border-border text-text-muted hover:text-text-primary hover:border-primary/40" }`} title="Test all API Key connections" + aria-label="Test all API Key connections" > {testingMode === "apikey" ? "sync" : "play_arrow"} @@ -386,6 +389,7 @@ export default function ProvidersPage() { diff --git a/src/app/global-error.js b/src/app/global-error.js index db7051860d..fe91c1d36b 100644 --- a/src/app/global-error.js +++ b/src/app/global-error.js @@ -25,10 +25,7 @@ export default function GlobalError({ error, reset }) { )} diff --git a/src/app/layout.js b/src/app/layout.js index 9f12dfe268..af6fec6faf 100644 --- a/src/app/layout.js +++ b/src/app/layout.js @@ -30,6 +30,12 @@ export default function RootLayout({ children }) { /> + + Skip to content + {children} diff --git a/src/lib/db/encryption.js b/src/lib/db/encryption.js new file mode 100644 index 0000000000..353f333103 --- /dev/null +++ b/src/lib/db/encryption.js @@ -0,0 +1,148 @@ +// @ts-check +/** + * Field-Level Encryption — AES-256-GCM + * + * Encrypts/decrypts sensitive fields (API keys, tokens) stored in SQLite. + * Format: `enc:v1:::` + * + * If STORAGE_ENCRYPTION_KEY is not set, operates in passthrough mode + * (stores plaintext for development convenience). + * + * @module lib/db/encryption + */ + +import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from "crypto"; + +const ALGORITHM = "aes-256-gcm"; +const IV_LENGTH = 16; +const KEY_LENGTH = 32; +const PREFIX = "enc:v1:"; + +/** @type {Buffer|null} */ +let _derivedKey = null; + +/** + * Derive a 256-bit key from the env secret using scrypt. + * Returns null if no encryption key is configured. + * @returns {Buffer|null} + */ +function getKey() { + if (_derivedKey !== null) return _derivedKey; + + const secret = process.env.STORAGE_ENCRYPTION_KEY; + if (!secret) return null; + + // Fixed salt derived from app name — deterministic so same key always produces same derived key + const salt = "omniroute-field-encryption-v1"; + _derivedKey = scryptSync(secret, salt, KEY_LENGTH); + return _derivedKey; +} + +/** + * Check if encryption is enabled. + * @returns {boolean} + */ +export function isEncryptionEnabled() { + return !!process.env.STORAGE_ENCRYPTION_KEY; +} + +/** + * Encrypt a plaintext string. Returns ciphertext with prefix. + * If encryption is not configured, returns plaintext unchanged. + * @param {string|null|undefined} plaintext + * @returns {string|null|undefined} + */ +export function encrypt(plaintext) { + if (!plaintext || typeof plaintext !== "string") return plaintext; + + const key = getKey(); + if (!key) return plaintext; // passthrough mode + + // Already encrypted — don't double-encrypt + if (plaintext.startsWith(PREFIX)) return plaintext; + + const iv = randomBytes(IV_LENGTH); + const cipher = createCipheriv(ALGORITHM, key, iv); + + let encrypted = cipher.update(plaintext, "utf8", "hex"); + encrypted += cipher.final("hex"); + const authTag = cipher.getAuthTag().toString("hex"); + + return `${PREFIX}${iv.toString("hex")}:${encrypted}:${authTag}`; +} + +/** + * Decrypt a ciphertext string. If not encrypted (no prefix), returns as-is. + * @param {string|null|undefined} ciphertext + * @returns {string|null|undefined} + */ +export function decrypt(ciphertext) { + if (!ciphertext || typeof ciphertext !== "string") return ciphertext; + + // Not encrypted — return as-is (legacy plaintext or passthrough mode) + if (!ciphertext.startsWith(PREFIX)) return ciphertext; + + const key = getKey(); + if (!key) { + console.warn( + "[Encryption] Found encrypted data but STORAGE_ENCRYPTION_KEY is not set. Cannot decrypt." + ); + return ciphertext; + } + + const body = ciphertext.slice(PREFIX.length); + const parts = body.split(":"); + if (parts.length !== 3) { + console.error("[Encryption] Malformed encrypted value"); + return ciphertext; + } + + const [ivHex, encryptedHex, authTagHex] = parts; + + try { + const iv = Buffer.from(ivHex, "hex"); + const authTag = Buffer.from(authTagHex, "hex"); + const decipher = createDecipheriv(ALGORITHM, key, iv); + decipher.setAuthTag(authTag); + + let decrypted = decipher.update(encryptedHex, "hex", "utf8"); + decrypted += decipher.final("utf8"); + return decrypted; + } catch (err) { + console.error("[Encryption] Decryption failed:", err.message); + return ciphertext; + } +} + +/** + * Encrypt sensitive fields in a connection object (mutates in-place). + * @param {object} conn + * @returns {object} The same object with encrypted fields + */ +export function encryptConnectionFields(conn) { + if (!isEncryptionEnabled()) return conn; + + if (conn.apiKey) conn.apiKey = encrypt(conn.apiKey); + if (conn.accessToken) conn.accessToken = encrypt(conn.accessToken); + if (conn.refreshToken) conn.refreshToken = encrypt(conn.refreshToken); + if (conn.idToken) conn.idToken = encrypt(conn.idToken); + return conn; +} + +/** + * Decrypt sensitive fields in a connection row (returns new object). + * @param {object|null} row + * @returns {object|null} + */ +export function decryptConnectionFields(row) { + if (!row) return row; + if (!isEncryptionEnabled()) return row; + + return { + ...row, + apiKey: decrypt(row.apiKey), + accessToken: decrypt(row.accessToken), + refreshToken: decrypt(row.refreshToken), + idToken: decrypt(row.idToken), + }; +} diff --git a/src/lib/db/providers.js b/src/lib/db/providers.js index 202a510b5b..1c1c3c055d 100644 --- a/src/lib/db/providers.js +++ b/src/lib/db/providers.js @@ -5,6 +5,7 @@ import { v4 as uuidv4 } from "uuid"; import { getDbInstance, rowToCamel, cleanNulls } from "./core.js"; import { backupDbFile } from "./backup.js"; +import { encryptConnectionFields, decryptConnectionFields } from "./encryption.js"; // ──────────────── Provider Connections ──────────────── @@ -29,13 +30,13 @@ export async function getProviderConnections(filter = {}) { sql += " ORDER BY priority ASC, updated_at DESC"; const rows = db.prepare(sql).all(params); - return rows.map((r) => cleanNulls(rowToCamel(r))); + return rows.map((r) => decryptConnectionFields(cleanNulls(rowToCamel(r)))); } export async function getProviderConnectionById(id) { const db = getDbInstance(); const row = db.prepare("SELECT * FROM provider_connections WHERE id = ?").get(id); - return row ? cleanNulls(rowToCamel(row)) : null; + return row ? decryptConnectionFields(cleanNulls(rowToCamel(row))) : null; } export async function createProviderConnection(data) { @@ -143,7 +144,7 @@ export async function createProviderConnection(data) { connection.providerSpecificData = data.providerSpecificData; } - _insertConnectionRow(db, connection); + _insertConnectionRow(db, encryptConnectionFields({ ...connection })); _reorderConnections(db, data.provider); backupDbFile("pre-write"); @@ -285,7 +286,7 @@ export async function updateProviderConnection(id, data) { if (!existing) return null; const merged = { ...rowToCamel(existing), ...data, updatedAt: new Date().toISOString() }; - _updateConnectionRow(db, id, merged); + _updateConnectionRow(db, id, encryptConnectionFields({ ...merged })); backupDbFile("pre-write"); if (data.priority !== undefined) { diff --git a/src/lib/initCloudSync.js b/src/lib/initCloudSync.js index 9ac426290b..70cf19e99c 100644 --- a/src/lib/initCloudSync.js +++ b/src/lib/initCloudSync.js @@ -17,6 +17,6 @@ export async function ensureCloudSyncInitialized() { } // Auto-initialize when module loads -ensureCloudSyncInitialized().catch(console.log); +ensureCloudSyncInitialized().catch((err) => console.error("[CloudSync] ensure failed:", err)); export default ensureCloudSyncInitialized; diff --git a/src/middleware/promptInjectionGuard.js b/src/middleware/promptInjectionGuard.js new file mode 100644 index 0000000000..c633bfc59d --- /dev/null +++ b/src/middleware/promptInjectionGuard.js @@ -0,0 +1,118 @@ +// @ts-check +/** + * Prompt Injection Guard — Express/Next.js middleware + * + * Wraps the inputSanitizer module as middleware for API routes. + * Blocks or warns on detected prompt injection attempts. + * + * @module middleware/promptInjectionGuard + */ + +import { sanitizeRequest } from "../shared/utils/inputSanitizer.js"; + +/** + * @typedef {Object} GuardOptions + * @property {"block"|"warn"|"log"} [mode="warn"] - Action on detection + * @property {Object} [logger] - Logger instance (defaults to console) + */ + +/** + * Create a prompt injection guard middleware. + * + * @param {GuardOptions} [options={}] + * @returns {(req: Request) => { blocked: boolean, result: Object }|null} + */ +export function createInjectionGuard(options = {}) { + const mode = options.mode || process.env.INJECTION_GUARD_MODE || "warn"; + const logger = options.logger || console; + + /** + * Check a request body for prompt injection. + * + * @param {Object} body - The parsed request body + * @returns {{ blocked: boolean, result: import("../shared/utils/inputSanitizer.js").SanitizeResult }} + */ + return function guardRequest(body) { + if (!body || typeof body !== "object") { + return { blocked: false, result: { flagged: false, detections: [], piiDetections: [] } }; + } + + const result = sanitizeRequest(body, logger); + + if (!result.flagged) { + return { blocked: false, result }; + } + + const highSeverity = result.detections.filter((d) => d.severity === "high"); + + if (mode === "block" && highSeverity.length > 0) { + logger.warn("[InjectionGuard] Blocked request with high-severity injection:", { + detections: result.detections.map((d) => ({ pattern: d.pattern, severity: d.severity })), + }); + return { blocked: true, result }; + } + + if (mode === "warn" || mode === "log") { + logger[mode === "warn" ? "warn" : "info"]( + "[InjectionGuard] Detected potential injection patterns:", + { + detections: result.detections.map((d) => ({ pattern: d.pattern, severity: d.severity })), + pii: result.piiDetections.length, + } + ); + } + + return { blocked: false, result }; + }; +} + +/** + * Next.js API route handler wrapper for injection guarding. + * + * @param {Function} handler - Original route handler + * @param {GuardOptions} [options={}] + * @returns {Function} Wrapped handler + */ +export function withInjectionGuard(handler, options = {}) { + const guard = createInjectionGuard(options); + + return async function guardedHandler(request, context) { + // Only apply to POST/PUT/PATCH + if (!["POST", "PUT", "PATCH"].includes(request.method)) { + return handler(request, context); + } + + try { + // Clone request so body can still be read by handler + const cloned = request.clone(); + const body = await cloned.json().catch(() => null); + + if (body) { + const { blocked, result } = guard(body); + + if (blocked) { + return new Response( + JSON.stringify({ + error: { + message: "Request blocked: potential prompt injection detected", + type: "injection_detected", + detections: result.detections.length, + }, + }), + { status: 400, headers: { "Content-Type": "application/json" } } + ); + } + + // Attach sanitization result as header for downstream handlers + if (result.flagged) { + request.headers.set("X-Injection-Flagged", "true"); + request.headers.set("X-Injection-Detections", String(result.detections.length)); + } + } + } catch { + // Don't block on guard errors — fail open + } + + return handler(request, context); + }; +} diff --git a/src/shared/components/CloudSyncStatus.js b/src/shared/components/CloudSyncStatus.js new file mode 100644 index 0000000000..22cea69f96 --- /dev/null +++ b/src/shared/components/CloudSyncStatus.js @@ -0,0 +1,77 @@ +"use client"; + +/** + * CloudSyncStatus — Compact sync status indicator for the sidebar + * + * Shows cloud sync connection state with a small icon + label. + * Fetches status from /api/sync/cloud periodically. + * + * @module shared/components/CloudSyncStatus + */ + +import { useState, useEffect, useRef } from "react"; + +const STATUS_CONFIG = { + connected: { icon: "cloud_done", color: "text-green-500", label: "Synced" }, + syncing: { icon: "cloud_sync", color: "text-blue-400 animate-pulse", label: "Syncing..." }, + disconnected: { icon: "cloud_off", color: "text-text-muted", label: "Offline" }, + error: { icon: "cloud_off", color: "text-red-400", label: "Error" }, + disabled: { icon: "cloud_off", color: "text-text-muted/50", label: "Disabled" }, +}; + +export default function CloudSyncStatus({ collapsed = false }) { + const [status, setStatus] = useState("disabled"); + const [lastSync, setLastSync] = useState(null); + const mountedRef = useRef(true); + + useEffect(() => { + mountedRef.current = true; + + async function poll() { + try { + const res = await fetch("/api/sync/cloud"); + if (!mountedRef.current) return; + if (!res.ok) { + setStatus("disconnected"); + return; + } + const data = await res.json(); + if (!mountedRef.current) return; + + if (!data.enabled) setStatus("disabled"); + else if (data.syncing) setStatus("syncing"); + else if (data.connected || data.lastSync) { + setStatus("connected"); + if (data.lastSync) setLastSync(new Date(data.lastSync)); + } else setStatus("disconnected"); + } catch { + if (mountedRef.current) setStatus("disconnected"); + } + } + + poll(); + const interval = setInterval(poll, 30000); + return () => { + mountedRef.current = false; + clearInterval(interval); + }; + }, []); + + // Don't render if cloud sync is disabled + if (status === "disabled") return null; + + const config = STATUS_CONFIG[status]; + + return ( +
+ + {!collapsed && {config.label}} +
+ ); +} diff --git a/src/shared/components/Sidebar.js b/src/shared/components/Sidebar.js index 0c355a844e..7a401c4250 100644 --- a/src/shared/components/Sidebar.js +++ b/src/shared/components/Sidebar.js @@ -8,6 +8,7 @@ import { cn } from "@/shared/utils/cn"; import { APP_CONFIG } from "@/shared/constants/config"; import Button from "./Button"; import { ConfirmModal } from "./Modal"; +import CloudSyncStatus from "./CloudSyncStatus"; const navItems = [ { href: "/dashboard/endpoint", label: "Endpoint", icon: "api" }, @@ -244,6 +245,9 @@ export default function Sidebar({ onClose, collapsed = false, onToggleCollapse }

+ {/* Cloud sync status indicator */} + + {/* Footer — Shutdown + Restart */}
0) return `${d}d ${h}h`; + if (h > 0) return `${h}h ${m}m`; + return `${m}m`; +} + +function formatBytes(bytes) { + if (!bytes) return "0 B"; + const units = ["B", "KB", "MB", "GB"]; + let i = 0; + let val = bytes; + while (val >= 1024 && i < units.length - 1) { + val /= 1024; + i++; + } + return `${val.toFixed(1)} ${units[i]}`; +} + +function MetricRow({ icon, label, value, color = "text-text-main" }) { + return ( +
+
+ + {label} +
+ {value} +
+ ); +} + +export default function SystemMonitor({ compact = false }) { + const [metrics, setMetrics] = useState(null); + const [error, setError] = useState(false); + const mountedRef = useRef(true); + + useEffect(() => { + mountedRef.current = true; + + async function poll() { + try { + const res = await fetch("/api/monitoring/health"); + if (!mountedRef.current) return; + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data = await res.json(); + if (!mountedRef.current) return; + setMetrics(data); + setError(false); + } catch { + if (mountedRef.current) setError(true); + } + } + + poll(); + const interval = setInterval(poll, REFRESH_INTERVAL); + return () => { + mountedRef.current = false; + clearInterval(interval); + }; + }, []); + + if (error && !metrics) { + return ( + +
+ error + Unable to load system metrics +
+
+ ); + } + + if (!metrics) { + return ( + +
+ + ); + } + + const memUsed = metrics.memory?.heapUsed || metrics.memoryUsage?.heapUsed || 0; + const memTotal = metrics.memory?.heapTotal || metrics.memoryUsage?.heapTotal || 1; + const memPercent = Math.round((memUsed / memTotal) * 100); + const memColor = + memPercent > 80 ? "text-red-400" : memPercent > 60 ? "text-amber-400" : "text-green-400"; + + if (compact) { + return ( +
+ + {memPercent}% mem + + + ⏱ {formatUptime(metrics.uptime)} + +
+ ); + } + + return ( + +

+ + System Monitor +

+ +
+ + + {metrics.version && ( + + )} + {metrics.activeConnections !== undefined && ( + + )} + {metrics.circuitBreakers && ( + 0 ? "text-amber-400" : "text-green-400"} + /> + )} +
+ + {/* Memory bar */} +
+
+
80 ? "bg-red-400" : memPercent > 60 ? "bg-amber-400" : "bg-green-400" + }`} + style={{ width: `${memPercent}%` }} + role="progressbar" + aria-valuenow={memPercent} + aria-valuemin={0} + aria-valuemax={100} + aria-label={`Memory usage: ${memPercent}%`} + /> +
+
+ + ); +} diff --git a/src/shared/components/Tooltip.js b/src/shared/components/Tooltip.js new file mode 100644 index 0000000000..d870bb245e --- /dev/null +++ b/src/shared/components/Tooltip.js @@ -0,0 +1,61 @@ +"use client"; + +/** + * Tooltip — Lightweight CSS-only Tooltip Component + * + * Uses the `title` attribute enhanced with custom CSS positioning. + * Renders a positioned tooltip on hover with smooth fade-in. + * + * @module shared/components/Tooltip + */ + +import { useState, useRef, useCallback } from "react"; + +/** + * @param {object} props + * @param {React.ReactNode} props.children - Element to wrap + * @param {string} props.content - Tooltip text + * @param {"top"|"bottom"|"left"|"right"} [props.position="top"] - Position + * @param {string} [props.className] - Additional className for wrapper + */ +export default function Tooltip({ children, content, position = "top", className = "" }) { + const [visible, setVisible] = useState(false); + const timeoutRef = useRef(null); + + const show = useCallback(() => { + clearTimeout(timeoutRef.current); + timeoutRef.current = setTimeout(() => setVisible(true), 200); + }, []); + + const hide = useCallback(() => { + clearTimeout(timeoutRef.current); + setVisible(false); + }, []); + + const positionClasses = { + top: "bottom-full left-1/2 -translate-x-1/2 mb-2", + bottom: "top-full left-1/2 -translate-x-1/2 mt-2", + left: "right-full top-1/2 -translate-y-1/2 mr-2", + right: "left-full top-1/2 -translate-y-1/2 ml-2", + }; + + return ( + + {children} + {visible && content && ( + + {content} + + )} + + ); +} diff --git a/src/shared/components/analytics/index.js b/src/shared/components/analytics/index.js index a82902650a..9a00af4721 100644 --- a/src/shared/components/analytics/index.js +++ b/src/shared/components/analytics/index.js @@ -1,4 +1,16 @@ +/** + * Analytics Charts — Barrel export + * + * TODO (#5): Split charts.js into individual component files: + * - StatCard.js, ActivityHeatmap.js, DailyTrendChart.js + * - AccountDonut.js, ApiKeyDonut.js, ProviderCostDonut.js + * - ApiKeyTable.js, ModelTable.js + * - WeeklyPattern.js, MostActiveDay7d.js, WeeklySquares7d.js + * - UsageDetail.js, CostTooltip.js, DarkTooltip.js, SortIndicator.js + */ export { + DarkTooltip, + CostTooltip, SortIndicator, StatCard, ActivityHeatmap, diff --git a/src/shared/schemas/validation.js b/src/shared/schemas/validation.js new file mode 100644 index 0000000000..ee25fcff09 --- /dev/null +++ b/src/shared/schemas/validation.js @@ -0,0 +1,145 @@ +// @ts-check +/** + * Zod Validation Schemas — Shared request schemas for API routes + * + * Provides runtime input validation for provider, combo, and settings APIs. + * + * @module shared/schemas/validation + */ + +import { z } from "zod"; + +// ─── Provider Connection ────────────────────────────────────────── + +export const providerConnectionSchema = z.object({ + provider: z.string().min(1, "Provider name is required"), + authType: z.enum(["oauth", "apikey", "free"]).optional(), + name: z.string().optional(), + email: z.string().email().optional().or(z.literal("")), + apiKey: z.string().optional(), + accessToken: z.string().optional(), + refreshToken: z.string().optional(), + isActive: z.boolean().default(true), + priority: z.number().int().min(0).default(0), + defaultModel: z.string().optional(), + globalPriority: z.number().int().min(0).optional().nullable(), + rateLimitProtection: z.boolean().default(false), + displayName: z.string().optional(), +}); + +// ─── Combo / Routing Rule ───────────────────────────────────────── + +export const comboNodeSchema = z.object({ + connectionId: z.string().uuid("Invalid connection ID"), + weight: z.number().int().min(0).max(100).default(1), + priority: z.number().int().min(0).default(0), +}); + +export const comboSchema = z.object({ + name: z.string().min(1, "Combo name is required").max(100), + model: z.string().min(1, "Model pattern is required"), + strategy: z.enum(["priority", "weighted", "round-robin", "cost-optimized"]).default("priority"), + nodes: z.array(comboNodeSchema).min(1, "At least one node is required"), + isActive: z.boolean().default(true), + maxRetries: z.number().int().min(0).max(10).default(2), + retryDelay: z.number().int().min(0).max(30000).default(1000), +}); + +// ─── API Key ────────────────────────────────────────────────────── + +export const apiKeyCreateSchema = z.object({ + label: z.string().min(1, "Label is required").max(64), +}); + +// ─── Settings ───────────────────────────────────────────────────── + +export const settingsSchema = z + .object({ + requireLogin: z.boolean().optional(), + password: z.string().min(6, "Password must be at least 6 characters").optional(), + defaultModel: z.string().optional(), + enableRequestLogs: z.boolean().optional(), + maxLogRetention: z.number().int().min(1).max(365).optional(), + rateLimitEnabled: z.boolean().optional(), + rateLimitPerMinute: z.number().int().min(0).optional(), + }) + .partial(); + +// ─── Proxy Settings ─────────────────────────────────────────────── + +export const proxySettingsSchema = z.object({ + enabled: z.boolean(), + url: z.string().url("Invalid proxy URL").optional().or(z.literal("")), + username: z.string().optional(), + password: z.string().optional(), + bypassList: z.array(z.string()).optional(), +}); + +// ─── Resilience Profile ─────────────────────────────────────────── + +export const resilienceProfileSchema = z.object({ + provider: z.string().min(1), + circuitBreaker: z + .object({ + failureThreshold: z.number().int().min(1).max(100).default(5), + resetTimeoutMs: z.number().int().min(1000).max(600000).default(30000), + halfOpenMax: z.number().int().min(1).max(10).default(1), + }) + .optional(), + backoff: z + .object({ + initialDelayMs: z.number().int().min(100).max(60000).default(1000), + maxDelayMs: z.number().int().min(1000).max(600000).default(60000), + multiplier: z.number().min(1).max(10).default(2), + }) + .optional(), + rateLimit: z + .object({ + requestsPerMinute: z.number().int().min(0).optional(), + tokensPerMinute: z.number().int().min(0).optional(), + }) + .optional(), +}); + +// ─── Chat Completion Request (basic validation) ─────────────────── + +export const chatCompletionSchema = z.object({ + model: z.string().min(1, "Model is required"), + messages: z + .array( + z.object({ + role: z.enum(["system", "user", "assistant", "tool"]), + content: z.union([z.string(), z.array(z.any())]), + }) + ) + .min(1, "At least one message is required"), + stream: z.boolean().optional(), + temperature: z.number().min(0).max(2).optional(), + max_tokens: z.number().int().min(1).optional(), + top_p: z.number().min(0).max(1).optional(), +}); + +// ─── Helper ─────────────────────────────────────────────────────── + +/** + * Validate data against a schema and format errors for API responses. + * + * @template T + * @param {z.ZodSchema} schema + * @param {unknown} data + * @returns {{ success: true, data: T } | { success: false, errors: Array<{ path: string, message: string }> }} + */ +export function validateSchema(schema, data) { + const result = schema.safeParse(data); + if (result.success) { + return { success: true, data: result.data }; + } + + return { + success: false, + errors: result.error.issues.map((issue) => ({ + path: issue.path.join("."), + message: issue.message, + })), + }; +} diff --git a/src/shared/services/initializeCloudSync.js b/src/shared/services/initializeCloudSync.js index 942e4b460a..78fdc7b4ad 100644 --- a/src/shared/services/initializeCloudSync.js +++ b/src/shared/services/initializeCloudSync.js @@ -25,7 +25,7 @@ export async function initializeCloudSync() { // For development/testing purposes if (typeof require !== "undefined" && require.main === module) { - initializeCloudSync().catch(console.log); + initializeCloudSync().catch((err) => console.error("[CloudSync] init failed:", err)); } export default initializeCloudSync; diff --git a/src/shared/utils/a11yAudit.js b/src/shared/utils/a11yAudit.js index 6575f855c7..2312072c7f 100644 --- a/src/shared/utils/a11yAudit.js +++ b/src/shared/utils/a11yAudit.js @@ -67,6 +67,82 @@ export function auditHTML(html) { return violations; } +/** + * Parse a hex color string to RGB components. + * @param {string} hex - Color in #RGB, #RRGGBB, or #RRGGBBAA format + * @returns {{ r: number, g: number, b: number }|null} + */ +function parseHexColor(hex) { + if (!hex || typeof hex !== "string") return null; + const clean = hex.replace(/^#/, ""); + + let r, g, b; + if (clean.length === 3) { + r = parseInt(clean[0] + clean[0], 16); + g = parseInt(clean[1] + clean[1], 16); + b = parseInt(clean[2] + clean[2], 16); + } else if (clean.length === 6 || clean.length === 8) { + r = parseInt(clean.slice(0, 2), 16); + g = parseInt(clean.slice(2, 4), 16); + b = parseInt(clean.slice(4, 6), 16); + } else { + return null; + } + + return { r, g, b }; +} + +/** + * Compute relative luminance per WCAG 2.x specification. + * @param {{ r: number, g: number, b: number }} rgb + * @returns {number} Relative luminance (0..1) + */ +function relativeLuminance({ r, g, b }) { + const [sR, sG, sB] = [r, g, b].map((c) => { + const v = c / 255; + return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); + }); + return 0.2126 * sR + 0.7152 * sG + 0.0722 * sB; +} + +/** + * Get the contrast ratio between two hex colors. + * @param {string} fgHex - Foreground color (#RRGGBB) + * @param {string} bgHex - Background color (#RRGGBB) + * @returns {number} Contrast ratio (1..21) + */ +export function getContrastRatio(fgHex, bgHex) { + const fg = parseHexColor(fgHex); + const bg = parseHexColor(bgHex); + if (!fg || !bg) return 0; + + const l1 = relativeLuminance(fg); + const l2 = relativeLuminance(bg); + const lighter = Math.max(l1, l2); + const darker = Math.min(l1, l2); + return (lighter + 0.05) / (darker + 0.05); +} + +/** + * Check WCAG AA contrast compliance between foreground and background colors. + * + * @param {string} fgHex - Foreground color (#RRGGBB) + * @param {string} bgHex - Background color (#RRGGBB) + * @param {{ largeText?: boolean }} [options={}] - Options + * @returns {{ ratio: number, aa: boolean, aaa: boolean }} + */ +export function checkContrast(fgHex, bgHex, options = {}) { + const ratio = getContrastRatio(fgHex, bgHex); + const minAA = options.largeText ? 3 : 4.5; + const minAAA = options.largeText ? 4.5 : 7; + + return { + ratio: Math.round(ratio * 100) / 100, + aa: ratio >= minAA, + aaa: ratio >= minAAA, + }; +} + /** * Generate a summary report from a list of violations. * diff --git a/src/shared/utils/costEstimator.js b/src/shared/utils/costEstimator.js new file mode 100644 index 0000000000..3aaefb65aa --- /dev/null +++ b/src/shared/utils/costEstimator.js @@ -0,0 +1,133 @@ +// @ts-check +/** + * Cost Estimator — Pre-flight cost estimation for LLM requests + * + * Estimates token-based costs before routing to a provider. + * Uses pricing data from the dashboard/database. + * + * @module shared/utils/costEstimator + */ + +/** + * Default pricing per 1M tokens (fallback when no pricing config exists). + * Values in USD. + */ +const DEFAULT_PRICING = { + "gpt-4o": { input: 2.5, output: 10.0 }, + "gpt-4o-mini": { input: 0.15, output: 0.6 }, + "gpt-4.1": { input: 2.0, output: 8.0 }, + "gpt-4.1-mini": { input: 0.4, output: 1.6 }, + "gpt-4.1-nano": { input: 0.1, output: 0.4 }, + o3: { input: 2.0, output: 8.0 }, + "o4-mini": { input: 1.1, output: 4.4 }, + "claude-sonnet-4-5-20250514": { input: 3.0, output: 15.0 }, + "claude-3-5-haiku-20241022": { input: 0.8, output: 4.0 }, + "gemini-2.5-pro": { input: 1.25, output: 10.0 }, + "gemini-2.5-flash": { input: 0.15, output: 0.6 }, +}; + +/** + * Rough token estimation from text. + * Uses ~4 chars per token approximation (GPT-family average). + * + * @param {string} text + * @returns {number} Estimated token count + */ +export function estimateTokens(text) { + if (!text || typeof text !== "string") return 0; + return Math.ceil(text.length / 4); +} + +/** + * Estimate input tokens from a chat completion request body. + * + * @param {Object} body - Request body + * @param {Array<{role: string, content: string|Array<{type: string, text?: string}>}>} [body.messages] + * @param {string} [body.system] + * @returns {number} Estimated input token count + */ +export function estimateInputTokens(body) { + if (!body) return 0; + let total = 0; + + if (body.system) total += estimateTokens(body.system); + + if (Array.isArray(body.messages)) { + for (const msg of body.messages) { + if (typeof msg.content === "string") { + total += estimateTokens(msg.content); + } else if (Array.isArray(msg.content)) { + for (const part of msg.content) { + if (part.type === "text" && typeof part.text === "string") { + total += estimateTokens(part.text); + } + } + } + // Add ~4 tokens overhead per message (role, separators) + total += 4; + } + } + + return total; +} + +/** + * Estimate the cost of a request given a model. + * + * @param {Object} params + * @param {string} params.model - Model identifier + * @param {number} params.inputTokens - Estimated input tokens + * @param {number} [params.maxOutputTokens=1000] - Max output tokens + * @param {Object} [params.pricingOverrides] - Custom pricing { input, output } per 1M tokens + * @returns {{ inputCost: number, outputCost: number, totalCost: number, model: string, inputTokens: number, outputTokens: number }} + */ +export function estimateCost({ model, inputTokens, maxOutputTokens = 1000, pricingOverrides }) { + // Find matching pricing (exact match or prefix match) + let pricing = pricingOverrides; + if (!pricing) { + const key = Object.keys(DEFAULT_PRICING).find((k) => model === k || model.startsWith(k)); + pricing = key ? DEFAULT_PRICING[key] : { input: 1.0, output: 3.0 }; // conservative fallback + } + + const inputCost = (inputTokens / 1_000_000) * pricing.input; + const outputCost = (maxOutputTokens / 1_000_000) * pricing.output; + const totalCost = inputCost + outputCost; + + return { + model, + inputTokens, + outputTokens: maxOutputTokens, + inputCost: Math.round(inputCost * 1_000_000) / 1_000_000, + outputCost: Math.round(outputCost * 1_000_000) / 1_000_000, + totalCost: Math.round(totalCost * 1_000_000) / 1_000_000, + }; +} + +/** + * Format a cost value for display. + * @param {number} usd + * @returns {string} + */ +export function formatCost(usd) { + if (usd < 0.01) return `$${(usd * 100).toFixed(4)}¢`; + return `$${usd.toFixed(4)}`; +} + +/** + * Quick pre-flight estimate: given a request body and model, return estimated cost. + * + * @param {Object} body - Chat completion request body + * @param {string} model - Target model + * @param {Object} [pricingOverrides] - Optional pricing overrides + * @returns {{ inputCost: number, outputCost: number, totalCost: number, formatted: string }} + */ +export function preflightEstimate(body, model, pricingOverrides) { + const inputTokens = estimateInputTokens(body); + const maxOutput = body.max_tokens || body.maxOutputTokens || 1000; + const result = estimateCost({ model, inputTokens, maxOutputTokens: maxOutput, pricingOverrides }); + + return { + ...result, + formatted: formatCost(result.totalCost), + }; +} diff --git a/src/shared/utils/streamTracker.js b/src/shared/utils/streamTracker.js new file mode 100644 index 0000000000..f5ab7e917e --- /dev/null +++ b/src/shared/utils/streamTracker.js @@ -0,0 +1,173 @@ +// @ts-check +/** + * Stream Tracker — Unified SSE stream monitoring + * + * Tracks token counts, latency, and errors during streaming responses. + * Emits periodic progress callbacks for real-time monitoring. + * + * @module shared/utils/streamTracker + */ + +/** + * @typedef {Object} StreamMetrics + * @property {number} startTime - Timestamp when stream started + * @property {number} firstTokenTime - Time to first token (ms) + * @property {number} totalTokens - Total tokens received + * @property {number} totalChunks - Total SSE chunks received + * @property {number} elapsedMs - Total elapsed time (ms) + * @property {number} tokensPerSecond - Current throughput + * @property {boolean} complete - Whether stream is complete + * @property {string|null} error - Error message if any + * @property {string|null} finishReason - Stop reason from provider + */ + +export class StreamTracker { + /** @param {{ onProgress?: (metrics: StreamMetrics) => void, progressIntervalMs?: number }} [options={}] */ + constructor(options = {}) { + this._onProgress = options.onProgress || null; + this._progressIntervalMs = options.progressIntervalMs || 500; + + this._startTime = Date.now(); + this._firstTokenTime = 0; + this._totalTokens = 0; + this._totalChunks = 0; + this._complete = false; + this._error = null; + this._finishReason = null; + this._lastProgressAt = 0; + this._buffer = ""; + } + + /** + * Record an incoming SSE chunk. + * @param {string|Object} chunk - Raw SSE text or parsed data + */ + onChunk(chunk) { + this._totalChunks++; + + if (this._totalChunks === 1) { + this._firstTokenTime = Date.now() - this._startTime; + } + + // Try to extract token count from chunk + let data = chunk; + if (typeof chunk === "string") { + // Parse SSE if formatted + if (chunk.startsWith("data: ")) { + const payload = chunk.slice(6).trim(); + if (payload === "[DONE]") { + this._complete = true; + this._emitProgress(); + return; + } + try { + data = JSON.parse(payload); + } catch { + data = null; + } + } + } + + if (data && typeof data === "object") { + // OpenAI format: choices[0].delta.content + const content = data.choices?.[0]?.delta?.content; + if (content) { + // Rough token estimate (~4 chars per token) + this._totalTokens += Math.ceil(content.length / 4); + } + + // Check for finish reason + const reason = data.choices?.[0]?.finish_reason; + if (reason) { + this._finishReason = reason; + } + + // Usage in final chunk (OpenAI includes this) + if (data.usage?.completion_tokens) { + this._totalTokens = data.usage.completion_tokens; + } + } + + this._maybeEmitProgress(); + } + + /** + * Mark stream as errored. + * @param {string|Error} error + */ + onError(error) { + this._error = typeof error === "string" ? error : error.message; + this._complete = true; + this._emitProgress(); + } + + /** Mark stream as complete. */ + onComplete() { + this._complete = true; + this._emitProgress(); + } + + /** @returns {StreamMetrics} Current metrics */ + getMetrics() { + const elapsedMs = Date.now() - this._startTime; + const tokensPerSecond = elapsedMs > 0 ? this._totalTokens / (elapsedMs / 1000) : 0; + + return { + startTime: this._startTime, + firstTokenTime: this._firstTokenTime, + totalTokens: this._totalTokens, + totalChunks: this._totalChunks, + elapsedMs, + tokensPerSecond: Math.round(tokensPerSecond * 10) / 10, + complete: this._complete, + error: this._error, + finishReason: this._finishReason, + }; + } + + /** @private */ + _maybeEmitProgress() { + const now = Date.now(); + if (now - this._lastProgressAt >= this._progressIntervalMs) { + this._emitProgress(); + } + } + + /** @private */ + _emitProgress() { + this._lastProgressAt = Date.now(); + if (this._onProgress) { + this._onProgress(this.getMetrics()); + } + } +} + +/** + * Create a TransformStream that tracks SSE progress. + * + * @param {{ onProgress?: (metrics: StreamMetrics) => void }} [options={}] + * @returns {{ stream: TransformStream, tracker: StreamTracker }} + */ +export function createStreamTracker(options = {}) { + const tracker = new StreamTracker(options); + + const stream = new TransformStream({ + transform(chunk, controller) { + const text = typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk); + const lines = text.split("\n"); + + for (const line of lines) { + if (line.startsWith("data: ")) { + tracker.onChunk(line); + } + } + + controller.enqueue(chunk); + }, + flush() { + tracker.onComplete(); + }, + }); + + return { stream, tracker }; +}