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>
This commit is contained in:
Leon Serfaty
2026-09-05 16:27:16 -04:00
co-authored by Claude Opus 5
parent 8f90347659
commit 1d02598786
71 changed files with 3641 additions and 819 deletions
+63 -12
View File
@@ -54,14 +54,32 @@ function umamiOrigin(): string {
}
}
// 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 {
// 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()
@@ -69,7 +87,7 @@ function buildCsp(): string {
// (added to connect-src below).
const umami = umamiOrigin()
const scriptSrc = [
"script-src 'self' 'unsafe-inline'",
nonce ? `script-src 'self' 'nonce-${nonce}'` : "script-src 'self' 'unsafe-inline'",
isDev ? "'unsafe-eval'" : "",
"https://challenges.cloudflare.com",
umami, // load the Umami analytics script
@@ -104,6 +122,21 @@ function buildCsp(): string {
].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) →
@@ -155,10 +188,28 @@ export async function proxy(request: NextRequest) {
dropStaleSessionCookie = state === "invalid"
}
const csp = buildCsp()
// 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 response = NextResponse.next()
// Set the CSP on the outgoing response so the browser enforces it.
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) {