fix(canvas): make root layout dynamic so CSP nonce reaches Next scripts

Tenant page loads were failing with repeated CSP violations:

  Executing inline script violates ... script-src 'self'
  'nonce-M2M4YTVh...' 'strict-dynamic'. ...

because Next.js's bootstrap inline scripts were emitted without a
nonce attribute. The middleware was generating per-request nonces
correctly and sending them via `x-nonce` — but the layout was
fully static, so Next.js cached the HTML once and served that cached
bundle (no nonces baked in) for every request.

Fix: call `await headers()` in the root layout. That opts the tree
into dynamic rendering AND signals Next.js to propagate the
x-nonce value to its own generated <script> tags.

The `nonce` return value is intentionally unused — the framework
handles its bootstrap scripts automatically once the read happens.
Future code that adds third-party <Script> components (analytics,
etc.) should pass the returned nonce explicitly.

Verified against live tenant: before this change every /_next/
chunk script tag in the HTML had no nonce attribute; expected after
deploy is `<script nonce="..." src="/_next/...">` on each.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hongming Wang 2026-04-20 12:34:03 -07:00
parent 1eea9e04ba
commit 1af6f696a2

View File

@ -1,4 +1,5 @@
import type { Metadata } from "next";
import { headers } from "next/headers";
import "./globals.css";
import { AuthGate } from "@/components/AuthGate";
import { CookieConsent } from "@/components/CookieConsent";
@ -8,11 +9,35 @@ export const metadata: Metadata = {
description: "AI Org Chart Canvas",
};
export default function RootLayout({
export default async function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
// Read the per-request CSP nonce that middleware.ts sets via the
// `x-nonce` request header. This call is load-bearing for TWO
// independent reasons:
//
// 1. It opts the root layout into dynamic rendering. Without a
// `headers()` / `cookies()` / `noStore()` call, Next.js treats
// the layout as statically pre-rendered and serves the SAME
// HTML for every request — which means the Next.js bootstrap
// <script> tags bake into the HTML without any nonce. The
// browser then rejects every one with a CSP violation because
// the header demands nonce-only script execution.
//
// 2. Next.js 15 propagates the nonce to its own generated inline
// scripts (the __next_f chunk push frames) ONLY when the header
// is actually read via `headers()`. The header's existence on
// the request isn't enough — Next.js watches for the read.
//
// Keeping the `nonce` variable unused is intentional: we don't need
// to pass it to any custom <Script nonce={...}> tags right now, the
// framework takes care of its own bootstrap scripts once the read
// happens. Destructuring via `await` + `.get()` is the minimum shape
// Next.js recognizes as "dynamic server-side access".
await headers();
return (
<html lang="en">
<body className="bg-zinc-950 text-white">