feat(core): implement 26 action items from critical analysis + bump v0.4.0

- Security: AES-256-GCM encryption for API keys/tokens, CI security audit
- Accessibility: ARIA labels, aria-live regions, skip-to-content, contrast utility
- Components: Tooltip, CloudSyncStatus, SystemMonitor, StreamTracker
- Utils: costEstimator, promptInjectionGuard middleware, Zod validation schemas
- Docs: openapi.yaml +9 routes, API_REFERENCE internal APIs, version bumps
- Quality: coverage thresholds 60/50/50, error handling improvements
This commit is contained in:
diegosouzapw
2026-02-15 12:33:56 -03:00
parent 6964904bac
commit 0e238a61fb
25 changed files with 1341 additions and 24 deletions

View File

@@ -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

View File

@@ -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
```
---

View File

@@ -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
```
---

View File

@@ -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

View File

@@ -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:

4
package-lock.json generated
View File

@@ -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"

View File

@@ -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",

View File

@@ -118,6 +118,8 @@ export default function HealthPage() {
{/* Status Banner */}
<div
role="status"
aria-live="polite"
className={`rounded-xl p-4 flex items-center gap-3 ${
data.status === "healthy"
? "bg-green-500/10 border border-green-500/20"
@@ -190,7 +192,7 @@ export default function HealthPage() {
</div>
{/* Provider Health */}
<Card className="p-5">
<Card className="p-5" role="region" aria-label="Provider health status">
<h2 className="text-lg font-semibold text-text-main mb-4 flex items-center gap-2">
<span className="material-symbols-outlined text-[20px] text-primary">
health_and_safety

View File

@@ -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"
>
<span className="material-symbols-outlined text-[14px]">
{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"
>
<span className="material-symbols-outlined text-[14px]">
{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"
>
<span className="material-symbols-outlined text-[14px]">
{testingMode === "apikey" ? "sync" : "play_arrow"}
@@ -386,6 +389,7 @@ export default function ProvidersPage() {
<button
onClick={() => setTestResults(null)}
className="p-1 rounded-lg hover:bg-bg-subtle text-text-muted hover:text-text-primary transition-colors"
aria-label="Close test results"
>
<span className="material-symbols-outlined text-lg">close</span>
</button>

View File

@@ -25,10 +25,7 @@ export default function GlobalError({ error, reset }) {
)}
<button
onClick={reset}
className="px-8 py-3 rounded-[10px] text-white border-none text-sm font-semibold cursor-pointer transition-transform duration-200 shadow-[0_4px_16px_rgba(99,102,241,0.3)] hover:-translate-y-0.5"
style={{
background: "linear-gradient(135deg, #6366f1, #8b5cf6)",
}}
className="px-8 py-3 rounded-[10px] text-white border-none text-sm font-semibold cursor-pointer transition-transform duration-200 shadow-[0_4px_16px_rgba(99,102,241,0.3)] hover:-translate-y-0.5 bg-gradient-to-br from-[#6366f1] to-[#8b5cf6]"
>
Try Again
</button>

View File

@@ -30,6 +30,12 @@ export default function RootLayout({ children }) {
/>
</head>
<body className={`${inter.variable} font-sans antialiased`} suppressHydrationWarning>
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:absolute focus:top-2 focus:left-2 focus:z-50 focus:px-4 focus:py-2 focus:bg-[#6366f1] focus:text-white focus:rounded-lg focus:text-sm focus:font-semibold focus:shadow-lg"
>
Skip to content
</a>
<ThemeProvider>{children}</ThemeProvider>
</body>
</html>

148
src/lib/db/encryption.js Normal file
View File

@@ -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:<iv_hex>:<ciphertext_hex>:<authTag_hex>`
*
* 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),
};
}

View File

@@ -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) {

View File

@@ -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;

View File

@@ -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);
};
}

View File

@@ -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 (
<div
className="flex items-center gap-2 px-3 py-1.5 text-xs rounded-lg hover:bg-white/5 transition-colors cursor-default"
title={lastSync ? `Last sync: ${lastSync.toLocaleTimeString()}` : config.label}
aria-label={`Cloud sync status: ${config.label}`}
>
<span className={`material-symbols-outlined text-[16px] ${config.color}`} aria-hidden="true">
{config.icon}
</span>
{!collapsed && <span className="text-text-muted truncate">{config.label}</span>}
</div>
);
}

