import { withPayload } from "@payloadcms/next/withPayload"; import type { NextConfig } from "next"; import path from "path"; import { fileURLToPath } from "url"; const __filename = fileURLToPath(import.meta.url); const dirname = path.dirname(__filename); // Applied to every route, including /admin and /api - none of these // constrain what the page can load/render, so they can't break the Payload // admin bundle. Camera/microphone/display-capture are allowed for the // site's own origin because the WebRTC call pages (app/(call)/**) need them. const baseSecurityHeaders = [ { key: "X-Content-Type-Options", value: "nosniff" }, { key: "X-Frame-Options", value: "SAMEORIGIN" }, { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" }, { key: "Permissions-Policy", value: "camera=(self), microphone=(self), display-capture=(self), geolocation=(), payment=()" }, // Browsers only ever honor this over an actual HTTPS connection, so it's // harmless to send in local http:// dev too. { key: "Strict-Transport-Security", value: "max-age=63072000; includeSubDomains" }, ]; // Deliberately NOT applied to /admin or /api: Payload's admin bundle isn't // audited here for CSP compatibility, and API routes serve data, not HTML, // so a content policy doesn't apply to them anyway. connect-src permits the // separately hosted LiveKit SFU and Cloudflare Turnstile at runtime. style-src needs // 'unsafe-inline' because Next App Router emits inline RSC flight/bootstrap // scripts that are required for hydration. script-src allows 'unsafe-eval' // only in dev, matching Next's own documented CSP guidance - React uses eval // there for server-error-stack reconstruction; neither React nor Next use it // in a production build. const isDev = process.env.NODE_ENV === "development"; const frontendCSP = [ "default-src 'self'", `script-src 'self' 'unsafe-inline' https://challenges.cloudflare.com${isDev ? " 'unsafe-eval'" : ""}`, "style-src 'self' 'unsafe-inline'", "img-src 'self' data: blob:", "font-src 'self' data:", "connect-src 'self' https://challenges.cloudflare.com wss: stun: turn: turns:", "media-src 'self' blob:", "frame-src 'self' https://challenges.cloudflare.com", "frame-ancestors 'self'", "base-uri 'self'", "form-action 'self'", "object-src 'none'", "upgrade-insecure-requests", ].join("; "); const nextConfig: NextConfig = { // NOT "standalone": this project uses a custom server (server.ts), and // standalone output can't be combined with // a custom server. The Dockerfile runs the full app via `npm start` instead. images: { localPatterns: [ { pathname: "/api/media/file/**", }, ], }, // Two root layouts exist - app/(frontend) and app/(payload) - so a single // not-found.tsx can't compose a 404; see app/global-not-found.tsx. experimental: { globalNotFound: true, }, turbopack: { root: path.resolve(dirname), }, async headers() { return [ { source: "/:path*", headers: baseSecurityHeaders }, { source: "/((?!admin|api).*)", headers: [{ key: "Content-Security-Policy", value: frontendCSP }] }, ]; }, }; export default withPayload(nextConfig, { devBundleServerPackages: false });