Skip to content

Implement configurable cache header policies#860

Open
ChristianPavilonis wants to merge 4 commits into
mainfrom
refactor/cache-headers
Open

Implement configurable cache header policies#860
ChristianPavilonis wants to merge 4 commits into
mainfrom
refactor/cache-headers

Conversation

@ChristianPavilonis

@ChristianPavilonis ChristianPavilonis commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Standardizes cache policy rendering across Fastly, Cloudflare, CDN, and s-maxage fallback headers.
  • Adds safe, configurable caching for hash-validated TSJS, publisher-origin static assets, and rehosted asset proxy responses.
  • Hardens privacy handling so private, no-store, and cookie-bearing responses strip shared edge-cache headers.

Changes

File Change
crates/trusted-server-core/src/cache_policy.rs Adds typed cache-policy rendering for browser and edge headers, including no-store/private cleanup.
crates/trusted-server-core/src/settings.rs Adds cache.asset_rules config, matchers/presets, validation, runtime prep, and path-to-policy resolution.
trusted-server.example.toml Documents disabled operator-controlled static/fingerprinted asset cache-rule examples.
crates/trusted-server-core/src/http_util.rs Routes static ETag responses through the cache-policy renderer.
crates/trusted-server-core/src/tsjs.rs Uses exact module-set hashes when available and avoids unverifiable fallback hashes.
crates/trusted-server-js/Cargo.toml Makes hashing dependencies available to the build script.
crates/trusted-server-js/build.rs Generates per-module SHA-256 metadata for bundled JS modules.
crates/trusted-server-js/src/bundle.rs Exposes per-module hashes and caches concatenated bundle hashes.
crates/trusted-server-core/src/publisher.rs Applies hash-validated immutable TSJS caching and configured publisher asset cache rules.
crates/trusted-server-core/src/proxy.rs Applies normalized cache policies to rehosted asset proxy responses and reapplies them after finalization.
crates/trusted-server-core/src/response_privacy.rs Removes shared-cache headers from private/no-store/cookie-bearing responses.
crates/trusted-server-core/src/integrations/prebid.rs Makes the neutralized Prebid shim no-store, private instead of long-lived public cache.
crates/trusted-server-core/src/integrations/testlight.rs Updates the default TSJS fallback comment/source behavior for registry-free configuration.
crates/trusted-server-core/src/lib.rs Exports the new cache_policy module.
crates/trusted-server-adapter-axum/src/app.rs Passes the portable s-maxage fallback edge header mode to TSJS and publisher handlers.
crates/trusted-server-adapter-cloudflare/src/app.rs Passes the Cloudflare-specific CDN cache header mode to TSJS and publisher handlers.
crates/trusted-server-adapter-fastly/src/app.rs Passes Fastly Surrogate-Control mode through EdgeZero fallback dispatch.
crates/trusted-server-adapter-fastly/src/main.rs Reapplies asset cache policies with Fastly Surrogate-Control at the shared finalization point used by both legacy and EdgeZero flows.
crates/trusted-server-adapter-fastly/src/route_tests.rs Updates route-test finalization for selected edge cache headers.
crates/trusted-server-adapter-spin/src/app.rs Passes the portable s-maxage fallback edge header mode to TSJS and publisher handlers.
docs/superpowers/specs/2026-07-06-cache-control-header-design.md Adds the cache-control design scope and deferred dynamic caching notes.
docs/superpowers/plans/2026-07-06-cache-control-header-implementation-plan.md Adds the implementation and verification plan for cache-header work.

Closes

Closes #293

Follow-ups:

Test plan

  • cargo test-fastly && cargo test-axum
  • cargo clippy-fastly && cargo clippy-axum
  • cargo fmt --all -- --check
  • JS tests: cd crates/trusted-server-js/lib && npx vitest run
  • JS format: cd crates/trusted-server-js/lib && npm run format
  • Docs format: cd docs && npm run format
  • WASM build: cargo build --package trusted-server-adapter-fastly --release --target wasm32-wasip1
  • Manual testing via fastly compute serve
  • Other: cargo test-cloudflare && cargo test-spin
  • Other: cargo clippy-cloudflare && cargo clippy-spin-native && cargo clippy-spin-wasm
  • Other: cd crates/trusted-server-js/lib && node build-all.mjs
  • Other: git diff --check
  • Other: npx prettier --check docs/superpowers/plans/2026-07-06-cache-control-header-implementation-plan.md docs/superpowers/specs/2026-07-06-cache-control-header-design.md

