Skip to content

fix(router): PPR gate honors a token-set per-request nonce (#656) - #657

Merged
ivogt merged 1 commit into
mainfrom
fix/ppr-nonce-token-gate
Jul 4, 2026
Merged

ivogt merged 1 commit into
mainfrom
fix/ppr-nonce-token-gate

Conversation

@ivogt

@ivogt ivogt commented Jul 4, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

PPR's axis-1 safety gate for a per-request CSP nonce only accounted for a nonce produced by the createRouter({ nonce }) provider. A per-request nonce supplied via the nonce ContextVar token — ctx.set(nonce, value) in middleware — was invisible to the gate: the threaded nonce param stayed undefined, so a ppr route still entered capture and baked the capture request's nonce into the SHARED shell. On a warm HIT that one frozen nonce was served to every visitor, and the browser's CSP rejected the nonced inline scripts/styles for all but the capture request.

This contradicted the documented guarantee (skills/ppr → "What always stays on axis 1": per-request CSP nonce). The guarantee held for provider-supplied nonces only.

Before vs. after (consumer code)

An app that sets its CSP nonce via the token instead of the provider:

// createRouter WITHOUT a nonce provider — the app owns the nonce in middleware.
export const urlpatterns = urls(({ path, layout, loader, loading, middleware }) => [
  middleware(async (ctx, next) => {
    ctx.set(nonce, crypto.randomUUID()); // per-request CSP nonce via the token
    return next();
  }, () => [
    layout(Shell, () => [
      path("/", Page, { name: "home", ppr: true }, () => [
        loader(PriceLoader),
        loading(<Skeleton />),
      ]),
    ]),
  ]),
]);
Before After
Gate sees token nonce? No (only provider) Yes (reads token at commit point)
ppr route behavior enters capture, tags x-rango-shell: MISS, bakes one nonce into the shared shell pure axis 1, no capture, no x-rango-shell header
Warm HIT serves the frozen capture-time nonce to everyone → CSP breaks never HITs; every GET renders a fresh per-request nonce
Diagnostic silent once-per-key worker warning (declared-intent-cannot-be-honored)

Semantics table

Behavior at the PPR commit point, per nonce source × route:

Nonce source ppr route non-ppr route
Provider (createRouter({ nonce })) axis 1, no capture, warn once/key axis 1, silent
Token (ctx.set(nonce, …) in middleware) axis 1, no capture, warn once/key axis 1, silent
No nonce normal PPR (MISS→capture→HIT) axis 1, silent

A nonce-gated ppr route emits no x-rango-shell header (mirrors the missing-store diagnostic) — it never participates in PPR, so it can never flip to HIT.

Code change

  • src/rsc/rsc-rendering.ts — the gate. Removed nonce === undefined from the OUTER PPR entry condition and moved the nonce check INSIDE, after resolvePprConfig (so only DECLARED routes pay any cost). The effective nonce is nonce ?? contextGet(reqCtx._variables, nonceToken) — the threaded provider param first (cheap short-circuit), then the token off the post-middleware request variables. This is only sound because the commit point runs AFTER the whole middleware chain, so a middleware-set token is already present. isPartial/GET/prerender-collect/RSC checks stay in the outer condition; capture internals untouched (the single gate guards serve AND capture scheduling).
  • src/rsc/shell-serve.ts — new warnPprNonceActiveOnce(key), a once-per-key diagnostic mirroring warnShellStoreMissingOnce.
  • Accessor used: contextGet(reqCtx._variables, nonceToken) — the idiomatic ContextVar read off RequestContext._variables (same primitive response-route-handler.ts uses for ctx.get), with nonce as nonceToken from rsc/nonce.js.

Test plan

  • Unit (src/rsc/__tests__/rsc-rendering-shell-ppr.test.ts): token-set nonce blocks (no getShell, no capture scheduled, no header) + warns once/key; provider nonce blocks + warns once/key (existing behavior pinned + now-warns); non-ppr route with token nonce = pure axis 1, no warning. 18 passed.
  • E2e — test-app (e2e/shell-cache.test.ts + fixture /shell-cache/nonce-token): route-DSL middleware() sets crypto.randomUUID() via the token; repeated HTML GETs never HIT and carry a DISTINCT nonce each (per-request freshness; also pins the commit-point ordering). Dev + production.
  • E2e — cloudflare-basic (e2e/ppr-shell.test.ts + /ppr-nonce in src/urls.tsx): same shape. Dev + production. Regenerated + committed router.named-routes.gen.ts for both apps.
  • Full shell-cache (30) and ppr-shell (30) suites green in both modes (no regression from the gate restructure); mini capture-path suite green.
  • pnpm run typecheck, pnpm run test:unit:all, pnpm run lint, pnpm run format, pnpm check:e2e-bucketing.

The warn-once is a worker-side console.warn (not observable through the Playwright request harness), so the e2e pins MISS-never-HIT + distinct-nonce and the warn is pinned by the unit test — the established precedent for the missing-store warning.

Notes

  • "Proper way to supply a nonce" guidance shipped too (issue PPR shell capture ignores a per-request nonce set via the nonce ContextVar token (only createRouter({ nonce }) gates it) → shared shell bakes a frozen nonce #656 suggested fix 3): skills/ppr/SKILL.md gains a "The proper way to supply a nonce" section and the nonce token's docstring (src/rsc/nonce.ts) now states the contract — createRouter({ nonce }) is the canonical path (threaded into NonceContext/useNonce, Scripts/MetaTags, Flight payload scripts, AND sets the token); a direct ctx.set(nonce, value) in middleware is app-managed only: it is readable via ctx.get(nonce) and gates PPR, but the router's own SSR machinery resolves its nonce BEFORE middleware and will not apply a token-set value to its scripts.
  • Docs updated in the same commit: skills/ppr/SKILL.md ("What always stays on axis 1" now genuinely covers provider AND token + warn-once), docs/design/ppr-shell-resume.md (scar-tissue note + pipeline diagram + serve-plumbing), feature-map.md/feature-file-map.md.
  • The task brief described the gated route as staying x-rango-shell: MISS; the correct contract (and what ships) is no header, matching the missing-store doctrine the brief also asked to mirror and the pre-existing provider-nonce unit test. Flagged here for reviewer awareness.
  • The provider path now reads resolvePprConfig on nonce'd requests too (previously skipped) — a cheap property read only for DECLARED-ppr routes.

Fixes #656

@pkg-pr-new

pkg-pr-new Bot commented Jul 4, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@rangojs/router@657

commit: 622fda0

@ivogt
ivogt force-pushed the fix/ppr-nonce-token-gate branch from 997e3fd to 7b0c1f2 Compare July 4, 2026 09:12
ivogt added a commit that referenced this pull request Jul 4, 2026
…p, fix snapshot-test flake (#652 item 3)

Three coordinated changes unblocking cloudflare-basic-e2e on cold CI runners
(failures on the main run for 2586ea9, the PR #657 runs, and PR #659's own
first CI run):

1. Dev backoff cap (runtime). The refused-capture backoff window is exponential
per consecutive failure, `min(BASE * 2^(failures-1), ceiling)`; the ceiling was a
flat 60s in every mode. On a cold runner each cold re-probe climbs the count, and
once the window exceeds ~16s it outlasts warmToHit's 20s poll: every request is
skipped as backed-off, an eternal MISS. The ceiling is now mode-dependent: 60s in
production (a genuinely ineligible route should be re-probed rarely), ~2s in dev,
where the dominant no-shell cause is a cold module graph that warms on the very
attempt that failed. Dev signal is `process.env.NODE_ENV !== "production"`,
matching handler.ts / server-action.ts / progressive-enhancement.ts.

2. PPR dev warmup (test-side, the sufficient fix). PR #659's first CI run showed
the cap alone is not sufficient: one probe cycle is ~12s (5s attempt + 5s in-place
retry + 2s capped backoff), so the 20s window fits ~2 cycles and today's cold
runners need more because the dev module graph is compiled ON DEMAND during the
capture render, repeatedly blowing the 5s budget (physics hole failed 3x at ~20s).
Extend tests/cloudflare-basic's existing dev-warmup setup project (which the dev
project already depends on) with a ppr warmup: GET every ppr fixture route under
an isolated `?probe=warmup` shell key (module-graph warm only, no test-visible
entry pre-stored), then poll each capturable fixture to x-rango-shell HIT, proving
the full capture pipeline (Flight render, fizz prerender+abort, putShell) is hot
before any real test's warm window starts. Cap + warmup together: the warmup makes
first attempts succeed; the cap remains the safety net for anything cold the
warmup missed. warmToHit windows and runtime constants are otherwise untouched.

3. shell-snapshot.test.ts 1ms flake (also blocking CI). "serves snapshot values
AS FRESH" builds the seeded store and the expected value from two separate
snapshotOf() calls, and segData embeds `Date.now() + 60_000` as expiresAt — a
millisecond tick between the calls fails the deep-equal (seen on the #659 run).
Pin the clock with fake timers (the shell-cache.test.ts pattern). The rest of the
file was audited: no other assertion deep-equals across two Date.now() instants.

Production backoff ceiling/growth, retry-in-place, the in-flight set, and the e2e
warm-window lengths are unchanged. #652 items 1-2 (barrier narrowing, drain
unification) remain open.
ivogt added a commit that referenced this pull request Jul 4, 2026
…dget, snapshot-test flake (#652 item 3)

Four coordinated changes unblocking cloudflare-basic-e2e on cold CI runners
(failures on the main run for 2586ea9, the PR #657 runs, and PR #659's own
first CI run):

1. Dev backoff cap (runtime). The refused-capture backoff window is exponential
per consecutive failure, `min(BASE * 2^(failures-1), ceiling)`; the ceiling was a
flat 60s in every mode. On a cold runner each cold re-probe climbs the count, and
once the window exceeds ~16s it outlasts warmToHit's 20s poll: every request is
skipped as backed-off, an eternal MISS. The ceiling is now mode-dependent: 60s in
production (a genuinely ineligible route should be re-probed rarely), ~2s in dev,
where the dominant no-shell cause is a cold module graph that warms on the very
attempt that failed. Dev signal is `process.env.NODE_ENV !== "production"`,
matching handler.ts / server-action.ts / progressive-enhancement.ts.

2. PPR dev warmup (test-side, the sufficient fix). PR #659's first CI run showed
the cap alone is not sufficient: one probe cycle is ~12s (5s attempt + 5s in-place
retry + 2s capped backoff), so the 20s window fits ~2 cycles and today's cold
runners need more because the dev module graph is compiled ON DEMAND during the
capture render, repeatedly blowing the 5s budget (physics hole failed 3x at ~20s).
Extend tests/cloudflare-basic's existing dev-warmup setup project (which the dev
project already depends on) with a ppr warmup: GET every ppr fixture route under
an isolated `?probe=warmup` shell key (module-graph warm only, no test-visible
entry pre-stored), then poll each capturable fixture to x-rango-shell HIT, proving
the full capture pipeline (Flight render, fizz prerender+abort, putShell) is hot
before any real test's warm window starts. Cap + warmup together: the warmup makes
first attempts succeed; the cap remains the safety net for anything cold the
warmup missed. warmToHit windows and runtime constants are otherwise untouched.

3. CI budget (the 600s owner). cloudflare-basic's playwright globalTimeout was
`10 * 60 * 1000` on CI; the merged PPR stack grew the sequential dev ->
production -> hmr run past 10m, so healthy runs brushed the cap and any flake
retry tipped it over — Playwright killed the run mid-flight ("Timed out waiting
600s for the test suite to run"), failing whichever test was executing (the 0ms
hmr-warmup skip-cascade, the mid-hmr kill at 10:31:06 while hmr tests were
passing). Raised to 14m, and the cloudflare-basic-e2e job's timeout-minutes from
15 to 20 so the workflow cap contains ~3-4m setup + the 14m playwright budget.
vite-rsc-demo and rangojs-router keep their 600s globalTimeout: their jobs
shard/split, and recent CI runs show per-job playwright time of ~3-5m — nowhere
near the cap.

4. shell-snapshot.test.ts 1ms flake (also blocking CI). "serves snapshot values
AS FRESH" builds the seeded store and the expected value from two separate
snapshotOf() calls, and segData embeds `Date.now() + 60_000` as expiresAt — a
millisecond tick between the calls fails the deep-equal (seen on the #659 run).
Pin the clock with fake timers (the shell-cache.test.ts pattern). The rest of the
file was audited: no other assertion deep-equals across two Date.now() instants.

Production backoff ceiling/growth, retry-in-place, the in-flight set, and the e2e
warm-window lengths are unchanged. #652 items 1-2 (barrier narrowing, drain
unification) remain open.
ivogt added a commit that referenced this pull request Jul 4, 2026
…dget, snapshot-test flake (#652 item 3) (#659)

Four coordinated changes unblocking cloudflare-basic-e2e on cold CI runners
(failures on the main run for 2586ea9, the PR #657 runs, and PR #659's own
first CI run):

1. Dev backoff cap (runtime). The refused-capture backoff window is exponential
per consecutive failure, `min(BASE * 2^(failures-1), ceiling)`; the ceiling was a
flat 60s in every mode. On a cold runner each cold re-probe climbs the count, and
once the window exceeds ~16s it outlasts warmToHit's 20s poll: every request is
skipped as backed-off, an eternal MISS. The ceiling is now mode-dependent: 60s in
production (a genuinely ineligible route should be re-probed rarely), ~2s in dev,
where the dominant no-shell cause is a cold module graph that warms on the very
attempt that failed. Dev signal is `process.env.NODE_ENV !== "production"`,
matching handler.ts / server-action.ts / progressive-enhancement.ts.

2. PPR dev warmup (test-side, the sufficient fix). PR #659's first CI run showed
the cap alone is not sufficient: one probe cycle is ~12s (5s attempt + 5s in-place
retry + 2s capped backoff), so the 20s window fits ~2 cycles and today's cold
runners need more because the dev module graph is compiled ON DEMAND during the
capture render, repeatedly blowing the 5s budget (physics hole failed 3x at ~20s).
Extend tests/cloudflare-basic's existing dev-warmup setup project (which the dev
project already depends on) with a ppr warmup: GET every ppr fixture route under
an isolated `?probe=warmup` shell key (module-graph warm only, no test-visible
entry pre-stored), then poll each capturable fixture to x-rango-shell HIT, proving
the full capture pipeline (Flight render, fizz prerender+abort, putShell) is hot
before any real test's warm window starts. Cap + warmup together: the warmup makes
first attempts succeed; the cap remains the safety net for anything cold the
warmup missed. warmToHit windows and runtime constants are otherwise untouched.

3. CI budget (the 600s owner). cloudflare-basic's playwright globalTimeout was
`10 * 60 * 1000` on CI; the merged PPR stack grew the sequential dev ->
production -> hmr run past 10m, so healthy runs brushed the cap and any flake
retry tipped it over — Playwright killed the run mid-flight ("Timed out waiting
600s for the test suite to run"), failing whichever test was executing (the 0ms
hmr-warmup skip-cascade, the mid-hmr kill at 10:31:06 while hmr tests were
passing). Raised to 14m, and the cloudflare-basic-e2e job's timeout-minutes from
15 to 20 so the workflow cap contains ~3-4m setup + the 14m playwright budget.
vite-rsc-demo and rangojs-router keep their 600s globalTimeout: their jobs
shard/split, and recent CI runs show per-job playwright time of ~3-5m — nowhere
near the cap.

4. shell-snapshot.test.ts 1ms flake (also blocking CI). "serves snapshot values
AS FRESH" builds the seeded store and the expected value from two separate
snapshotOf() calls, and segData embeds `Date.now() + 60_000` as expiresAt — a
millisecond tick between the calls fails the deep-equal (seen on the #659 run).
Pin the clock with fake timers (the shell-cache.test.ts pattern). The rest of the
file was audited: no other assertion deep-equals across two Date.now() instants.

Production backoff ceiling/growth, retry-in-place, the in-flight set, and the e2e
warm-window lengths are unchanged. #652 items 1-2 (barrier narrowing, drain
unification) remain open.
The PPR axis-1 safety gate only saw a nonce produced by the
createRouter({ nonce }) provider (threaded as the `nonce` param). A per-request
nonce set via the `nonce` ContextVar token in middleware (ctx.set(nonce, value))
left the threaded param undefined, so a ppr route sailed through the gate,
entered capture, and baked the capture request's nonce into the SHARED shell;
on a HIT that frozen nonce was served to every visitor and the browser's CSP
rejected the nonced scripts for all but the capture request.

The gate now reads the token off the post-middleware request variables at the
commit point (nonce ?? contextGet(reqCtx._variables, nonceToken)) — sound
because the commit point runs after the whole middleware chain. A declared-but-
gated route logs a once-per-key warning (warnPprNonceActiveOnce, mirroring
warnShellStoreMissingOnce) and serves pure axis 1 with no x-rango-shell header;
non-ppr routes stay silent and zero-cost. Capture internals are untouched — the
single gate guards both serve and capture scheduling.

Tests: unit gate matrix (token-set blocks + no getShell/no schedule, provider
blocks, warn-once per key naming the route, non-ppr+token = no warn); e2e
test-app shell-cache + cloudflare-basic ppr-shell (dev+prod) — a new ppr route
whose route-DSL middleware() sets crypto.randomUUID() via the token stays off
HIT across repeated GETs with a DISTINCT nonce each. Docs: ppr SKILL, design
scar-tissue note, feature-map/feature-file-map.
@ivogt
ivogt force-pushed the fix/ppr-nonce-token-gate branch from 7b0c1f2 to 622fda0 Compare July 4, 2026 10:50
@ivogt
ivogt merged commit da524c2 into main Jul 4, 2026
30 checks passed
@ivogt
ivogt deleted the fix/ppr-nonce-token-gate branch July 4, 2026 11:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant