Skip to content

feat: keep documents private by default - #162

Merged
KyleJune merged 1 commit into
mainfrom
feat/private-document-cache-policy
Sep 24, 2026
Merged

KyleJune merged 1 commit into
mainfrom
feat/private-document-cache-policy

Conversation

@KyleJune

Copy link
Copy Markdown
Member

Summary

A document (HTML) response carries the hydrated data of every loader that ran, such as a layout loader's signed-in user, and the request's serialized context. Before this change, the deepest route's loader or action headers reached the document as written. Since 0.16.4, so did a thrown HttpError's headers. So a loader or error sending Cache-Control: public, max-age=60 made a publicly cacheable page that held one reader's data. udibo found this class of leak in practice and fixed it on its side (udibo/udibo#1482). Data responses have been private by default since #145. This PR does the same for documents.

The rule. On a document, Juniper keeps the cache headers that come from the route from letting a shared cache store the page. That covers a loader's or action's data() or Response, and an HttpError thrown by a loader, action or middleware.

  • Cache-Control: public, s-maxage, and a field-qualified private="…" are removed. private is added at the front unless a bare private or no-store remains. Every other directive is kept, including max-age, no-cache, no-store and stale-while-revalidate. So public, max-age=60 becomes private, max-age=60, public, no-store becomes no-store, and max-age=60 becomes private, max-age=60. A policy that needs no change is sent byte for byte.
  • CDN-Cache-Control, *-CDN-Cache-Control and Surrogate-Control become no-store. Dropping them instead could let a CDN fall back to its default TTL, and private isn't defined for Surrogate-Control.
  • Expires is dropped when the route sends no Cache-Control, because on its own it makes the page storable by a shared cache.

No exemption for documents without loader data. The rule applies even when no loader ran, for example on the error page for an HttpError that middleware throws. Such a document still serializes the request's router context, which middleware usually fills per user, and its layouts can render from that context. Juniper can't tell per-user context from shared context, so "no loader data" doesn't mean "nothing per-request". A page that really is public uses the opt-in.

The opt-in. Route middleware sets a new optional AppEnv variable, c.set("publicDocument", true), and the route's cache headers go out on the document as written. It is per request and scoped by where the middleware is mounted. It follows the pattern of the existing secureHeadersNonce variable. createServer takes no options and is generated into main.ts. A route-module export would need Builder changes and per-match resolution. So a context variable is the smallest idiomatic surface.

Unchanged:

  • Data responses keep a route's policy as written, because they carry only that route's data or error.
  • A header that route middleware sets, before or after next(), is not rewritten. It is the app's choice for every response of the route. A policy from the route still replaces it, as before.
  • A document that gets no policy is still sent without one.
  • Redirects on document requests keep their own headers.

Mechanism. renderDocument now builds the route headers (action, then loader, then error, with cookies appended in that order) into one Headers, applies the rule, and returns through newResponse → commitResponse, the #152 commit path. Before, it wrote each header with c.header and returned stream(c, …) directly. The document is the error page and the normal page alike, so this is the only place the rule runs.

Versioning: feat:, not fix: and not feat!

  • It adds public API: the publicDocument variable on AppEnv. That makes it at least a feat.
  • It follows feat: default data responses to a private cache policy #145's precedent. feat: default data responses to a private cache policy #145 made data responses private by default and required an opt-in for public ones, and it shipped as feat: (0.16.0) with a behaviour-change note.
  • No published surface breaks. Exports, types, endpoints and cookie or token formats are unchanged. The old result is one line of middleware away.
  • In 0.x, feat and feat! both cut a minor. The practical result is 0.17.0, which ^0.16 ranges don't pick up automatically, so affected apps get the change only when they deliberately upgrade. A fix: would cut 0.16.7, which would reach every ^0.16.x app silently.

Behavior change for existing apps

An app that relied on a shared cache storing a document now gets private caching on that document. This affects apps whose loader, action or thrown error sends public, s-maxage, a bare max-age, a CDN cache field, or Expires alone on a document route. They opt back in with c.set("publicDocument", true) in that route's middleware, after checking that every loader on the page, layouts included, and the shared context are the same for every visitor. Apps that set their document policy in middleware see no change.

Changes

  • src/_server.tsx:
    • AppEnv.publicDocument?: boolean, with JSDoc.
    • renderDocument assembles the route headers, then calls keepDocumentPrivate, then commits through newResponse.
    • New helpers: privateCachePolicy, which splits directives quote-aware so private="a, b" stays one directive; isSharedCacheDirective; keepDocumentPrivate.
  • src/server.tsx: the createServer JSDoc states the document rule and the opt-in.
  • docs/routing.md, Caching Loader Data:
    • A new Caching Documents subsection covers what a document carries, the rewrite with a table, the CDN field and Expires rules, the no-exemption rule, what isn't rewritten, and the opt-in with an example.
    • The blog middleware example now sets public only on data requests. Before, it also made documents public, which would leak per-user layout data.
    • The middleware-policy wording and the Response/data() bullets now say what happens on a document.
  • docs/error-handling.md, Error Headers: the error document's cache headers are made private, the data error response keeps them, and a link to the opt-in.
  • src/server.test.tsx: new suite, the cache policy of a document.