Note: cd docs && npm run format failed because docs-local Prettier was not installed; touched docs were checked with npx prettier instead.

Checklist

  • Changes follow CLAUDE.md conventions
  • No unwrap() in production code — use expect("should ...")
  • Uses tracing macros (not println!)
  • New code has tests
  • No secrets or credentials committed

@ChristianPavilonis
ChristianPavilonis changed the base branch from main to server-side-ad-templates-impl July 7, 2026 17:59
Base automatically changed from server-side-ad-templates-impl to main July 7, 2026 20:08
ChristianPavilonis

This comment was marked as low quality.

@ChristianPavilonis
ChristianPavilonis marked this pull request as ready for review July 8, 2026 19:00

@prk-Jr prk-Jr left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

Configurable, safe-by-default cache-header policies across all four adapters. Cache policy is expressed once as typed data (CachePolicy / EdgeCacheHeader) and rendered per-runtime; hash-gated immutability, privacy stripping, and operator-controlled asset rules are all well-tested. No blocking issues — logic is sound and correctly platform-scoped. Findings below are all non-blocking.

Verified during review:

  • Hash-gated immutability is safeserve_tsjs_static marks a response immutable (1yr) only when the request ?v= equals the hash of the content actually being served, so a stale URL after a redeploy falls back to the short TTL rather than pinning old content.
  • Injection ↔ serving hash consistency — HTML injection (html_processor.rs) and the serving path (publisher.rs) both derive the hash from js_module_ids_immediate() + concatenated_hash; deferred modules use single_module_hash on both sides.
  • Privacy hardeningprivate / no-store / cookie-bearing responses strip all four edge-cache headers, with the downgrade re-run after operator headers are applied.
  • Origin no-store not upgraded — a split later Cache-Control field carrying no-store correctly blocks the normalized upgrade.
  • Asset-proxy finalization is correctly Fastly-onlyhandle_asset_proxy_request / apply_after_route_finalization are wired only on Fastly, so there is no missing-reapplication gap on Cloudflare / Axum / Spin.

Non-blocking

🤔 thinking

  • Hex-only fingerprint heuristic: filename_contains_hash misses base62/base36 bundler hashes (false negative → silently uncached) and can match coincidental hex stems (false positive → stale). (settings.rs)
  • Normalized policy overrides origin no-cache/Vary: upgrade gate checks only private/no-store. (publisher.rs)

🌱 seedling

  • handle_publisher_request is now at the 7-argument CLAUDE.md limit; EdgeCacheHeader is threaded through several signatures — consider a request-context struct. (publisher.rs)

⛏ nitpick

  • SURROGATE_CACHE_HEADERS re-export is now a misnomer (contains CDN headers) with no in-tree consumers. (response_privacy.rs:21)

📝 note

  • #[validate(nested)] on cache is a no-op; real validation lives in prepare_runtime. (settings.rs)

👍 praise

  • Directive-exact Cache-Control matching closes the old substring-match privacy hole. (cache_policy.rs)

CI Status

  • fmt: PASS
  • clippy (fastly / axum / cloudflare / cloudflare-wasm / spin-native / spin-wasm): PASS
  • rust tests (fastly / axum / cloudflare / spin / CLI / parity): PASS
  • js tests (vitest): PASS
  • docs / typescript format: PASS
  • CodeQL + integration/browser tests: PASS

Comment thread crates/trusted-server-core/src/settings.rs Outdated
Comment thread crates/trusted-server-core/src/publisher.rs
Comment thread crates/trusted-server-core/src/publisher.rs
Comment thread crates/trusted-server-core/src/response_privacy.rs Outdated
Comment thread crates/trusted-server-core/src/settings.rs
Comment thread crates/trusted-server-core/src/cache_policy.rs

@aram356 aram356 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

