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:
co-authored by
Claude Opus 5
parent
8f90347659
commit
1d02598786
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user