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 Content-Security-Policy. `script-src` uses 'unsafe-inline' because // Next.js 16's Turbopack build does NOT stamp a per-request nonce onto its // inline hydration scripts (`self.__next_f.push(...)`). A nonce-based policy // therefore blocks those inline scripts and the app never hydrates (blank page). // `style-src` also keeps 'unsafe-inline' (Radix / Tailwind / framer-motion inject // inline styles). NOTE: to restore the stricter nonce-based script policy, build // with webpack (`next build --webpack`) so Next applies the nonce to its scripts. function buildCsp(): string { const isDev = process.env.NODE_ENV !== "production" const sentry = sentryIngestOrigin() // Dev additionally needs 'unsafe-eval' (Turbopack HMR) plus a dev websocket // (added to connect-src below). const scriptSrc = isDev ? `script-src 'self' 'unsafe-inline' 'unsafe-eval' https://challenges.cloudflare.com` : `script-src 'self' 'unsafe-inline' 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" } const csp = buildCsp() const response = NextResponse.next() // 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)$).*)", ], }