Snapshot of uncommitted work that had accumulated in the tree alongside the Turnstile changes: - marketing pages, SEO helpers (lib/seo.ts, lib/marketing/) and structured data - admin billing actions and a per-user portfolio view, plus an admin error boundary - rate limiting (lib/rate-limit.ts) applied across the /api/v1 surface - CSP and proxy adjustments, accounting/webhook lib updates - Playwright config and an e2e/unit test suite - next bumped to ^16.3.4 with the lockfile regenerated - generated AGENTS.md / CLAUDE.md Authored by other sessions working in this tree; committed here so the Turnstile work could be pushed without leaving the tree dirty. Typecheck passes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
237 lines
8.3 KiB
TypeScript
237 lines
8.3 KiB
TypeScript
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
|
|
}
|
|
}
|
|
|
|
// Origin serving the Umami analytics script (script.js) and receiving its event
|
|
// beacons (POST /api/send). Mirrors the component default so the CSP allows both
|
|
// loading the script AND sending events; stays in sync with NEXT_PUBLIC_UMAMI_SRC
|
|
// when overridden.
|
|
function umamiOrigin(): string {
|
|
const src = process.env.NEXT_PUBLIC_UMAMI_SRC || "https://fickanalytics.phluit.net/script.js"
|
|
try {
|
|
return new URL(src).origin
|
|
} catch {
|
|
return ""
|
|
}
|
|
}
|
|
|
|
// Build the Content-Security-Policy.
|
|
//
|
|
// `script-src` is NONCE-based on the app surface and 'unsafe-inline' elsewhere.
|
|
//
|
|
// The nonce is minted per request and set on the REQUEST headers, which is how
|
|
// Next discovers it and stamps it onto its inline hydration scripts; we echo the
|
|
// policy on the response. Browsers ignore 'unsafe-inline' once a nonce is
|
|
// present, so an injected inline <script> cannot execute.
|
|
//
|
|
// Why it is not applied everywhere: statically PRERENDERED pages are written to
|
|
// disk at build time with no nonce on their script tags, so serving them with a
|
|
// fresh per-request nonce blocks every script on the page. See
|
|
// `wantsNonce()` — the nonce is scoped to the dynamically rendered app surface,
|
|
// which is also the only place user-supplied data (tenant names, property names,
|
|
// maintenance descriptions) is rendered, and therefore the only place inline
|
|
// script injection is a real risk. Marketing and legal pages render
|
|
// developer-authored content and keep the permissive policy.
|
|
//
|
|
// 'strict-dynamic' is deliberately NOT used: it would make the browser ignore
|
|
// the host allowlist below, which is exactly what lets the Turnstile and Umami
|
|
// script tags load.
|
|
//
|
|
// `style-src` deliberately keeps 'unsafe-inline': Radix, Tailwind and
|
|
// framer-motion all set inline styles from JS, and style injection is not an
|
|
// execution primitive.
|
|
function buildCsp(nonce: string | null): 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 umami = umamiOrigin()
|
|
const scriptSrc = [
|
|
nonce ? `script-src 'self' 'nonce-${nonce}'` : "script-src 'self' 'unsafe-inline'",
|
|
isDev ? "'unsafe-eval'" : "",
|
|
"https://challenges.cloudflare.com",
|
|
umami, // load the Umami analytics script
|
|
]
|
|
.filter(Boolean)
|
|
.join(" ")
|
|
const connectSrc = [
|
|
"connect-src 'self'",
|
|
isDev ? "ws: wss:" : "",
|
|
"https://api.stripe.com https://api.openai.com https://challenges.cloudflare.com",
|
|
umami, // Umami event beacons (POST /api/send)
|
|
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("; ")
|
|
}
|
|
|
|
/**
|
|
* True for paths that Next renders per request, and where the rendered HTML can
|
|
* contain user-supplied strings. These get the strict nonce policy. Everything
|
|
* else — the marketing site and the statically prerendered legal pages — keeps
|
|
* 'unsafe-inline', because a prerendered page's scripts carry no nonce and would
|
|
* all be blocked.
|
|
*/
|
|
function wantsNonce(pathname: string): boolean {
|
|
if (pathname.startsWith("/tenant-portal/")) return true // token-addressed, renders tenant data
|
|
return (
|
|
PROTECTED_PATHS.some((p) => pathname.startsWith(p)) ||
|
|
AUTH_PATHS.some((p) => pathname.startsWith(p))
|
|
)
|
|
}
|
|
|
|
// 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 nonce, on the app surface only. Web Crypto is used rather than
|
|
// node:crypto so this keeps working if the proxy moves to the edge runtime.
|
|
let nonce: string | null = null
|
|
if (wantsNonce(pathname)) {
|
|
const bytes = new Uint8Array(16)
|
|
crypto.getRandomValues(bytes)
|
|
nonce = btoa(String.fromCharCode(...bytes))
|
|
}
|
|
|
|
const csp = buildCsp(nonce)
|
|
|
|
// Next reads the CSP off the INCOMING request to discover the nonce and stamp
|
|
// it onto its own inline scripts — setting it only on the response would leave
|
|
// those scripts unnonced and the page would never hydrate.
|
|
const requestHeaders = new Headers(request.headers)
|
|
if (nonce) {
|
|
requestHeaders.set("x-nonce", nonce)
|
|
requestHeaders.set("Content-Security-Policy", csp)
|
|
}
|
|
|
|
const response = NextResponse.next({ request: { headers: requestHeaders } })
|
|
// Echo it 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)$).*)",
|
|
],
|
|
}
|