View File

@@ -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 }
</div>
</nav>
{/* Cloud sync status indicator */}
<CloudSyncStatus collapsed={collapsed} />
{/* Footer — Shutdown + Restart */}
<div
className={cn(

View File

@@ -0,0 +1,176 @@
"use client";
/**
* SystemMonitor — Real-time system metrics widget
*
* Displays CPU/memory/uptime and token throughput metrics.
* Fetches from /api/monitoring/health periodically.
*
* @module shared/components/SystemMonitor
*/
import { useState, useEffect, useRef } from "react";
import Card from "./Card";
const REFRESH_INTERVAL = 10000; // 10s
function formatUptime(seconds) {
if (!seconds) return "N/A";
const d = Math.floor(seconds / 86400);
const h = Math.floor((seconds % 86400) / 3600);
const m = Math.floor((seconds % 3600) / 60);
if (d > 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 (
<div className="flex items-center justify-between py-1.5">
<div className="flex items-center gap-2 text-text-muted">
<span className="material-symbols-outlined text-[16px]" aria-hidden="true">
{icon}
</span>
<span className="text-sm">{label}</span>
</div>
<span className={`text-sm font-mono font-medium ${color}`}>{value}</span>
</div>
);
}
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 (
<Card className="p-4">
<div className="flex items-center gap-2 text-text-muted text-sm">
<span className="material-symbols-outlined text-[18px] text-red-400">error</span>
<span>Unable to load system metrics</span>
</div>
</Card>
);
}
if (!metrics) {
return (
<Card className="p-4 animate-pulse">
<div className="h-24 bg-surface rounded-lg" />
</Card>
);
}
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 (
<div
className="flex items-center gap-4 text-xs text-text-muted"
role="status"
aria-label="System metrics"
>
<span title={`Memory: ${memPercent}%`}>
<span className={`font-mono font-medium ${memColor}`}>{memPercent}%</span> mem
</span>
<span title={`Uptime: ${formatUptime(metrics.uptime)}`}>
{formatUptime(metrics.uptime)}
</span>
</div>
);
}
return (
<Card className="p-4" role="region" aria-label="System monitoring">
<h3 className="text-sm font-semibold text-text-main mb-3 flex items-center gap-2">
<span className="material-symbols-outlined text-[18px] text-primary" aria-hidden="true">
monitoring
</span>
System Monitor
</h3>
<div className="space-y-0.5">
<MetricRow icon="timer" label="Uptime" value={formatUptime(metrics.uptime)} />
<MetricRow
icon="memory"
label="Memory"
value={`${formatBytes(memUsed)} / ${formatBytes(memTotal)} (${memPercent}%)`}
color={memColor}
/>
{metrics.version && (
<MetricRow icon="info" label="Version" value={metrics.version} color="text-primary" />
)}
{metrics.activeConnections !== undefined && (
<MetricRow icon="lan" label="Connections" value={String(metrics.activeConnections)} />
)}
{metrics.circuitBreakers && (
<MetricRow
icon="health_and_safety"
label="Circuit Breakers"
value={`${metrics.circuitBreakers.open || 0} open`}
color={metrics.circuitBreakers.open > 0 ? "text-amber-400" : "text-green-400"}
/>
)}
</div>
{/* Memory bar */}
<div className="mt-3">
<div className="h-1.5 bg-surface rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all duration-500 ${
memPercent > 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}%`}
/>
</div>
</div>
</Card>
);
}

View File

@@ -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 (
<span
className={`relative inline-flex ${className}`}
onMouseEnter={show}
onMouseLeave={hide}
onFocus={show}
onBlur={hide}
>
{children}
{visible && content && (
<span
role="tooltip"
className={`absolute z-50 px-2.5 py-1.5 text-xs font-medium text-white bg-gray-900/95 rounded-md shadow-lg whitespace-nowrap pointer-events-none animate-in fade-in duration-150 border border-white/10 ${positionClasses[position] || positionClasses.top}`}
>
{content}
</span>
)}
</span>
);
}

View File

@@ -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,

View File

@@ -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<T>} 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,
})),
};
}

View File

@@ -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;

View File

@@ -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.
*

View File

@@ -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),
};
}

View File

@@ -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 };
}