import { NextResponse, type NextRequest } from "next/server" import { getSessionCookie } from "better-auth/cookies" const PROTECTED_PATHS = [ "/admin", "/dashboard", "/properties", "/tenants", "/rent", "/maintenance", "/leases", "/expenses", "/settings", "/onboarding", "/calendar", "/inspections", "/vendors", "/reports", "/activity", "/ai", "/ai-dashboard", "/predictions", "/recommendations", "/impact", "/follow-ups", "/team", ] const AUTH_PATHS = ["/login", "/signup", "/forgot-password"] // Origin of the Sentry ingest endpoint, derived from the public DSN so the // CSP stays in sync with whatever project/region the DSN points at. Returns // null when Sentry is not configured. function sentryIngestOrigin(): string | null { const dsn = process.env.NEXT_PUBLIC_SENTRY_DSN if (!dsn) return null try { return new URL(dsn).origin } catch { return null } } // Build the per-request Content-Security-Policy. `script-src` carries a // per-request nonce instead of 'unsafe-inline'. `style-src` keeps // 'unsafe-inline' because Radix / Tailwind / framer-motion inject inline // styles and removing it would break the UI. Next.js reads the nonce from the // `content-security-policy` request header and applies it to its own inline // scripts automatically. function buildCsp(nonce: string): string { const isDev = process.env.NODE_ENV !== "production" const sentry = sentryIngestOrigin() // In development, Next.js/React and Turbopack HMR require eval() for hot // reloading and debugging features, and open a dev websocket. These are NOT // added in production, where the nonce-based policy stays strict. const scriptSrc = isDev ? `script-src 'self' 'nonce-${nonce}' 'unsafe-eval' https://challenges.cloudflare.com` : `script-src 'self' 'nonce-${nonce}' https://challenges.cloudflare.com` const connectSrc = [ "connect-src 'self'", isDev ? "ws: wss:" : "", "https://api.stripe.com https://api.openai.com https://challenges.cloudflare.com", sentry ?? "", ] .filter(Boolean) .join(" ") return [ "default-src 'self'", "img-src 'self' data: blob: https:", "style-src 'self' 'unsafe-inline'", scriptSrc, "font-src 'self' data:", connectSrc, // Sentry Session Replay spins up its compression worker from a blob: URL; // without worker-src the browser falls back to script-src and blocks it. "worker-src 'self' blob:", "frame-src https://js.stripe.com https://hooks.stripe.com https://challenges.cloudflare.com", "frame-ancestors 'none'", "base-uri 'self'", "form-action 'self'", "object-src 'none'", ].join("; ") } // Cookie presence is only a hint (cheap, no DB). Before bouncing a visitor off // an auth page we confirm the session is actually alive — otherwise a stale // cookie loops forever: /dashboard → /login (server sees no session) → // /dashboard (proxy sees a cookie) → … until ERR_TOO_MANY_REDIRECTS. // "unknown" (auth service unreachable / rate-limited) renders the auth page // without touching cookies, which is safe in both directions. async function sessionState(request: NextRequest): Promise<"valid" | "invalid" | "unknown"> { try { const base = process.env.BETTER_AUTH_URL ?? request.nextUrl.origin const res = await fetch(new URL("/api/auth/get-session", base), { headers: { cookie: request.headers.get("cookie") ?? "" }, cache: "no-store", }) if (!res.ok) return "unknown" // Better Auth returns JSON `null` when the session is missing or revoked. const session = await res.json() return session ? "valid" : "invalid" } catch { return "unknown" } } export async function proxy(request: NextRequest) { const pathname = request.nextUrl.pathname // Optimistic check based on the presence of the session cookie. Real // enforcement happens in routes / server components via getSessionUser(). const sessionCookie = getSessionCookie(request) const isProtected = PROTECTED_PATHS.some((p) => pathname.startsWith(p)) if (isProtected && !sessionCookie) { const url = request.nextUrl.clone() url.pathname = "/login" return NextResponse.redirect(url) } const isAuthPage = AUTH_PATHS.some((p) => pathname.startsWith(p)) let dropStaleSessionCookie = false if (isAuthPage && sessionCookie) { const state = await sessionState(request) if (state === "valid") { const url = request.nextUrl.clone() url.pathname = "/dashboard" return NextResponse.redirect(url) } // Dead cookie (session revoked or expired): render the auth page and drop // the cookie below so protected paths stop treating this visitor as // signed in. On "unknown", render the page but keep the cookie. dropStaleSessionCookie = state === "invalid" } // Per-request CSP nonce. UUID contains only hex + dashes, so it never // includes HTML-escape characters (which Next rejects in nonces). const nonce = crypto.randomUUID() const csp = buildCsp(nonce) // Forward the nonce + CSP on the request headers so Next.js can pick up the // nonce and apply it to its own inline scripts during render. const requestHeaders = new Headers(request.headers) requestHeaders.set("x-nonce", nonce) requestHeaders.set("Content-Security-Policy", csp) const response = NextResponse.next({ request: { headers: requestHeaders } }) // Also set the CSP on the outgoing response so the browser enforces it. response.headers.set("Content-Security-Policy", csp) if (dropStaleSessionCookie) { // Covers both the plain and __Secure-prefixed Better Auth cookie names. for (const cookie of request.cookies.getAll()) { if (!cookie.name.includes("better-auth.session_token")) continue response.cookies.set(cookie.name, "", { maxAge: 0, path: "/", httpOnly: true, sameSite: "lax", secure: cookie.name.startsWith("__Secure-"), }) } } return response } export const config = { matcher: [ "/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)", ], }