Files
property-management-network/proxy.ts
T
Leon SerfatyandClaude Opus 5 1d02598786 chore: sync in-progress work across marketing, admin, API and tests
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>
2026-09-05 16:27:16 -04:00

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)$).*)",
],
}