This site uses one functional cookie to keep feature rollouts consistent for you. Nothing is set until you choose. See the privacy notice.
Dev notes
Why the landing page sections were blank in production, how the CSP got fixed, and how the middleware was restructured to avoid paying Auth0 latency on every request.
A Content Security Policy is an HTTP header that tells the browser which resources it's allowed to load and execute. The policy had script-src 'nonce-{nonce}' 'strict-dynamic' — each request generated a random nonce and any script without that nonce attribute was blocked.
Next.js App Router inlines RSC payload scripts directly into the HTML — they look like self.__next_f.push([...]) and hydrate the React tree on the client. These scripts have no nonce attribute. And 'strict-dynamic' explicitly ignores 'self', so even same-origin scripts are blocked unless they carry the nonce.
Locally this was fine because the dev server always re-renders everything. In production the landing page became fully static after moving the auth redirect to middleware, and Vercel served the CSP from the CDN edge. The clue: HeroSection still animated because its animation is pure CSS @keyframes — everything else uses IntersectionObserver which requires JS. JS was simply not running at all.
The fix is script-src 'self' 'unsafe-inline'. What 'unsafe-inline' actually protects against is reflected XSS — an attacker injecting a <script> tag into the HTML response. The real XSS protection here is React's automatic JSX escaping — any value rendered in JSX gets HTML-escaped before it touches the DOM. The attack surface only opens up if you use dangerouslySetInnerHTML, and there is none of that in this codebase.
To do nonce-based CSP correctly in Next.js you have to make the root layout async and read the nonce from request headers inside it. But reading headers() in any server component opts that route out of static generation — every page goes dynamic. The root layout wraps every page, so every page takes the TTFB hit. It's not worth it here, and it's what the Next.js docs recommend for static apps.
auth0.middleware() makes a network call to Auth0 on every single request — not just auth routes, every page. That showed up in TTFB data immediately and the whole middleware was pulled. Now the proxy runs on every request but auth0.middleware() is only invoked for /auth/* and authenticated /vitals or /settings requests. Everything else hits NextResponse.next() with the CSP header attached and returns immediately.
For protected routes, auth0.getSession(req) runs first — the proxy-safe overload that reads from req.cookies directly, no network call, just a cookie decrypt. If there's no session it redirects to login and auth0.middleware never runs. If there is a session, auth0.middleware runs after to handle rolling session refresh.
The remaining directives do meaningful work: default-src 'self' blocks loading resources from unknown domains, frame-ancestors 'self' stops anyone else framing the site, object-src 'none' blocks Flash and plugins, base-uri 'self' blocks base tag injection. connect-src is locked down so JS can only make requests to known endpoints — same origin, plus specific external services: Speed Insights, TCGdex, and GitHub raw for Pokémon sprites.
A CSP is one header doing a lot, but it isn't everything. Three smaller headers cover the gaps, and they live in next.config.ts rather than the proxy — they never change, so there's no reason to recompute them per request the way the media-origin-dependent CSP has to be. X-Content-Type-Options: nosniff stops a browser second-guessing a response's declared type, which is how a served file gets coerced into running as script. Referrer-Policy: strict-origin-when-cross-origin keeps the full path on my own origin but sends only the bare origin outward, so an outbound link never leaks the page you were on. Permissions-Policy denies camera, microphone, and geolocation outright, because nothing here touches those APIs and the safe default for an unused capability is off. HSTS isn't in the list on purpose: Vercel already sends it. And the set is asserted in a test now, so dropping one fails a run instead of going unnoticed.
One thing the policy now varies by environment. React's development build calls eval() to rebuild callstacks that crossed the server/client boundary, so on a strict policy the dev overlay throws about eval instead of showing the error you opened it to read. React never calls it in production, so 'unsafe-eval' is added in development only and the shipped policy is unchanged. The flag defaults to off, because the version of this that goes wrong is the one where a missing environment check quietly opens eval in prod. That is separate from 'wasm-unsafe-eval', which stays in both — it is the Draco decoder for the 3D models.
Everything above is about what a page is allowed to do. The operator dashboard exports CSVs, and a CSV has its own version of the same question — except the thing executing it is Excel, not a browser, and no header reaches it.
A cell beginning with = + - @ is run as a formula when the file is opened. The exports carry product and store names, so exporting text somebody else typed is a way to run something on the machine of whoever opens it. RFC 4180 quoting was already correct and is not the defence: the quotes come off at parse time and the formula runs anyway.
A leading apostrophe pins the cell to text and is not displayed. Only strings get it — a negative number is data, and prefixing it would corrupt every negative figure in the finance export, which is a worse bug than the one being fixed. No library for this on purpose: the popular CSV writers do not escape these characters by default either, so adding one would grow the bundle without addressing it.
Update — August 14, 2026
The policy listed https://api.paulsumido.com in connect-src. The browser calls the API directly for one demo, so in production that was right and in every local checkout it was wrong — the README and .env.example both tell you to point at localhost:3001, and a different port is a different origin.
Nothing rescued it. The dev flag the policy builder already takes only widens script-src for React's dev build, and the header goes on every response regardless of environment. The existing test only asserted on img-src.
This is the gap named below arriving on schedule: nothing reports violations, so a policy blocking something legitimate is found by a broken page. It just happened to be a page only I would load, which is why it sat there.
The fix is that connect-src follows the same constant the fetch uses, so the policy allows exactly where the app goes and nowhere else — and stops being a thing to remember when the API moves. The media origin was already parameterised for precisely this reason; the API origin was the one that got typed in.
Verified on the artifact rather than the sources, because a security header is not a thing to take on trust: built against localhost, the production hostname appears nowhere in the output; built against the production URL, it is back in 53 chunks. Removal and substitution, in that order, since the second is what would have broken the live site.