Files
MichilisandClaude Opus 5 8c8463924f fix: the theme belongs in a cookie, and the offline page in the shell
next/script rendered the theme script with a nonce, and browsers hide a
nonce attribute from the DOM once they have parsed it, so the server sent
nonce="" and the client read undefined. Every page load logged a
hydration mismatch.

The cookie is the fix rather than a workaround for it. The server reads
it and stamps data-theme on the html element, so the first frame is
already the right colour and there is no inline script at all.

Chasing that turned up worse. /offline sat outside [locale], which made
it a sibling of the root layout, so Next gave it a generated one: no
stylesheet, no font, no theme, and a second html element. The phase 7
check asserted the text and a button and passed while the page was
plainly broken.

It is a normal screen now. The service worker keeps one offline copy per
language the reader actually visits, learned from their own successful
navigations, so no list of locales lives in the worker and a third
language stays a catalog file.

The e2e warm-up also asks for the dynamic routes, which cost a compile of
their own and were being paid for by whichever test reached one first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 23:04:05 +00:00

68 lines
2.7 KiB
TypeScript

import createMiddleware from 'next-intl/middleware';
import type { NextRequest } from 'next/server';
import { routing } from './src/i18n/routing';
const intl = createMiddleware(routing);
// React reconstructs server stacks with eval in development and never in production.
const DEV = process.env.NODE_ENV === 'development';
/**
* Security headers (SPEC.md section 14). Everything is served from one origin: the app
* proxies `/api`, next/font self hosts the typeface and there is no third party script
* anywhere, so the policy names `'self'` and stops.
*
* Scripts are allowed by nonce rather than by `'unsafe-inline'`. A fresh nonce per request
* means an injected `<script>` cannot guess its way in, and `'strict-dynamic'` lets the one
* nonced bootstrap load the chunks it needs without listing them.
*/
function policy(nonce: string): string {
return [
"default-src 'self'",
// 'wasm-unsafe-eval' is the QR decoder: zxing runs as WebAssembly, self hosted.
`script-src 'self' 'nonce-${nonce}' 'strict-dynamic' 'wasm-unsafe-eval'${DEV ? " 'unsafe-eval'" : ''}`,
// Styles stay inline-allowed: Tailwind ships a stylesheet, but React writes inline
// style attributes for things like a dragged card, and those are not noncable.
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: blob:",
"font-src 'self'",
// The dev server pushes updates over a websocket to the same origin.
`connect-src 'self'${DEV ? ' ws:' : ''}`,
"worker-src 'self' blob:",
"object-src 'none'",
"base-uri 'self'",
"form-action 'self'",
"frame-ancestors 'none'",
'upgrade-insecure-requests',
].join('; ');
}
const STATIC_HEADERS: Record<string, string> = {
'x-content-type-options': 'nosniff',
'referrer-policy': 'strict-origin-when-cross-origin',
// The scanner needs the camera; nothing else needs anything.
'permissions-policy': 'camera=(self), microphone=(), geolocation=(), payment=()',
};
export default function proxy(request: NextRequest) {
const nonce = crypto.randomUUID();
const csp = policy(nonce);
// Next reads the nonce off the request headers and stamps it on the scripts it renders.
// Setting them on the incoming request is what carries them through next-intl's rewrite.
request.headers.set('x-nonce', nonce);
request.headers.set('content-security-policy', csp);
const response = intl(request);
response.headers.set('content-security-policy', csp);
for (const [key, value] of Object.entries(STATIC_HEADERS)) response.headers.set(key, value);
return response;
}
export const config = {
// Everything except /api (forwarded to the API by the route handler), Next internals and
// static files.
matcher: ['/((?!api|healthz|_next|_vercel|.*\\..*).*)'],
};