Testing

Every case in the new suite runs twice: with cors() in front of the app's middleware and without it. That is 138 cases in all. Each document case first checks the status, Content-Type: text/html; charset=utf-8, and the app's own header. Where the page hydrates loader data, it also checks that the HTML contains the root loader's per-reader state.

  • Rewrite table. 10 policies × 5 sources: an HttpError the loader throws, data() the loader returns, a Response the loader returns, data() the action returns, and an HttpError the action throws. The policies include public, max-age=60, s-maxage, casing, qualified private with a comma inside the quotes, bare max-age, no-cache, public, no-store and no-store, and already-private values kept byte for byte.
  • Opt-in. public, max-age=60 is kept for every source, for a middleware-thrown error, for CDN fields and for Expires.
  • No loader data. A middleware-thrown public error is private both when the layout has a loader that didn't run and when no route has a loader.
  • Unchanged cases.
    • A middleware policy is kept.
    • A document without a policy gets none.
    • A data request keeps data()'s public policy and its CDN fields.
  • Header assembly.
    • GET: the cookies of the app, then the loader, with the loader's headers over the app's.
    • POST: the cookies of the app, the action, then the error, with the error's headers over the action's.
    • An error a layout loader throws beats the page loader's policy.
  • CDN fields and Expires. CDN fields become no-store. Expires is dropped when there is no Cache-Control and kept beside one.

Before the fix, all 74 rewrite and no-loader-data cases failed at the Cache-Control assertion (for example, - public, max-age=60 / + private, max-age=60). The opt-in and unchanged cases passed. The two CDN and Expires cases added in review also failed at their own assertions before that fix.

Mutation checks. Each file was restored by writing back the saved content, and a sha256 check confirmed the match. Each mutation failed only at its intended assertion:

Mutation Result
Skip the Cache-Control rewrite 80 cases red, including every rewrite case and both no-loader-data cases
Ignore publicDocument the 16 opt-in cases red
Exempt documents without loader data the 4 no-loader-data cases red
Keep a qualified private, or split directives on raw commas the qualified-private cases red
Don't count no-store as private the no-store and public, no-store cases red
Keep CDN fields / keep a lone Expires their cases red
Drop loader cookies / drop action cookies / reverse header precedence the header-assembly cases red
Return c.newResponse without commitResponse the header-assembly cases red under cors() only

Gates, from the Juniper root:

  • deno task check: exit 0
  • deno task test --parallel --reporter=dot: 59 passed, 840 steps
  • deno task test:example --parallel --reporter=dot: 33 passed

Review. An adversarial reviewer ran 15 probes against this change and against main. It found no regressions from the renderDocument refactor across HEAD, cookie order, status, cors(), a response middleware committed before next(), the error handler, deferred documents, bot requests or aborts. It found two defects, both fixed here:

  • CDN-Cache-Control, Surrogate-Control and a lone Expires from a loader still made the document storable by a CDN.
  • The rewritten header and cookie loops weren't pinned by any test.

Its doc suggestions are applied too: the blog example, the middleware wording, and the Response/data() bullets. A second pass on those fixes found no defects, and its own two mutations (a narrower CDN field pattern, and always dropping Expires) each failed the intended test.

Not covered: vendor cache headers with their own syntax or precedence, such as Akamai's Edge-Control and nginx's X-Accel-Expires, pass through unchanged. The CDN rule covers only the fields with a published spec: RFC 9213's CDN-Cache-Control family and Surrogate-Control. The second reviewer pass raised this as a question of scope, not a defect.

Pre-existing, not changed here: under --parallel, src/utils/testing.test.tsx clears the process environment for a moment. One of #159's cases then sees development mode and fails. I saw it once in four full runs on this branch. It is filed as #161 and left open by this PR.

Closes

Closes #157

🤖 Generated with Claude Code

A document carries the hydrated data of every loader that ran and the
request's serialized context, but a loader's, action's or thrown error's
cache headers reached it as written. `Cache-Control: public` from one route
made a publicly cacheable page holding per-reader data.

On a document, Juniper now rewrites route cache headers so a shared cache
cannot store the page: `Cache-Control` drops `public`, `s-maxage` and a
field-qualified `private` and gains `private` unless `private` or
`no-store` remains; CDN cache fields become `no-store`; a lone `Expires` is
dropped. Route middleware opts a page back in with
`c.set("publicDocument", true)`. Data responses and middleware-set headers
are unchanged. The document now commits through `newResponse`, the same
path as every other loader and action response.

Closes #157

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@KyleJune
KyleJune merged commit 5cc63a5 into main Sep 24, 2026
10 checks passed
@KyleJune
KyleJune deleted the feat/private-document-cache-policy branch September 24, 2026 22:18
KyleJune pushed a commit that referenced this pull request Sep 24, 2026
# [0.17.0](0.16.6...0.17.0) (2026-09-24)

### Features

* keep documents private by default ([#162](#162)) ([5cc63a5](5cc63a5)), closes [#157](#157)
@github-actions

Copy link
Copy Markdown

🎉 This PR is included in version 0.17.0 🎉

The release is available on:

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Error documents should not take a shared-cache policy from the error while carrying loader data

1 participant