Solid, well-shaped abstraction — expressing cache policy as typed data and rendering it per-runtime is the right call, and the multi-value Cache-Control handling (get_all + directive-name-exact matching, so no-storey / not-private don't false-match) is careful work.

Four blocking issues, though. The most important is that the rehosted-asset path will override an origin's explicit no-store, using a guard that this very commit wrote for the publisher path but didn't wire into the asset proxy. The other three are a missing immutable safety check, a fingerprint heuristic that can't match the two most common bundlers, and a docs/behavior mismatch on disabled rules that can hard-fail startup.

Findings below were verified by running the code or by an adversarial pass. Four other hypotheses I chased (a missing GET/HEAD gate on asset routes, an EC-cookie shared-cache leak on Fastly, "zero caching" from the hash gate, and a broad TSJS regression) all turned out to be false and are deliberately not reported.

Blocking

🔧 wrench

  • Asset-proxy rehost overrides an origin no-store / privateproxy.rs:1173. Only the status is checked; publisher.rs:470 guards this correctly for the same feature. The PR's own test (proxy.rs:3755) feeds an origin no-store and asserts it becomes public, max-age=31536000, immutable. No downstream rescue: the Set-Cookie backstop can't fire because the asset proxy strips set-cookie.
  • Normalized re-publicizes after privacy hardeningproxy.rs:127. The same root cause at a second layer. apply_after_route_finalization used to only ever make responses more private, so running it last was safe; the new Normalized arm makes them more public and still runs last. This inverts the invariant in the plan doc (L56-57): hardening "runs after any new policy application". Both sites need the fix.
  • immutable = true accepted with no fingerprint requirementsettings.rs:1983. requires_hash_in_filename defaults to false, so path_prefix = "/assets/" + immutable = true puts a non-revalidatable year-long policy on an unfingerprinted /assets/app.js. Contradicts the plan doc (L47-49): "immutable only for TS-fingerprinted rehosted URLs".
  • Hex-only fingerprint gate never matches Vite or esbuildsettings.rs:2204, with the reachable trap at configuration.md:1041. Verified against real builds: Vite 8 emits /assets/index-DA15JTLU.js (base64url), esbuild /assets/app-VRTVD5R5.js (base32). /assets/ is Vite's default output dir — exactly what the enabled = true docs example globs. And it isn't a clean no-op: ~0.02% of Vite hashes are all-hex by chance, so the rule fires on ~1 in 5,000 assets, varying per build.
  • "Disabled rules are ignored" is falseconfiguration.md:998. prepare_runtime validates every rule regardless of enabled. Confirmed by execution: a disabled rule with a bad regex, or a disabled placeholder with no matcher, both fail startup — which per line 54 of the same page means the service returns its startup-error response. This path has no test, which CLAUDE.md's reviewer checklist explicitly asks for.

❓ question

  • What consumes edge_ttl_seconds on Fastly today?configuration.md:1014. Fastly's read-through cache stores the backend's response and decides TTL from the backend's headers at send(); this PR rewrites headers on egress, after that decision. Caching a Wasm-synthesized response needs an explicit Core/Simple Cache call, and the repo has zero uses of fastly::cache / CacheOverride / SimpleCache. Is there a service-layer piece outside the repo? To be fair: the Surrogate-Control emission predates this PR, so it's not a regression here — but this PR is what turns it into a documented operator knob.
  • Is spec acceptance criterion #138 handled at the service layer? The design doc requires "Runtime cache-key configuration preserves the v query parameter for /static/tsjs=". This matters more now: the same path serves either a 1-year immutable response (matching ?v=) or a 300s one (bare/mismatched), discriminated only by query string — and this PR makes the bare URL a real, emitted URL for the first time. If any shared cache normalizes the query away, those two cross-contaminate. There's no cache-key config in fastly.toml / edgezero.toml, and the operator docs never mention the requirement. All 13 acceptance criteria in the shipped spec are still unchecked.

Non-blocking

🌱 seedling

  • Cloudflare is ~2 lines from actually working. Cloudflare's Workers Cache (GA 2026-07-06) documents cloudflare-cdn-cache-control as its highest-precedence cache directive — exactly what this PR emits. But it's opt-in, and neither wrangler.toml nor wrangler.ci.toml has a [cache] block, so the header is inert today. Adding [cache]\nenabled = true (Wrangler ≥ 4.69.0) would turn EdgeCacheHeader::CloudflareCdnCacheControl from a no-op into a fully effective directive — plausibly the highest-ROI change available here. (compatibility_date = "2024-09-23" is also stale.)
  • tsjs_unified_script_src() dropped ?v=tsjs.rs:30. Bounded ~6-minute post-deploy staleness on the ad-creative path only. Details inline; suggest a follow-up issue rather than expanding this PR.

📌 out of scope

  • The runtime half of this belongs in edgezero, not trusted-server-core. Worth a follow-up issue, not a change to this PR.

    EdgeCacheHeader encodes a purely platform fact — which shared-cache header does this runtime speak. The tell is that all four adapters hand-thread a per-adapter constant (SurrogateControl / CloudflareCdnCacheControl / SMaxageFallback) into handle_tsjs_dynamic and handle_publisher_request. The adapter already knows its own runtime; it shouldn't have to tell core what platform it is. That plumbing is also what pushed handle_publisher_request to exactly 7 parameters, CLAUDE.md's stated ceiling.

    More importantly, the part that would make edge_ttl_seconds actually work can only be built in edgezero. edgezero-core currently has no cache concept at all, and edgezero-adapter-fastly/src/proxy.rs:31 sends upstream with send_async_streaming(&backend_name) and no CacheOverride — so the store/TTL decision for proxied responses is made inside edgezero, before this PR's egress-time header rewrite ever runs. Trusted Server cannot fix that from where it sits.

    A split that seems right:

    • edgezeroEdgeCacheHeader and the edge-header-name registry; the CachePolicy → platform headers render step (ideally behind an adapter method like apply_cache_policy(&policy, &mut resp), so the parameter disappears from core signatures entirely); CacheOverride on backend sends; the Core/Simple Cache API for synthetic responses; the wrangler [cache] block.
    • trusted-serverCachePolicy as typed domain data, the cache.asset_rules config surface and matching, and the response_privacy invariants (which would consume edgezero's header registry rather than own it).

    None of this blocks the PR — the browser-facing half is real and useful today. But it does mean the edge half is currently a promise the runtime layer can't keep, which is worth being explicit about before operators configure edge_ttl_seconds expecting shared-cache behavior.

♻️ refactor

  • SURROGATE_CACHE_HEADERS has zero consumersresponse_privacy.rs:21. Dead re-export, and now a misnomer since it includes the CDN headers. Delete it.

🤔 thinking

  • No validation that a rule sets any TTL. visibility = "public" with neither browser_ttl_seconds nor edge_ttl_seconds renders a bare Cache-Control: public, which hands the response to heuristic freshness. Probably worth rejecting at config load.
  • The cache-rule path is completely silent. There isn't a single log:: statement in settings.rs:1890-2215 or in cache_policy.rs, and the application site is a bare if let with no else. A rule that matches nothing — the hash-gate case above, for instance — is undebuggable in production. A log::debug! on gate rejection would pay for itself.

📝 note

  • #[validate(nested)] on Settings.cache is a no-op. CacheSettings declares no field validators and CacheAssetRule doesn't derive Validate, so the attribute does nothing today. Harmless, but it reads as protection that isn't there.
  • The PR description says asset cache policies are reapplied "in legacy and EdgeZero flows", but there's only one call site (main.rs:206).

CI Status

All 19 checks green at a5eb7a3, verified via gh pr checks:

  • fmt: PASS
  • clippy (fastly / axum / cloudflare / cloudflare-wasm / spin-native / spin-wasm): PASS
  • rust tests (fastly, axum native, cloudflare, spin, cross-adapter parity, ts CLI): PASS
  • js tests (vitest) + format-typescript + format-docs: PASS
  • integration + browser integration + CodeQL: PASS

Comment thread crates/trusted-server-core/src/proxy.rs
Comment thread crates/trusted-server-core/src/proxy.rs
Comment thread crates/trusted-server-core/src/settings.rs
Comment thread crates/trusted-server-core/src/settings.rs Outdated
Comment thread docs/guide/configuration.md Outdated
Comment thread docs/guide/configuration.md Outdated
Comment thread docs/guide/configuration.md Outdated
Comment thread crates/trusted-server-core/src/response_privacy.rs Outdated
Comment thread crates/trusted-server-core/src/tsjs.rs
@aram356

aram356 commented Jul 16, 2026

Copy link
Copy Markdown
Collaborator

@ChristianPavilonis Please resolve conflicts

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Refactor and standardize how Trusted Server sets cache response headers

3 participants