import path from "node:path"; import type { NextConfig } from "next"; /** * Security headers. * * Two profiles: * - strict (default): frame-ancestors 'none' + X-Frame-Options: DENY. * Applied to every route except /staff/report. * - staff-embed: frame-ancestors 'self' , no X-Frame-Options. * Lets the CiviCRM "Engagement Report" extension embed the staff page * in an iframe on contact pages. * * The catch-all source uses a negative lookahead so it does NOT match * /staff/report — otherwise both rules apply and the browser ANDs the * frame-ancestors directives together, blocking embedding entirely. */ const isDev = process.env.NODE_ENV !== "production"; const devOnlyDynamicScript = isDev ? " 'unsafe-eval'" : ""; const buildCsp = (frameAncestors: string) => [ "default-src 'self'", `script-src 'self' 'unsafe-inline'${devOnlyDynamicScript}`, "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com", "font-src 'self' https://fonts.gstatic.com data:", "img-src 'self' data:", "connect-src 'self'", `frame-ancestors ${frameAncestors}`, "form-action 'self'", "base-uri 'self'", "object-src 'none'", ].join("; "); const sharedHeaders = [ { key: "Strict-Transport-Security", value: "max-age=63072000; includeSubDomains; preload" }, { key: "X-Content-Type-Options", value: "nosniff" }, { key: "Referrer-Policy", value: "same-origin" }, { key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=(), interest-cohort=()", }, ]; function civiOriginForCsp(): string { const raw = process.env.CIVI_BASE_URL; if (!raw) return ""; try { return new URL(raw).origin; } catch { return ""; } } const strictHeaders = [ { key: "Content-Security-Policy", value: buildCsp("'none'") }, ...sharedHeaders, { key: "X-Frame-Options", value: "DENY" }, ]; const staffEmbedHeaders = (() => { const origin = civiOriginForCsp(); const frameAncestors = origin ? `'self' ${origin}` : "'self'"; return [ { key: "Content-Security-Policy", value: buildCsp(frameAncestors) }, ...sharedHeaders, // Intentionally NO X-Frame-Options: frame-ancestors above is the policy. ]; })(); const nextConfig: NextConfig = { poweredByHeader: false, reactStrictMode: true, // Pin Turbopack's filesystem root to THIS app's directory. Without this, // Next 16 walks up to the parent civi-webform/ workspace (it sees two // package-lock.json files and silently picks the outer one), which causes // Turbopack to watch the parent node_modules/, .claude-flow/, .swarm/, and // ruvector.db. Background writes in those trees trigger a recompile loop: // compile → write .next/dev → re-trigger → memory blows up. The build-time // warning surfaces the same issue. turbopack: { root: path.resolve(__dirname), }, async headers() { return [ { source: "/staff/report", headers: staffEmbedHeaders }, // Catch-all that explicitly excludes /staff/report — see header notes. { source: "/((?!staff/report).*)", headers: strictHeaders }, ]; }, }; export default nextConfig;