diff --git a/next.config.mjs b/next.config.mjs index e122958835..8fe8475d2b 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -6,9 +6,8 @@ const nextConfig = { transpilePackages: ["@omniroute/open-sse"], allowedDevOrigins: ["192.168.*"], typescript: { - // Migration Phase: ignore TS errors during build. - // Remove after all 984 type errors are resolved. - ignoreBuildErrors: true, + // All TS errors resolved — strict checking enforced + ignoreBuildErrors: false, }, images: { unoptimized: true, diff --git a/src/app/(dashboard)/dashboard/usage/components/EvalsTab.tsx b/src/app/(dashboard)/dashboard/usage/components/EvalsTab.tsx index ee24ffac6f..c6b8c9ccbf 100644 --- a/src/app/(dashboard)/dashboard/usage/components/EvalsTab.tsx +++ b/src/app/(dashboard)/dashboard/usage/components/EvalsTab.tsx @@ -184,8 +184,10 @@ export default function EvalsTab() { // Count total cases and unique models across all suites const totalCases = suites.reduce((sum, s) => sum + (s.cases?.length || s.caseCount || 0), 0); - const uniqueModels = [ - ...new Set(suites.flatMap((s) => (s.cases || []).map((c) => c.model).filter(Boolean))), + const uniqueModels: string[] = [ + ...new Set( + suites.flatMap((s: any) => (s.cases || []).map((c: any) => c.model)).filter(Boolean) + ), ]; if (loading) { @@ -393,8 +395,8 @@ export default function EvalsTab() { const caseCount = suite.cases?.length || suite.caseCount || 0; // Count unique models in this suite - const suiteModels = [ - ...new Set((suite.cases || []).map((c) => c.model).filter(Boolean)), + const suiteModels: string[] = [ + ...new Set((suite.cases || []).map((c: any) => c.model).filter(Boolean)), ]; return ( diff --git a/src/app/api/v1/images/generations/route.ts b/src/app/api/v1/images/generations/route.ts index a1daf7920a..8d7e2683b0 100644 --- a/src/app/api/v1/images/generations/route.ts +++ b/src/app/api/v1/images/generations/route.ts @@ -92,15 +92,15 @@ export async function POST(request) { const result = await handleImageGeneration({ body, credentials, log }); if (result.success) { - return new Response(JSON.stringify(result.data), { + return new Response(JSON.stringify((result as any).data), { status: 200, headers: { "Content-Type": "application/json" }, }); } - const errorPayload = toJsonErrorPayload(result.error, "Image generation provider error"); + const errorPayload = toJsonErrorPayload((result as any).error, "Image generation provider error"); return new Response(JSON.stringify(errorPayload), { - status: result.status, + status: (result as any).status, headers: { "Content-Type": "application/json" }, }); } diff --git a/src/app/api/v1/providers/[provider]/images/generations/route.ts b/src/app/api/v1/providers/[provider]/images/generations/route.ts index c04319ee23..ef5bbc512d 100644 --- a/src/app/api/v1/providers/[provider]/images/generations/route.ts +++ b/src/app/api/v1/providers/[provider]/images/generations/route.ts @@ -79,15 +79,15 @@ export async function POST(request, { params }) { const result = await handleImageGeneration({ body, credentials, log }); if (result.success) { - return new Response(JSON.stringify(result.data), { + return new Response(JSON.stringify((result as any).data), { status: 200, headers: { "Content-Type": "application/json" }, }); } - const errorPayload = toJsonErrorPayload(result.error, "Image generation provider error"); + const errorPayload = toJsonErrorPayload((result as any).error, "Image generation provider error"); return new Response(JSON.stringify(errorPayload), { - status: result.status, + status: (result as any).status, headers: { "Content-Type": "application/json" }, }); } diff --git a/src/instrumentation.ts b/src/instrumentation.ts new file mode 100644 index 0000000000..7d35c420a0 --- /dev/null +++ b/src/instrumentation.ts @@ -0,0 +1,16 @@ +/** + * Next.js Instrumentation Hook + * + * Called once when the server starts (both dev and production). + * Used to initialize graceful shutdown handlers. + * + * @see https://nextjs.org/docs/app/building-your-application/optimizing/instrumentation + */ + +export async function register() { + // Only run on the server (not during build or in Edge runtime) + if (process.env.NEXT_RUNTIME === "nodejs") { + const { initGracefulShutdown } = await import("@/lib/gracefulShutdown"); + initGracefulShutdown(); + } +} diff --git a/src/lib/gracefulShutdown.ts b/src/lib/gracefulShutdown.ts new file mode 100644 index 0000000000..c33a64acc4 --- /dev/null +++ b/src/lib/gracefulShutdown.ts @@ -0,0 +1,124 @@ +/** + * Graceful Shutdown — E-2 Critical Fix + * + * Handles SIGTERM / SIGINT to drain in-flight requests before exit. + * Critical for Docker containers and Kubernetes pods where hard kills + * can drop active SSE streams. + * + * Usage: + * import { initGracefulShutdown } from "@/lib/gracefulShutdown"; + * initGracefulShutdown(); + * + * @module lib/gracefulShutdown + */ + +/** Whether we are currently shutting down */ +let isShuttingDown = false; + +/** Number of in-flight requests being tracked */ +let activeRequests = 0; + +/** Grace period before forced exit (default 30s, configurable) */ +const SHUTDOWN_TIMEOUT_MS = parseInt(process.env.SHUTDOWN_TIMEOUT_MS || "30000", 10); + +/** + * Check if the server is currently shutting down. + * Route handlers can use this to reject new requests. + */ +export function isDraining(): boolean { + return isShuttingDown; +} + +/** + * Track a new in-flight request. Call `done()` when it completes. + * Returns a done callback. + */ +export function trackRequest(): () => void { + activeRequests++; + let called = false; + return () => { + if (!called) { + called = true; + activeRequests--; + } + }; +} + +/** + * Get current active request count (for monitoring/health endpoints). + */ +export function getActiveRequestCount(): number { + return activeRequests; +} + +/** + * Wait for all in-flight requests to complete, with timeout. + */ +async function waitForDrain(): Promise { + const start = Date.now(); + const CHECK_INTERVAL_MS = 250; + + return new Promise((resolve) => { + const check = () => { + if (activeRequests <= 0) { + console.log("[Shutdown] All in-flight requests drained."); + resolve(); + return; + } + + if (Date.now() - start > SHUTDOWN_TIMEOUT_MS) { + console.warn( + `[Shutdown] Timeout after ${SHUTDOWN_TIMEOUT_MS}ms with ${activeRequests} active requests. Forcing exit.` + ); + resolve(); + return; + } + + console.log(`[Shutdown] Waiting for ${activeRequests} in-flight request(s)...`); + setTimeout(check, CHECK_INTERVAL_MS); + }; + + check(); + }); +} + +/** + * Perform cleanup: close DB connections, flush logs. + */ +async function cleanup(): Promise { + try { + // Close SQLite database — import dynamically to avoid circular deps + const { getDbInstance } = await import("@/lib/db/core"); + const db = getDbInstance(); + if (db && typeof db.close === "function") { + db.close(); + console.log("[Shutdown] SQLite database closed."); + } + } catch (err) { + console.error("[Shutdown] Error during cleanup:", (err as Error).message); + } +} + +/** + * Initialize graceful shutdown handlers. + * Should be called once during server startup. + */ +export function initGracefulShutdown(): void { + const shutdown = async (signal: string) => { + if (isShuttingDown) return; // Prevent double-shutdown + isShuttingDown = true; + + console.log(`\n[Shutdown] Received ${signal}. Draining ${activeRequests} request(s)...`); + + await waitForDrain(); + await cleanup(); + + console.log("[Shutdown] Bye."); + process.exit(0); + }; + + process.on("SIGTERM", () => shutdown("SIGTERM")); + process.on("SIGINT", () => shutdown("SIGINT")); + + console.log("[Shutdown] Graceful shutdown handlers registered."); +} diff --git a/src/proxy.ts b/src/proxy.ts index 2ecabc7f52..012ac49719 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -3,6 +3,8 @@ import { jwtVerify } from "jose"; import { generateRequestId } from "./shared/utils/requestId"; import { getSettings } from "./lib/localDb"; import { isPublicRoute, verifyAuth, isAuthRequired } from "./shared/utils/apiAuth"; +import { checkBodySize } from "./shared/middleware/bodySizeGuard"; +import { isDraining } from "./lib/gracefulShutdown"; // FASE-01: Fail-fast — no hardcoded fallback. Server must have JWT_SECRET configured. if (!process.env.JWT_SECRET) { @@ -19,6 +21,26 @@ export async function proxy(request) { const response = NextResponse.next(); response.headers.set("X-Request-Id", requestId); + // ──────────────── Pre-flight: Reject during shutdown drain ──────────────── + if (isDraining() && pathname.startsWith("/api/")) { + return NextResponse.json( + { + error: { + code: "SERVICE_UNAVAILABLE", + message: "Server is shutting down", + correlation_id: requestId, + }, + }, + { status: 503 } + ); + } + + // ──────────────── Pre-flight: Reject oversized bodies ──────────────── + if (pathname.startsWith("/api/") && request.method !== "GET" && request.method !== "OPTIONS") { + const bodySizeRejection = checkBodySize(request); + if (bodySizeRejection) return bodySizeRejection; + } + // ──────────────── Protect Management API Routes ──────────────── if (pathname.startsWith("/api/") && !pathname.startsWith("/api/v1/")) { // Allow public routes (login, logout, health, etc.) diff --git a/src/shared/middleware/bodySizeGuard.ts b/src/shared/middleware/bodySizeGuard.ts new file mode 100644 index 0000000000..469eddd6c7 --- /dev/null +++ b/src/shared/middleware/bodySizeGuard.ts @@ -0,0 +1,65 @@ +/** + * Body Size Guard — E-1 Critical Fix + * + * Middleware helper that rejects oversized request bodies + * before they are parsed, preventing OOM from malicious payloads. + * + * Usage: + * import { checkBodySize, MAX_BODY_BYTES } from "@/shared/middleware/bodySizeGuard"; + * + * const rejection = checkBodySize(request); + * if (rejection) return rejection; + * + * @module shared/middleware/bodySizeGuard + */ + +/** Default maximum body size: 10 MB */ +const DEFAULT_MAX_BODY_BYTES = 10 * 1024 * 1024; + +/** Larger limit for backup/import routes: 100 MB */ +export const MAX_BODY_BYTES_IMPORT = 100 * 1024 * 1024; + +/** Configured limit — reads from env or falls back to 10 MB */ +export const MAX_BODY_BYTES = parseInt( + process.env.MAX_BODY_SIZE_BYTES || String(DEFAULT_MAX_BODY_BYTES), + 10 +); + +/** + * Check Content-Length header against the configured limit. + * Returns a 413 Response if the body is too large, or null if OK. + */ +export function checkBodySize(request: Request, limit: number = MAX_BODY_BYTES): Response | null { + const contentLength = request.headers.get("content-length"); + + if (contentLength) { + const bytes = parseInt(contentLength, 10); + if (!Number.isNaN(bytes) && bytes > limit) { + return new Response( + JSON.stringify({ + error: { + message: `Request body too large. Maximum allowed: ${formatBytes(limit)}`, + type: "payload_too_large", + code: "PAYLOAD_TOO_LARGE", + }, + }), + { + status: 413, + headers: { + "Content-Type": "application/json", + "Access-Control-Allow-Origin": process.env.CORS_ORIGIN || "*", + }, + } + ); + } + } + + return null; +} + +/** Format bytes as human-readable string */ +function formatBytes(bytes: number): string { + if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(0)} MB`; + if (bytes >= 1024) return `${(bytes / 1024).toFixed(0)} KB`; + return `${bytes} bytes`; +} diff --git a/tsconfig.json b/tsconfig.json index 2162fa9a27..f07ffcea5f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,11 +1,7 @@ { "compilerOptions": { "target": "ES2022", - "lib": [ - "dom", - "dom.iterable", - "esnext" - ], + "lib": ["dom", "dom.iterable", "esnext"], "allowJs": true, "checkJs": false, "skipLibCheck": true, @@ -14,21 +10,16 @@ "esModuleInterop": true, "module": "esnext", "moduleResolution": "bundler", + "allowImportingTsExtensions": true, "resolveJsonModule": true, "isolatedModules": true, "jsx": "react-jsx", "incremental": true, "forceConsistentCasingInFileNames": true, "paths": { - "@/*": [ - "./src/*" - ], - "@omniroute/open-sse": [ - "./open-sse" - ], - "@omniroute/open-sse/*": [ - "./open-sse/*" - ] + "@/*": ["./src/*"], + "@omniroute/open-sse": ["./open-sse"], + "@omniroute/open-sse/*": ["./open-sse/*"] }, "plugins": [ { @@ -45,9 +36,5 @@ ".next/types/**/*.ts", ".next/dev/types/**/*.ts" ], - "exclude": [ - "node_modules", - "open-sse", - "antigravity-manager-analysis" - ] + "exclude": ["node_modules", "open-sse", "antigravity-manager-analysis"] }