import { NextResponse, type NextRequest } from "next/server"; import { STUDIO_TOKEN_COOKIE, STUDIO_TOKEN_HEADER, presentedToken, resolveAccessPosture, timingSafeStringEqual, } from "@/lib/auth/access"; const TOKEN_MAX_AGE_SECONDS = 60 * 60 * 24 * 30; function denyResponse(isApi: boolean, status: number, message: string): NextResponse { if (isApi) { return new NextResponse(JSON.stringify({ error: message }), { status, headers: { "content-type": "application/json" }, }); } return new NextResponse(message, { status }); } function enforceAccess(request: NextRequest): NextResponse | null { const posture = resolveAccessPosture(); if (posture.kind === "allow") return null; const url = request.nextUrl; const isApi = url.pathname.startsWith("/api/"); const queryToken = url.searchParams.get("token"); if (queryToken && timingSafeStringEqual(queryToken.trim(), posture.token)) { const clean = url.clone(); clean.searchParams.delete("token"); const redirect = NextResponse.redirect(clean); redirect.cookies.set(STUDIO_TOKEN_COOKIE, posture.token, { httpOnly: true, sameSite: "lax", secure: url.protocol === "https:", path: "/", maxAge: TOKEN_MAX_AGE_SECONDS, }); return redirect; } const presented = presentedToken( request.headers.get(STUDIO_TOKEN_HEADER), request.cookies.get(STUDIO_TOKEN_COOKIE)?.value, ); if (presented && timingSafeStringEqual(presented, posture.token)) return null; return denyResponse(isApi, 401, "Unauthorized"); } export function proxy(request: NextRequest) { const denied = enforceAccess(request); if (denied) return denied; const start = Date.now(); const clientIp = request.headers.get("CF-Connecting-IP") || request.headers.get("X-Forwarded-For")?.split(",")[0]?.trim() || request.headers.get("X-Real-IP") || "unknown"; const method = request.method; const path = request.nextUrl.pathname; const sanitizedUrl = request.nextUrl.clone(); for (const sensitiveKey of ["api_key", "key", "token", "access_token"]) { if (sanitizedUrl.searchParams.has(sensitiveKey)) { sanitizedUrl.searchParams.set(sensitiveKey, "[redacted]"); } } const query = sanitizedUrl.search || ""; const userAgent = request.headers.get("User-Agent")?.slice(0, 100) || "unknown"; const rawReferer = request.headers.get("Referer") || "-"; const referer = (() => { if (rawReferer === "-") return "-"; try { const parsed = new URL(rawReferer); return `${parsed.origin}${parsed.pathname}`.slice(0, 200); } catch { return "[invalid]"; } })(); const authHeader = request.headers.get("Authorization") || ""; const hasAuth = Boolean(authHeader); const country = request.headers.get("CF-IPCountry") || "-"; const response = NextResponse.next(); const duration = Date.now() - start; const timestamp = new Date().toISOString(); const logParts = [ `ip=${clientIp}`, `country=${country}`, `method=${method}`, `path=${path}${query}`, `duration=${duration}ms`, `auth=${hasAuth ? "present" : "none"}`, `ua=${userAgent}`, ]; if (referer !== "-") { logParts.push(`referer=${referer}`); } const logMsg = `${timestamp} ACCESS ${logParts.join(" | ")}`; if (process.env.LOCAL_STUDIO_ACCESS_LOGS === "true") { console.log(logMsg); } response.headers.set("X-Content-Type-Options", "nosniff"); response.headers.set("X-Frame-Options", "DENY"); response.headers.set("X-XSS-Protection", "1; mode=block"); response.headers.set("Referrer-Policy", "strict-origin-when-cross-origin"); return response; } export default proxy; // Configure which paths the middleware runs on export const config = { matcher: [ // Every /api/* request, unconditionally. This MUST come first and carry no // extension exclusion: the privileged API routes are the token gate's whole // point, and dynamic segments (/api/proxy/[...path], /api/agent/sessions/[id]) // let a caller append a `.png`-style suffix. If the static-asset exclusion // below also covered /api, that suffix would skip the gate entirely. "/api/:path*", /* * All non-API paths except: * - _next/static (static files) * - _next/image (image optimization files) * - favicon.ico (favicon file) * - public folder files (icons/, image extensions) */ "/((?!api/|_next/static|_next/image|favicon.ico|icons/|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)", ], };