From d7737d3c2b00546ba15a19e1ab7b1b4be2b054c7 Mon Sep 17 00:00:00 2001 From: Christian Date: Wed, 8 Jul 2026 11:25:17 -0500 Subject: [PATCH 1/8] Implement configurable cache header policies --- crates/trusted-server-adapter-axum/src/app.rs | 4 +- .../tests/routes.rs | 36 + .../src/app.rs | 8 +- .../tests/routes.rs | 37 + .../trusted-server-adapter-fastly/src/app.rs | 6 +- .../trusted-server-adapter-fastly/src/main.rs | 3 +- crates/trusted-server-adapter-spin/src/app.rs | 4 +- .../tests/routes.rs | 30 + .../trusted-server-core/src/cache_policy.rs | 565 +++ crates/trusted-server-core/src/http_util.rs | 44 +- .../src/integrations/prebid.rs | 18 +- .../src/integrations/testlight.rs | 5 +- crates/trusted-server-core/src/lib.rs | 1 + crates/trusted-server-core/src/proxy.rs | 168 +- crates/trusted-server-core/src/publisher.rs | 3188 +++-------------- .../src/response_privacy.rs | 194 +- crates/trusted-server-core/src/settings.rs | 474 +++ crates/trusted-server-core/src/tsjs.rs | 47 +- crates/trusted-server-js/Cargo.toml | 3 +- crates/trusted-server-js/build.rs | 47 +- crates/trusted-server-js/src/bundle.rs | 163 +- docs/guide/configuration.md | 73 + ...ache-control-header-implementation-plan.md | 446 +++ .../2026-07-06-cache-control-header-design.md | 145 + trusted-server.example.toml | 21 + 25 files changed, 2886 insertions(+), 2844 deletions(-) create mode 100644 crates/trusted-server-core/src/cache_policy.rs create mode 100644 docs/superpowers/plans/2026-07-06-cache-control-header-implementation-plan.md create mode 100644 docs/superpowers/specs/2026-07-06-cache-control-header-design.md diff --git a/crates/trusted-server-adapter-axum/src/app.rs b/crates/trusted-server-adapter-axum/src/app.rs index 1bed830ac..3cce48ad7 100644 --- a/crates/trusted-server-adapter-axum/src/app.rs +++ b/crates/trusted-server-adapter-axum/src/app.rs @@ -11,6 +11,7 @@ use edgezero_core::router::RouterService; use error_stack::Report; use trusted_server_core::auction::endpoints::handle_auction; use trusted_server_core::auction::{AuctionOrchestrator, build_orchestrator}; +use trusted_server_core::cache_policy::EdgeCacheHeader; use trusted_server_core::ec::EcContext; use trusted_server_core::error::{IntoHttpResponse as _, TrustedServerError}; use trusted_server_core::integrations::{IntegrationRegistry, ProxyDispatchInput}; @@ -183,7 +184,7 @@ async fn dispatch_fallback( let method = req.method().clone(); if method == Method::GET && path.starts_with("/static/tsjs=") { - return handle_tsjs_dynamic(&req, &state.registry); + return handle_tsjs_dynamic(&req, &state.registry, EdgeCacheHeader::SMaxageFallback); } if state.registry.has_route(&method, &path) { @@ -222,6 +223,7 @@ async fn dispatch_fallback( &mut ec_context, auction, req, + EdgeCacheHeader::SMaxageFallback, ) .await?; // Async finalize so the dispatched auction is collected and its bids are diff --git a/crates/trusted-server-adapter-axum/tests/routes.rs b/crates/trusted-server-adapter-axum/tests/routes.rs index 03caa3d11..2c230137a 100644 --- a/crates/trusted-server-adapter-axum/tests/routes.rs +++ b/crates/trusted-server-adapter-axum/tests/routes.rs @@ -208,6 +208,42 @@ async fn tsjs_route_prefix_is_handled_not_5xx() { ); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn tsjs_route_matching_hash_uses_s_maxage_fallback() { + let mut svc = make_service(); + let src = trusted_server_core::tsjs::tsjs_script_src(&["creative"]); + let req = Request::builder() + .method("GET") + .uri(src) + .body(AxumBody::empty()) + .expect("should build request"); + + let resp = svc + .ready() + .await + .expect("should be ready") + .call(req) + .await + .expect("should respond"); + + assert_eq!( + resp.status().as_u16(), + 200, + "matching TSJS hash should serve OK" + ); + assert_eq!( + resp.headers() + .get("cache-control") + .and_then(|value| value.to_str().ok()), + Some("public, max-age=31536000, s-maxage=31536000, immutable"), + "Axum adapter should render the portable s-maxage fallback" + ); + assert!( + resp.headers().get("surrogate-control").is_none(), + "s-maxage fallback must not emit Fastly Surrogate-Control" + ); +} + // --------------------------------------------------------------------------- // Middleware tests // --------------------------------------------------------------------------- diff --git a/crates/trusted-server-adapter-cloudflare/src/app.rs b/crates/trusted-server-adapter-cloudflare/src/app.rs index 644676fc5..1b5bd0ab9 100644 --- a/crates/trusted-server-adapter-cloudflare/src/app.rs +++ b/crates/trusted-server-adapter-cloudflare/src/app.rs @@ -10,6 +10,7 @@ use edgezero_core::router::RouterService; use error_stack::Report; use trusted_server_core::auction::endpoints::handle_auction; use trusted_server_core::auction::{AuctionOrchestrator, build_orchestrator}; +use trusted_server_core::cache_policy::EdgeCacheHeader; #[cfg(target_arch = "wasm32")] use trusted_server_core::config_payload::settings_from_config_blob; use trusted_server_core::ec::EcContext; @@ -380,7 +381,11 @@ fn build_router(state: &Arc) -> RouterService { let allow_tsjs = method == Method::GET; let result = if allow_tsjs && path.starts_with("/static/tsjs=") { - handle_tsjs_dynamic(&req, &state.registry) + handle_tsjs_dynamic( + &req, + &state.registry, + EdgeCacheHeader::CloudflareCdnCacheControl, + ) } else if state.registry.has_route(&method, &path) { let mut ec_context = EcContext::default(); state @@ -414,6 +419,7 @@ fn build_router(state: &Arc) -> RouterService { &mut ec_context, auction, req, + EdgeCacheHeader::CloudflareCdnCacheControl, ) .await { diff --git a/crates/trusted-server-adapter-cloudflare/tests/routes.rs b/crates/trusted-server-adapter-cloudflare/tests/routes.rs index 09e3ed324..b66a7dc7a 100644 --- a/crates/trusted-server-adapter-cloudflare/tests/routes.rs +++ b/crates/trusted-server-adapter-cloudflare/tests/routes.rs @@ -203,6 +203,43 @@ async fn tsjs_route_is_routed_not_5xx() { assert!(status < 500, "tsjs route must not 5xx: got {status}"); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn tsjs_route_emits_cloudflare_cache_header_for_matching_hash() { + let router = test_router(); + let src = trusted_server_core::tsjs::tsjs_script_src(&["creative"]); + let req = request_builder() + .method("GET") + .uri(src) + .body(edgezero_core::body::Body::empty()) + .expect("should build request"); + + let resp = route(router, req).await; + + assert_eq!( + resp.status().as_u16(), + 200, + "matching TSJS hash should serve OK" + ); + assert_eq!( + resp.headers() + .get("cache-control") + .and_then(|value| value.to_str().ok()), + Some("public, max-age=31536000, immutable"), + "browser cache policy should be immutable for matching TSJS hash" + ); + assert_eq!( + resp.headers() + .get("cloudflare-cdn-cache-control") + .and_then(|value| value.to_str().ok()), + Some("max-age=31536000"), + "Cloudflare adapter should emit the Cloudflare-specific edge header" + ); + assert!( + resp.headers().get("surrogate-control").is_none(), + "Cloudflare adapter must not emit Fastly Surrogate-Control" + ); +} + /// Verify that every expected explicit route is registered in the route table. /// /// Uses [`RouterService::routes()`] for introspection rather than checking diff --git a/crates/trusted-server-adapter-fastly/src/app.rs b/crates/trusted-server-adapter-fastly/src/app.rs index d6090c983..ca93eb3c2 100644 --- a/crates/trusted-server-adapter-fastly/src/app.rs +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -96,7 +96,8 @@ use edgezero_core::router::RouterService; use error_stack::Report; use trusted_server_core::auction::AuctionTelemetrySink; use trusted_server_core::auction::endpoints::handle_auction; -use trusted_server_core::auction::{AuctionOrchestrator, build_orchestrator}; +use trusted_server_core::auction::{build_orchestrator, AuctionOrchestrator}; +use trusted_server_core::cache_policy::EdgeCacheHeader; use trusted_server_core::constants::{COOKIE_SHAREDID, COOKIE_TS_EIDS}; use trusted_server_core::ec::EcContext; use trusted_server_core::ec::batch_sync::handle_batch_sync; @@ -735,7 +736,7 @@ async fn dispatch_fallback( }; let result = if uses_dynamic_tsjs_fallback(&method, &path) { - handle_tsjs_dynamic(&req, &state.registry) + handle_tsjs_dynamic(&req, &state.registry, EdgeCacheHeader::SurrogateControl) } else if state.registry.has_route(&method, &path) { // Integration-proxy responses are not bounded by // publisher.max_buffered_body_bytes. Publisher fallback below uses the @@ -808,6 +809,7 @@ async fn dispatch_fallback( &mut ec.ec_context, auction, req, + EdgeCacheHeader::SurrogateControl, ) .await { diff --git a/crates/trusted-server-adapter-fastly/src/main.rs b/crates/trusted-server-adapter-fastly/src/main.rs index 39d35b198..24be7ad20 100644 --- a/crates/trusted-server-adapter-fastly/src/main.rs +++ b/crates/trusted-server-adapter-fastly/src/main.rs @@ -11,6 +11,7 @@ use error_stack::Report; use fastly::http::Method as FastlyMethod; use fastly::{Request as FastlyRequest, Response as FastlyResponse}; +use trusted_server_core::cache_policy::EdgeCacheHeader; use trusted_server_core::ec::device::DeviceSignals; use trusted_server_core::ec::finalize::ec_finalize_response; use trusted_server_core::ec::kv::KvIdentityGraph; @@ -202,7 +203,7 @@ fn edgezero_main(mut req: FastlyRequest) { } if let Some(policy) = asset_cache_policy { - policy.apply_after_route_finalization(&mut response); + policy.apply_after_route_finalization(&mut response, EdgeCacheHeader::SurrogateControl); } if let Some(ec_state) = ec_state { diff --git a/crates/trusted-server-adapter-spin/src/app.rs b/crates/trusted-server-adapter-spin/src/app.rs index 960bafc41..7348a7c17 100644 --- a/crates/trusted-server-adapter-spin/src/app.rs +++ b/crates/trusted-server-adapter-spin/src/app.rs @@ -10,6 +10,7 @@ use edgezero_core::router::RouterService; use error_stack::Report; use trusted_server_core::auction::endpoints::handle_auction; use trusted_server_core::auction::{AuctionOrchestrator, build_orchestrator}; +use trusted_server_core::cache_policy::EdgeCacheHeader; use trusted_server_core::ec::EcContext; use trusted_server_core::error::{IntoHttpResponse as _, TrustedServerError}; use trusted_server_core::http_util::sanitize_forwarded_headers; @@ -665,7 +666,7 @@ fn build_router(state: &Arc) -> RouterService { // Dynamic tsjs serving is GET-only; other methods fall through to the // integration/publisher fallback. let result = if method == Method::GET && path.starts_with("/static/tsjs=") { - handle_tsjs_dynamic(&req, &state.registry) + handle_tsjs_dynamic(&req, &state.registry, EdgeCacheHeader::SMaxageFallback) } else if state.registry.has_route(&method, &path) { let mut ec_context = EcContext::default(); state @@ -699,6 +700,7 @@ fn build_router(state: &Arc) -> RouterService { &mut ec_context, auction, req, + EdgeCacheHeader::SMaxageFallback, ) .await { diff --git a/crates/trusted-server-adapter-spin/tests/routes.rs b/crates/trusted-server-adapter-spin/tests/routes.rs index 2f7b1037e..d88c37fa3 100644 --- a/crates/trusted-server-adapter-spin/tests/routes.rs +++ b/crates/trusted-server-adapter-spin/tests/routes.rs @@ -209,6 +209,36 @@ async fn tsjs_route_is_routed_not_5xx() { assert!(status < 500, "tsjs route must not 5xx: got {status}"); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn tsjs_route_matching_hash_uses_s_maxage_fallback() { + let router = test_router(); + let src = trusted_server_core::tsjs::tsjs_script_src(&["creative"]); + let req = request_builder() + .method("GET") + .uri(src) + .body(edgezero_core::body::Body::empty()) + .expect("should build request"); + + let resp = route(router, req).await; + + assert_eq!( + resp.status().as_u16(), + 200, + "matching TSJS hash should serve OK" + ); + assert_eq!( + resp.headers() + .get("cache-control") + .and_then(|value| value.to_str().ok()), + Some("public, max-age=31536000, s-maxage=31536000, immutable"), + "Spin adapter should render the portable s-maxage fallback" + ); + assert!( + resp.headers().get("surrogate-control").is_none(), + "s-maxage fallback must not emit Fastly Surrogate-Control" + ); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn verify_signature_is_routed() { let router = test_router(); diff --git a/crates/trusted-server-core/src/cache_policy.rs b/crates/trusted-server-core/src/cache_policy.rs new file mode 100644 index 000000000..5b2b09a3e --- /dev/null +++ b/crates/trusted-server-core/src/cache_policy.rs @@ -0,0 +1,565 @@ +//! Structured cache-policy rendering helpers. +//! +//! Cache policy is expressed once as typed data and then rendered into the +//! runtime-specific headers used by each edge platform. The helpers in this +//! module only write cache-control headers; response privacy hardening still +//! runs later so personalized or cookie-bearing responses cannot be made +//! shared-cacheable by accident. + +use std::time::Duration; + +use http::header::{self, HeaderName}; +use http::{HeaderMap, HeaderValue}; + +/// String name Fastly uses for shared-cache control. +pub const HEADER_SURROGATE_CONTROL_NAME: &str = "surrogate-control"; +/// String name Fastly may use for shared-cache control in some configurations. +pub const HEADER_FASTLY_SURROGATE_CONTROL_NAME: &str = "fastly-surrogate-control"; +/// String name for the standards-track CDN-only shared-cache control header. +pub const HEADER_CDN_CACHE_CONTROL_NAME: &str = "cdn-cache-control"; +/// String name for Cloudflare-specific CDN-only shared-cache control. +pub const HEADER_CLOUDFLARE_CDN_CACHE_CONTROL_NAME: &str = "cloudflare-cdn-cache-control"; + +/// Runtime edge-cache header names owned by this crate. +pub const EDGE_CACHE_HEADER_NAMES: &[&str] = &[ + HEADER_SURROGATE_CONTROL_NAME, + HEADER_FASTLY_SURROGATE_CONTROL_NAME, + HEADER_CDN_CACHE_CONTROL_NAME, + HEADER_CLOUDFLARE_CDN_CACHE_CONTROL_NAME, +]; + +/// Header name Fastly uses for shared-cache control. +pub const HEADER_SURROGATE_CONTROL: HeaderName = + HeaderName::from_static(HEADER_SURROGATE_CONTROL_NAME); +/// Header name Fastly may use for shared-cache control in some configurations. +pub const HEADER_FASTLY_SURROGATE_CONTROL: HeaderName = + HeaderName::from_static(HEADER_FASTLY_SURROGATE_CONTROL_NAME); +/// Standards-track header name for CDN-only shared-cache control. +pub const HEADER_CDN_CACHE_CONTROL: HeaderName = + HeaderName::from_static(HEADER_CDN_CACHE_CONTROL_NAME); +/// Cloudflare-specific header name for CDN-only shared-cache control. +pub const HEADER_CLOUDFLARE_CDN_CACHE_CONTROL: HeaderName = + HeaderName::from_static(HEADER_CLOUDFLARE_CDN_CACHE_CONTROL_NAME); + +/// Cache-control value used when a response must not be stored. +pub const NO_STORE_PRIVATE_CACHE_CONTROL: &str = "no-store, private"; + +/// Shared-cache header family emitted for the current runtime. +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub enum EdgeCacheHeader { + /// Emit Fastly's `Surrogate-Control` header. + SurrogateControl, + /// Emit the standards-track `CDN-Cache-Control` header. + CdnCacheControl, + /// Emit Cloudflare's `Cloudflare-CDN-Cache-Control` header. + CloudflareCdnCacheControl, + /// Put `s-maxage` into `Cache-Control` instead of emitting a separate edge header. + SMaxageFallback, + /// Do not emit edge-cache directives. + None, +} + +/// Cache visibility for the browser-facing `Cache-Control` header. +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub enum CacheVisibility { + /// Response may be stored by shared caches when edge directives allow it. + Public, + /// Response is private to the requesting browser. + Private, +} + +impl CacheVisibility { + fn directive(self) -> &'static str { + match self { + Self::Public => "public", + Self::Private => "private", + } + } +} + +impl EdgeCacheHeader { + fn header_name(self) -> Option { + match self { + Self::SurrogateControl => Some(HEADER_SURROGATE_CONTROL), + Self::CdnCacheControl => Some(HEADER_CDN_CACHE_CONTROL), + Self::CloudflareCdnCacheControl => Some(HEADER_CLOUDFLARE_CDN_CACHE_CONTROL), + Self::SMaxageFallback | Self::None => None, + } + } +} + +/// Structured browser/edge cache policy. +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub struct CachePolicy { + /// Whether the browser-facing response is public or private. + pub visibility: CacheVisibility, + /// Browser cache TTL rendered as `max-age`. + pub browser_ttl: Option, + /// Shared edge cache TTL rendered as an edge header or `s-maxage` fallback. + pub edge_ttl: Option, + /// Optional `stale-while-revalidate` duration. + pub stale_while_revalidate: Option, + /// Optional `stale-if-error` duration. + pub stale_if_error: Option, + /// Whether to render `immutable` for browser caches. + pub immutable: bool, +} + +impl CachePolicy { + /// Create a public immutable policy for content-addressed static assets. + #[must_use] + pub const fn public_immutable(ttl: Duration) -> Self { + Self { + visibility: CacheVisibility::Public, + browser_ttl: Some(ttl), + edge_ttl: Some(ttl), + stale_while_revalidate: None, + stale_if_error: None, + immutable: true, + } + } + + /// Create the current short TSJS fallback policy for unversioned/mismatched requests. + #[must_use] + pub const fn public_short_with_stale( + ttl: Duration, + stale_while_revalidate: Duration, + stale_if_error: Duration, + ) -> Self { + Self { + visibility: CacheVisibility::Public, + browser_ttl: Some(ttl), + edge_ttl: Some(ttl), + stale_while_revalidate: Some(stale_while_revalidate), + stale_if_error: Some(stale_if_error), + immutable: false, + } + } + + /// Create a private revalidation policy for personalized browser responses. + #[must_use] + pub const fn private_revalidate() -> Self { + Self { + visibility: CacheVisibility::Private, + browser_ttl: Some(Duration::from_secs(0)), + edge_ttl: None, + stale_while_revalidate: None, + stale_if_error: None, + immutable: false, + } + } + + /// Render the browser-facing `Cache-Control` value. + #[must_use] + pub fn cache_control_value(self, edge_header: EdgeCacheHeader) -> String { + let mut directives = Vec::new(); + directives.push(self.visibility.directive().to_string()); + + if let Some(ttl) = self.browser_ttl { + directives.push(format!("max-age={}", ttl.as_secs())); + } + + if edge_header == EdgeCacheHeader::SMaxageFallback { + if let Some(ttl) = self + .edge_ttl + .filter(|_| self.visibility == CacheVisibility::Public) + { + directives.push(format!("s-maxage={}", ttl.as_secs())); + } + } + + if let Some(ttl) = self.stale_while_revalidate { + directives.push(format!("stale-while-revalidate={}", ttl.as_secs())); + } + + if let Some(ttl) = self.stale_if_error { + directives.push(format!("stale-if-error={}", ttl.as_secs())); + } + + if self.immutable && self.browser_ttl.is_some_and(|ttl| ttl.as_secs() > 0) { + directives.push("immutable".to_string()); + } + + directives.join(", ") + } + + /// Render the separate edge-cache header value, if this policy should emit one. + #[must_use] + pub fn edge_header_value(self, edge_header: EdgeCacheHeader) -> Option { + if self.visibility != CacheVisibility::Public { + return None; + } + if matches!( + edge_header, + EdgeCacheHeader::None | EdgeCacheHeader::SMaxageFallback + ) { + return None; + } + + let edge_ttl = self.edge_ttl?; + let mut directives = vec![format!("max-age={}", edge_ttl.as_secs())]; + + if let Some(ttl) = self.stale_while_revalidate { + directives.push(format!("stale-while-revalidate={}", ttl.as_secs())); + } + + if let Some(ttl) = self.stale_if_error { + directives.push(format!("stale-if-error={}", ttl.as_secs())); + } + + Some(directives.join(", ")) + } + + /// Apply the policy to response headers for the selected runtime edge header. + /// + /// # Panics + /// + /// Panics if the internally-rendered cache header values are not valid HTTP + /// header values. This should not happen because values are generated from + /// fixed directive names and numeric durations. + pub fn apply_to_headers(self, headers: &mut HeaderMap, edge_header: EdgeCacheHeader) { + let cache_control = self.cache_control_value(edge_header); + headers.insert( + header::CACHE_CONTROL, + HeaderValue::from_str(&cache_control) + .expect("should render a valid cache-control header"), + ); + + remove_edge_cache_headers(headers); + if let Some(header_name) = edge_header.header_name() { + if let Some(value) = self.edge_header_value(edge_header) { + headers.insert( + header_name, + HeaderValue::from_str(&value) + .expect("should render a valid edge cache-control header"), + ); + } + } + } +} + +/// Cache-control mode, including explicitly uncacheable responses. +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub enum CacheControlPolicy { + /// Apply a regular TTL-based cache policy. + Store(CachePolicy), + /// Apply `Cache-Control: no-store, private` and strip shared-cache headers. + NoStorePrivate, +} + +impl CacheControlPolicy { + /// Apply this cache-control mode to response headers. + /// + /// # Panics + /// + /// Panics if an internally-rendered cache header value is not valid. This + /// should not happen because values are generated from fixed directive names + /// and numeric durations. + pub fn apply_to_headers(self, headers: &mut HeaderMap, edge_header: EdgeCacheHeader) { + match self { + Self::Store(policy) => policy.apply_to_headers(headers, edge_header), + Self::NoStorePrivate => apply_no_store_private_to_headers(headers), + } + } +} + +impl From for CacheControlPolicy { + fn from(policy: CachePolicy) -> Self { + Self::Store(policy) + } +} + +/// Remove every runtime-specific shared-cache header owned by this crate. +pub fn remove_edge_cache_headers(headers: &mut HeaderMap) { + for name in EDGE_CACHE_HEADER_NAMES { + headers.remove(*name); + } +} + +/// Return true when `name` is an edge-cache header owned by this crate. +#[must_use] +pub fn is_edge_cache_header_name(name: &str) -> bool { + EDGE_CACHE_HEADER_NAMES + .iter() + .any(|candidate| name.eq_ignore_ascii_case(candidate)) +} + +/// Return true when a `Cache-Control` field value contains `directive`. +/// +/// Matching is directive-name exact and case-insensitive. Pseudo-directives such +/// as `not-private` or `no-storey` do not match `private` / `no-store`. +#[must_use] +pub fn cache_control_value_has_directive(value: &str, directive: &str) -> bool { + value.split(',').any(|part| { + let part = part.trim(); + let directive_name = part + .find(['=', ';']) + .map_or(part, |end| &part[..end]) + .trim(); + directive_name.eq_ignore_ascii_case(directive) + }) +} + +/// Return true when any `Cache-Control` header value contains `directive`. +#[must_use] +pub fn cache_control_headers_have_directive(headers: &HeaderMap, directive: &str) -> bool { + headers + .get_all(header::CACHE_CONTROL) + .iter() + .filter_map(|value| value.to_str().ok()) + .any(|value| cache_control_value_has_directive(value, directive)) +} + +/// Return true when response cache-control contains exact `private` or `no-store`. +#[must_use] +pub fn cache_control_headers_are_private_or_no_store(headers: &HeaderMap) -> bool { + cache_control_headers_have_directive(headers, "private") + || cache_control_headers_have_directive(headers, "no-store") +} + +/// Apply `Cache-Control: no-store, private` and strip all shared-cache headers. +/// +/// # Panics +/// +/// Panics if the fixed no-store cache-control value is not a valid HTTP header +/// value. This should not happen for a static ASCII value. +pub fn apply_no_store_private_to_headers(headers: &mut HeaderMap) { + headers.insert( + header::CACHE_CONTROL, + HeaderValue::from_static(NO_STORE_PRIVATE_CACHE_CONTROL), + ); + remove_edge_cache_headers(headers); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn public_immutable_renders_browser_and_fastly_headers() { + let policy = CachePolicy::public_immutable(Duration::from_secs(31_536_000)); + let mut headers = HeaderMap::new(); + + policy.apply_to_headers(&mut headers, EdgeCacheHeader::SurrogateControl); + + assert_eq!( + headers + .get(header::CACHE_CONTROL) + .and_then(|v| v.to_str().ok()), + Some("public, max-age=31536000, immutable"), + "should render immutable browser policy" + ); + assert_eq!( + headers + .get(HEADER_SURROGATE_CONTROL) + .and_then(|v| v.to_str().ok()), + Some("max-age=31536000"), + "should render Fastly edge TTL" + ); + } + + #[test] + fn s_maxage_fallback_renders_edge_ttl_inside_cache_control() { + let policy = CachePolicy::public_short_with_stale( + Duration::from_secs(300), + Duration::from_secs(60), + Duration::from_secs(86_400), + ); + + assert_eq!( + policy.cache_control_value(EdgeCacheHeader::SMaxageFallback), + "public, max-age=300, s-maxage=300, stale-while-revalidate=60, stale-if-error=86400", + "should render portable two-tier fallback" + ); + } + + #[test] + fn generic_cdn_header_renders_cdn_only_policy() { + let policy = CachePolicy::public_short_with_stale( + Duration::from_secs(300), + Duration::from_secs(60), + Duration::from_secs(86_400), + ); + let mut headers = HeaderMap::new(); + + policy.apply_to_headers(&mut headers, EdgeCacheHeader::CdnCacheControl); + + assert_eq!( + headers + .get(header::CACHE_CONTROL) + .and_then(|v| v.to_str().ok()), + Some("public, max-age=300, stale-while-revalidate=60, stale-if-error=86400"), + "should keep CDN TTL out of browser cache-control when using targeted CDN header" + ); + assert_eq!( + headers + .get(HEADER_CDN_CACHE_CONTROL) + .and_then(|v| v.to_str().ok()), + Some("max-age=300, stale-while-revalidate=60, stale-if-error=86400"), + "should render generic CDN cache policy" + ); + } + + #[test] + fn cloudflare_specific_header_renders_cdn_only_policy() { + let policy = CachePolicy::public_short_with_stale( + Duration::from_secs(300), + Duration::from_secs(60), + Duration::from_secs(86_400), + ); + let mut headers = HeaderMap::new(); + + policy.apply_to_headers(&mut headers, EdgeCacheHeader::CloudflareCdnCacheControl); + + assert_eq!( + headers + .get(header::CACHE_CONTROL) + .and_then(|v| v.to_str().ok()), + Some("public, max-age=300, stale-while-revalidate=60, stale-if-error=86400"), + "should keep CDN TTL out of browser cache-control when using targeted CDN header" + ); + assert_eq!( + headers + .get(HEADER_CLOUDFLARE_CDN_CACHE_CONTROL) + .and_then(|v| v.to_str().ok()), + Some("max-age=300, stale-while-revalidate=60, stale-if-error=86400"), + "should render Cloudflare-specific CDN cache policy" + ); + assert!( + headers.get(HEADER_CDN_CACHE_CONTROL).is_none(), + "should not also emit the generic CDN cache header" + ); + } + + #[test] + fn private_policy_removes_stale_edge_headers() { + let policy = CachePolicy::private_revalidate(); + let mut headers = HeaderMap::new(); + headers.insert( + HEADER_SURROGATE_CONTROL, + HeaderValue::from_static("max-age=60"), + ); + headers.insert( + HEADER_CDN_CACHE_CONTROL, + HeaderValue::from_static("max-age=60"), + ); + headers.insert( + HEADER_CLOUDFLARE_CDN_CACHE_CONTROL, + HeaderValue::from_static("max-age=60"), + ); + + policy.apply_to_headers(&mut headers, EdgeCacheHeader::SurrogateControl); + + assert_eq!( + headers + .get(header::CACHE_CONTROL) + .and_then(|v| v.to_str().ok()), + Some("private, max-age=0"), + "should render private browser policy" + ); + assert!( + headers.get(HEADER_SURROGATE_CONTROL).is_none(), + "should remove Fastly shared-cache headers for private responses" + ); + assert!( + headers.get(HEADER_CDN_CACHE_CONTROL).is_none(), + "should remove generic CDN cache headers for private responses" + ); + assert!( + headers.get(HEADER_CLOUDFLARE_CDN_CACHE_CONTROL).is_none(), + "should remove Cloudflare cache headers for private responses" + ); + } + + #[test] + fn no_store_policy_removes_stale_edge_headers() { + let mut headers = HeaderMap::new(); + headers.insert( + HEADER_SURROGATE_CONTROL, + HeaderValue::from_static("max-age=60"), + ); + headers.insert( + HEADER_FASTLY_SURROGATE_CONTROL, + HeaderValue::from_static("max-age=60"), + ); + headers.insert( + HEADER_CDN_CACHE_CONTROL, + HeaderValue::from_static("max-age=60"), + ); + headers.insert( + HEADER_CLOUDFLARE_CDN_CACHE_CONTROL, + HeaderValue::from_static("max-age=60"), + ); + + CacheControlPolicy::NoStorePrivate.apply_to_headers(&mut headers, EdgeCacheHeader::None); + + assert_eq!( + headers + .get(header::CACHE_CONTROL) + .and_then(|v| v.to_str().ok()), + Some(NO_STORE_PRIVATE_CACHE_CONTROL), + "should render no-store cache policy" + ); + assert!( + headers.get(HEADER_SURROGATE_CONTROL).is_none() + && headers.get(HEADER_FASTLY_SURROGATE_CONTROL).is_none() + && headers.get(HEADER_CDN_CACHE_CONTROL).is_none() + && headers.get(HEADER_CLOUDFLARE_CDN_CACHE_CONTROL).is_none(), + "should remove all shared-cache headers" + ); + } + + #[test] + fn immutable_is_omitted_without_positive_browser_ttl() { + let policy = CachePolicy { + visibility: CacheVisibility::Public, + browser_ttl: Some(Duration::from_secs(0)), + edge_ttl: Some(Duration::from_secs(60)), + stale_while_revalidate: None, + stale_if_error: None, + immutable: true, + }; + + assert_eq!( + policy.cache_control_value(EdgeCacheHeader::None), + "public, max-age=0", + "should not render immutable without a positive browser TTL" + ); + } + + #[test] + fn cache_control_directive_matching_is_exact() { + assert!( + cache_control_value_has_directive("public, max-age=60, No-Store", "no-store"), + "should match real no-store directives case-insensitively" + ); + assert!( + cache_control_value_has_directive("private=\"set-cookie\", max-age=0", "private"), + "should match directives with arguments" + ); + assert!( + !cache_control_value_has_directive("public, no-storey, not-private", "no-store"), + "should not match pseudo-directives by substring" + ); + assert!( + !cache_control_value_has_directive("public, no-storey, not-private", "private"), + "should not match pseudo-private directives by substring" + ); + } + + #[test] + fn cache_control_header_matching_checks_all_values() { + let mut headers = HeaderMap::new(); + headers.append(header::CACHE_CONTROL, HeaderValue::from_static("public")); + headers.append( + header::CACHE_CONTROL, + HeaderValue::from_static("max-age=60"), + ); + headers.append(header::CACHE_CONTROL, HeaderValue::from_static("no-store")); + + assert!( + cache_control_headers_are_private_or_no_store(&headers), + "should inspect every Cache-Control field value" + ); + } +} diff --git a/crates/trusted-server-core/src/http_util.rs b/crates/trusted-server-core/src/http_util.rs index 5ad7011fe..1b0693e56 100644 --- a/crates/trusted-server-core/src/http_util.rs +++ b/crates/trusted-server-core/src/http_util.rs @@ -4,8 +4,10 @@ use edgezero_core::body::Body as EdgeBody; use error_stack::Report; use http::{Request, Response, StatusCode, header}; use sha2::{Digest as _, Sha256}; +use std::time::Duration; use subtle::ConstantTimeEq as _; +use crate::cache_policy::{CachePolicy, EdgeCacheHeader}; use crate::constants::INTERNAL_HEADERS; use crate::error::TrustedServerError; use crate::platform::ClientInfo; @@ -274,43 +276,41 @@ pub fn serve_static_with_etag( body: &str, req: &Request, content_type: &str, + edge_header: EdgeCacheHeader, ) -> Response { - // Compute ETag for conditional caching let hash = Sha256::digest(body.as_bytes()); let etag = format!("\"sha256-{}\"", hex::encode(hash)); + let short_policy = CachePolicy::public_short_with_stale( + Duration::from_secs(300), + Duration::from_secs(60), + Duration::from_secs(86_400), + ); - // If-None-Match handling for 304 responses if let Some(if_none_match) = req .headers() .get(header::IF_NONE_MATCH) .and_then(|h| h.to_str().ok()) && if_none_match == etag { - return Response::builder() - .status(StatusCode::NOT_MODIFIED) - .header(header::ETAG, &etag) - .header( - header::CACHE_CONTROL, - "public, max-age=300, s-maxage=300, stale-while-revalidate=60, stale-if-error=86400", - ) - .header("surrogate-control", "max-age=300") - .header(header::VARY, "Accept-Encoding") - .body(EdgeBody::empty()) - .expect("should build 304 static response"); - } - - Response::builder() + let mut response = Response::builder() + .status(StatusCode::NOT_MODIFIED) + .header(header::ETAG, &etag) + .header(header::VARY, "Accept-Encoding") + .body(EdgeBody::empty()) + .expect("should build 304 static response"); + short_policy.apply_to_headers(response.headers_mut(), edge_header); + return response; + } + + let mut response = Response::builder() .status(StatusCode::OK) .header(header::CONTENT_TYPE, content_type) - .header( - header::CACHE_CONTROL, - "public, max-age=300, s-maxage=300, stale-while-revalidate=60, stale-if-error=86400", - ) - .header("surrogate-control", "max-age=300") .header(header::ETAG, &etag) .header(header::VARY, "Accept-Encoding") .body(EdgeBody::from(body.as_bytes())) - .expect("should build static response") + .expect("should build static response"); + short_policy.apply_to_headers(response.headers_mut(), edge_header); + response } /// Encrypts a URL using XChaCha20-Poly1305 with a key derived from the publisher `proxy_secret`. diff --git a/crates/trusted-server-core/src/integrations/prebid.rs b/crates/trusted-server-core/src/integrations/prebid.rs index 4cc10f8da..c9b3f5ded 100644 --- a/crates/trusted-server-core/src/integrations/prebid.rs +++ b/crates/trusted-server-core/src/integrations/prebid.rs @@ -23,6 +23,7 @@ use crate::auction::provider::{AuctionProvider, ProviderRequestOutcome}; use crate::auction::types::{ AuctionContext, AuctionRequest, AuctionResponse, Bid as AuctionBid, MediaType, }; +use crate::cache_policy::{CacheControlPolicy, EdgeCacheHeader}; use crate::consent_config::ConsentForwardingMode; use crate::cookies::{CONSENT_COOKIE_NAMES, strip_cookies}; use crate::error::TrustedServerError; @@ -754,14 +755,16 @@ impl PrebidIntegration { ) -> Result, Report> { let body = "// Script overridden by Trusted Server\n"; - http::Response::builder() + let mut response = http::Response::builder() .status(StatusCode::OK) .header(header::CONTENT_TYPE, PREBID_BUNDLE_CONTENT_TYPE) - .header(header::CACHE_CONTROL, "public, max-age=31536000") .body(EdgeBody::from(body)) .change_context(TrustedServerError::Prebid { message: "Failed to build Prebid script handler response".to_string(), - }) + })?; + CacheControlPolicy::NoStorePrivate + .apply_to_headers(response.headers_mut(), EdgeCacheHeader::None); + Ok(response) } fn external_bundle_script_src(&self) -> String { @@ -3557,7 +3560,14 @@ external_bundle_sri = "sha384-AAAA" .get(header::CACHE_CONTROL) .and_then(|value| value.to_str().ok()) .expect("should have cache-control"); - assert!(cache_control.contains("max-age=31536000")); + assert_eq!( + cache_control, "no-store, private", + "neutralized stable shim must not be cached for a year" + ); + assert!( + response.headers().get("surrogate-control").is_none(), + "neutralized shim must not emit edge-cache headers" + ); let body = String::from_utf8( response diff --git a/crates/trusted-server-core/src/integrations/testlight.rs b/crates/trusted-server-core/src/integrations/testlight.rs index 888427e52..80b2c4dfa 100644 --- a/crates/trusted-server-core/src/integrations/testlight.rs +++ b/crates/trusted-server-core/src/integrations/testlight.rs @@ -264,8 +264,9 @@ fn default_timeout_ms() -> u32 { } fn default_shim_src() -> String { - // Testlight is included in the unified bundle, so we return the unified script source. - // Uses conservative all-module hash since the registry is unavailable at config time. + // Testlight is included in the unified bundle, so return the registry-free + // unified script source. It intentionally omits `?v=` because the exact + // enabled module set is unavailable at config-default time. tsjs::tsjs_unified_script_src() } diff --git a/crates/trusted-server-core/src/lib.rs b/crates/trusted-server-core/src/lib.rs index 70a4d6cfd..48e92faed 100644 --- a/crates/trusted-server-core/src/lib.rs +++ b/crates/trusted-server-core/src/lib.rs @@ -35,6 +35,7 @@ pub(crate) mod asset_image_optimizer; pub mod auction; pub mod auction_config_types; pub mod auth; +pub mod cache_policy; pub mod config; pub mod config_payload; pub mod consent; diff --git a/crates/trusted-server-core/src/proxy.rs b/crates/trusted-server-core/src/proxy.rs index ea0a0cf8d..0270c4292 100644 --- a/crates/trusted-server-core/src/proxy.rs +++ b/crates/trusted-server-core/src/proxy.rs @@ -13,6 +13,9 @@ use std::sync::{Arc, LazyLock, Mutex}; use std::time::Duration; use web_time::{SystemTime, UNIX_EPOCH}; +use crate::cache_policy::{ + apply_no_store_private_to_headers, CachePolicy, EdgeCacheHeader, NO_STORE_PRIVATE_CACHE_CONTROL, +}; use crate::constants::{ HEADER_ACCEPT, HEADER_ACCEPT_ENCODING, HEADER_ACCEPT_LANGUAGE, HEADER_REFERER, HEADER_USER_AGENT, HEADER_X_FORWARDED_FOR, @@ -96,7 +99,7 @@ const ASSET_PROXY_STRIP_RESPONSE_HEADERS: [&str; 3] = ["set-cookie", "strict-transport-security", "clear-site-data"]; /// Cache-control value used when asset proxy responses must not be stored. -pub const ASSET_NO_STORE_PRIVATE_CACHE_CONTROL: &str = "no-store, private"; +pub const ASSET_NO_STORE_PRIVATE_CACHE_CONTROL: &str = NO_STORE_PRIVATE_CACHE_CONTROL; /// Cache policy metadata emitted by the asset proxy handler. /// @@ -109,13 +112,23 @@ pub enum AssetProxyCachePolicy { OriginControlled, /// Reapply `Cache-Control: no-store, private` after standard finalization. NoStorePrivate, + /// Reapply an operator-selected normalized cache policy after finalization. + Normalized(CachePolicy), } impl AssetProxyCachePolicy { /// Apply protected cache headers after route-level response finalization. - pub fn apply_after_route_finalization(self, response: &mut Response) { - if self == Self::NoStorePrivate { - apply_no_store_cache_control(response); + pub fn apply_after_route_finalization( + self, + response: &mut Response, + edge_header: EdgeCacheHeader, + ) { + match self { + Self::OriginControlled => {} + Self::NoStorePrivate => apply_no_store_cache_control(response), + Self::Normalized(policy) => { + policy.apply_to_headers(response.headers_mut(), edge_header) + } } } } @@ -169,6 +182,11 @@ impl AssetProxyResponse { apply_no_store_cache_control(&mut self.response); } + fn apply_normalized_cache_policy(&mut self, policy: CachePolicy) { + self.cache_policy = AssetProxyCachePolicy::Normalized(policy); + policy.apply_to_headers(self.response.headers_mut(), EdgeCacheHeader::None); + } + /// Return cache policy metadata for router finalization. #[must_use] pub fn cache_policy(&self) -> AssetProxyCachePolicy { @@ -1020,10 +1038,7 @@ fn strip_asset_proxy_response_headers(response: &mut Response) { } fn apply_no_store_cache_control(response: &mut Response) { - response.headers_mut().insert( - header::CACHE_CONTROL, - HeaderValue::from_static(ASSET_NO_STORE_PRIVATE_CACHE_CONTROL), - ); + apply_no_store_private_to_headers(response.headers_mut()); } fn should_preflight_s3( @@ -1206,6 +1221,13 @@ pub async fn handle_asset_proxy_request( let mut response = platform_response_to_fastly_asset(platform_resp); strip_asset_proxy_response_headers(response.response_mut()); + let status = response.response().status(); + if status.is_success() || status == StatusCode::NOT_MODIFIED { + if let Some(policy) = settings.asset_cache_policy_for_path(incoming_path)? { + response.apply_normalized_cache_policy(policy); + } + } + Ok(response) } @@ -2167,6 +2189,7 @@ mod tests { use std::io; use std::rc::Rc; use std::sync::{Arc, Mutex}; + use std::time::Duration; use super::{ AssetProxyCachePolicy, IMAGE_FALLBACK_CONTENT_TYPE, ProxyRequestConfig, @@ -2177,6 +2200,7 @@ mod tests { proxy_request, rebuild_response_with_body, reconstruct_and_validate_signed_target, redirect_is_permitted, stream_asset_body, }; + use crate::cache_policy::{CachePolicy, EdgeCacheHeader}; use crate::constants::{HEADER_ACCEPT, HEADER_X_FORWARDED_FOR}; use crate::creative; use crate::error::{IntoHttpResponse, TrustedServerError}; @@ -2191,9 +2215,9 @@ mod tests { use crate::settings::{ AssetImageOptimizerConfig, AssetOriginAuth, ImageOptimizerAspectRatioConfig, ImageOptimizerCropOffsetsConfig, ImageOptimizerProfileSet, ImageOptimizerSettings, - OriginQueryPolicy, ProxyAssetRoute, S3SigV4AuthConfig, UnknownProfilePolicy, + OriginQueryPolicy, ProxyAssetRoute, S3SigV4AuthConfig, Settings, UnknownProfilePolicy, }; - use crate::test_support::tests::create_test_settings; + use crate::test_support::tests::{crate_test_settings_str, create_test_settings}; use bytes::Bytes; use edgezero_core::body::Body as EdgeBody; use edgezero_core::http::response_builder as edge_response_builder; @@ -4304,6 +4328,130 @@ mod tests { }); } + #[test] + fn handle_asset_proxy_request_applies_configured_normalized_cache_policy() { + futures::executor::block_on(async { + let stub = Arc::new(StubHttpClient::new()); + stub.push_response_with_headers( + 200, + b"asset".to_vec(), + vec![(header::CACHE_CONTROL.as_str(), "no-store")], + ); + let services = build_services_with_http_client( + Arc::clone(&stub) as Arc + ); + let settings = Settings::from_toml(&format!( + r#"{} + + [[cache.asset_rules]] + id = "fingerprinted-assets" + enabled = true + path_globs = ["/assets/**/*.js"] + requires_hash_in_filename = true + visibility = "public" + browser_ttl_seconds = 31536000 + edge_ttl_seconds = 31536000 + immutable = true + "#, + crate_test_settings_str() + )) + .expect("should parse settings with cache asset rule"); + let req = build_http_request( + Method::GET, + "https://www.example.com/assets/app.0123abcd.js", + ); + let route = ProxyAssetRoute::new("/assets/", "https://assets.example.com"); + + let asset_response = handle_asset_proxy_request(&settings, &services, req, &route) + .await + .expect("should proxy asset request"); + assert_eq!( + asset_response.cache_policy(), + AssetProxyCachePolicy::Normalized(CachePolicy::public_immutable( + Duration::from_secs(31_536_000) + )), + "should carry normalized cache policy metadata" + ); + + let mut response = asset_response + .into_response() + .expect("should return buffered asset response"); + assert_eq!( + response_header(&response, header::CACHE_CONTROL), + Some("public, max-age=31536000, immutable"), + "core response should apply browser cache policy immediately" + ); + assert!( + response.headers().get("surrogate-control").is_none(), + "runtime-specific edge header should wait for adapter finalization" + ); + + AssetProxyCachePolicy::Normalized(CachePolicy::public_immutable(Duration::from_secs( + 31_536_000, + ))) + .apply_after_route_finalization(&mut response, EdgeCacheHeader::SurrogateControl); + assert_eq!( + response + .headers() + .get("surrogate-control") + .and_then(|value| value.to_str().ok()), + Some("max-age=31536000"), + "Fastly finalization should render Surrogate-Control" + ); + }); + } + + #[test] + fn handle_asset_proxy_request_leaves_non_matching_assets_origin_controlled() { + futures::executor::block_on(async { + let stub = Arc::new(StubHttpClient::new()); + stub.push_response_with_headers( + 200, + b"asset".to_vec(), + vec![(header::CACHE_CONTROL.as_str(), "public, max-age=60")], + ); + let services = build_services_with_http_client( + Arc::clone(&stub) as Arc + ); + let settings = Settings::from_toml(&format!( + r#"{} + + [[cache.asset_rules]] + id = "fingerprinted-assets" + enabled = true + path_globs = ["/assets/**/*.js"] + requires_hash_in_filename = true + visibility = "public" + browser_ttl_seconds = 31536000 + edge_ttl_seconds = 31536000 + immutable = true + "#, + crate_test_settings_str() + )) + .expect("should parse settings with cache asset rule"); + let req = build_http_request(Method::GET, "https://www.example.com/assets/app.js"); + let route = ProxyAssetRoute::new("/assets/", "https://assets.example.com"); + + let asset_response = handle_asset_proxy_request(&settings, &services, req, &route) + .await + .expect("should proxy asset request"); + + assert_eq!( + asset_response.cache_policy(), + AssetProxyCachePolicy::OriginControlled, + "non-fingerprinted file should not receive normalized immutable policy" + ); + let response = asset_response + .into_response() + .expect("should return buffered asset response"); + assert_eq!( + response_header(&response, header::CACHE_CONTROL), + Some("public, max-age=60"), + "origin-controlled response should preserve origin cache header" + ); + }); + } + fn test_profile_set() -> ImageOptimizerProfileSet { let mut profiles = HashMap::new(); profiles.insert("default".to_string(), "width=1920".to_string()); diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 4bed98327..1da702196 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -37,7 +37,6 @@ use http::{HeaderValue, Method, Request, Response, StatusCode, Uri, header}; use crate::auction::endpoints::{ merge_auction_eids, resolve_auction_eids, resolve_client_auction_eids, }; -use crate::auction::formats::sanitize_publisher_page_url; use crate::auction::orchestrator::{ AuctionOrchestrator, DispatchAuctionOutcome, DispatchedAuction, }; @@ -48,6 +47,9 @@ use crate::auction::telemetry::{ use crate::auction::types::{ AuctionContext, AuctionRequest, Bid, DeviceInfo, PublisherInfo, SiteInfo, UserInfo, }; +use crate::cache_policy::{ + cache_control_headers_are_private_or_no_store, CachePolicy, EdgeCacheHeader, +}; use crate::consent::{consent_allows_server_side_auction, gate_eids_by_consent}; use crate::constants::{COOKIE_TS_EIDS, HEADER_X_COMPRESS_HINT}; use crate::cookies::handle_request_cookies; @@ -287,6 +289,7 @@ fn accept_encoding_qvalue(header_value: &str, target: &str) -> Option { pub fn handle_tsjs_dynamic( req: &Request, integration_registry: &IntegrationRegistry, + edge_header: EdgeCacheHeader, ) -> Result, Report> { const PREFIX: &str = "/static/tsjs="; const UNIFIED_FILENAMES: &[&str] = &["tsjs-unified.js", "tsjs-unified.min.js"]; @@ -301,42 +304,62 @@ pub fn handle_tsjs_dynamic( // Serve core + immediate modules (excludes deferred like prebid) let module_ids = integration_registry.js_module_ids_immediate(); let body = trusted_server_js::concatenate_modules(&module_ids); - let mut resp = serve_static_with_etag(&body, req, "application/javascript; charset=utf-8"); - resp.headers_mut() - .insert(HEADER_X_COMPRESS_HINT, HeaderValue::from_static("on")); - return Ok(resp); + let hash = trusted_server_js::concatenated_hash(&module_ids); + return Ok(serve_tsjs_static(req, &body, &hash, edge_header)); } - if let Some(module_id) = parse_single_module_filename(filename) { - // Deferred modules and the conditionally injected diagnostics module - // are served as content-addressed standalone assets. Delivery remains - // cookie-independent so the static response can stay publicly cached. + if let Some(module_id) = parse_deferred_module_filename(filename) { + // Only serve if the deferred module is actually enabled let deferred_ids = integration_registry.js_module_ids_deferred(); - let diagnostics_standalone = module_id - == crate::integrations::gpt_diagnostics::GPT_DIAGNOSTICS_INTEGRATION_ID - && integration_registry.integration_enabled(module_id); - if !deferred_ids.contains(&module_id) && !diagnostics_standalone { + if !deferred_ids.contains(&module_id) { return Ok(not_found_response()); } - if let Some(content) = trusted_server_js::module_bundle(module_id) { - let mut resp = - serve_static_with_etag(content, req, "application/javascript; charset=utf-8"); - resp.headers_mut() - .insert(HEADER_X_COMPRESS_HINT, HeaderValue::from_static("on")); - return Ok(resp); + if let (Some(content), Some(hash)) = ( + trusted_server_js::module_bundle(module_id), + trusted_server_js::single_module_hash(module_id), + ) { + return Ok(serve_tsjs_static(req, content, hash, edge_header)); } } Ok(not_found_response()) } -/// Extract a module ID from a deferred-module filename like `tsjs-sourcepoint.min.js`. +fn serve_tsjs_static( + req: &Request, + body: &str, + expected_hash: &str, + edge_header: EdgeCacheHeader, +) -> Response { + let mut resp = serve_static_with_etag( + body, + req, + "application/javascript; charset=utf-8", + edge_header, + ); + if request_version_hash(req).is_some_and(|hash| hash == expected_hash) { + CachePolicy::public_immutable(Duration::from_secs(31_536_000)) + .apply_to_headers(resp.headers_mut(), edge_header); + } + resp.headers_mut() + .insert(HEADER_X_COMPRESS_HINT, HeaderValue::from_static("on")); + resp +} + +fn request_version_hash(req: &Request) -> Option<&str> { + req.uri().query()?.split('&').find_map(|pair| { + let (name, value) = pair.split_once('=')?; + (name == "v").then_some(value) + }) +} + +/// Extract a module ID from a deferred-module filename like `tsjs-prebid.min.js`. /// /// Returns `Some(&'static str)` if the filename matches a known JS module ID, /// `None` otherwise. The caller must additionally verify that the module is /// both deferred and enabled via the [`IntegrationRegistry`]. #[must_use] -fn parse_single_module_filename(filename: &str) -> Option<&'static str> { +fn parse_deferred_module_filename(filename: &str) -> Option<&'static str> { let stem = filename .strip_prefix("tsjs-") .and_then(|s| s.strip_suffix(".min.js").or_else(|| s.strip_suffix(".js")))?; @@ -358,9 +381,6 @@ struct ProcessResponseParams<'a> { integration_registry: &'a IntegrationRegistry, ad_slots_script: Option<&'a str>, ad_bids_state: &'a Arc>>, - suppress_datadome_client_side_tag: bool, - gpt_diagnostics: - Option<&'a crate::integrations::gpt_diagnostics::GptDiagnosticsRequestDecision>, } struct PublisherBodyProcessor { @@ -377,17 +397,15 @@ impl PublisherBodyProcessor { let is_rsc_flight = content_type_contains_ascii_case_insensitive(¶ms.content_type, "text/x-component"); let inner: Box = if is_html { - Box::new(create_html_stream_processor(HtmlStreamProcessorParams { - origin_host: ¶ms.origin_host, - request_host: ¶ms.request_host, - request_scheme: ¶ms.request_scheme, + Box::new(create_html_stream_processor( + ¶ms.origin_host, + ¶ms.request_host, + ¶ms.request_scheme, settings, integration_registry, - ad_slots_script: params.ad_slots_script.as_deref().map(str::to_string), - ad_bids_state: Arc::clone(¶ms.ad_bids_state), - suppress_datadome_client_side_tag: params.suppress_datadome_client_side_tag, - gpt_diagnostics: params.gpt_diagnostics.clone(), - })?) + params.ad_slots_script.as_deref().map(str::to_string), + Arc::clone(¶ms.ad_bids_state), + )?) } else if is_rsc_flight { Box::new(RscFlightUrlRewriter::new( ¶ms.origin_host, @@ -455,17 +473,15 @@ fn process_response_streaming( let max_pending_decoded_bytes = params.settings.publisher.max_buffered_body_bytes; if is_html { - let processor = create_html_stream_processor(HtmlStreamProcessorParams { - origin_host: params.origin_host, - request_host: params.request_host, - request_scheme: params.request_scheme, - settings: params.settings, - integration_registry: params.integration_registry, - ad_slots_script: params.ad_slots_script.map(str::to_string), - ad_bids_state: params.ad_bids_state.clone(), - suppress_datadome_client_side_tag: params.suppress_datadome_client_side_tag, - gpt_diagnostics: params.gpt_diagnostics.cloned(), - })?; + let processor = create_html_stream_processor( + params.origin_host, + params.request_host, + params.request_scheme, + params.settings, + params.integration_registry, + params.ad_slots_script.map(str::to_string), + params.ad_bids_state.clone(), + )?; StreamingPipeline::new(config, processor) .with_max_pending_decoded_bytes(max_pending_decoded_bytes) .process(body_as_reader(body)?, output)?; @@ -940,33 +956,25 @@ async fn hold_finish_tail_segments( /// `use<>` states that explicitly: without it, Rust 2024 would have the opaque /// type capture every input lifetime, forcing callers to keep the settings and /// registry alive for as long as the processor. -struct HtmlStreamProcessorParams<'a> { - origin_host: &'a str, - request_host: &'a str, - request_scheme: &'a str, - settings: &'a Settings, - integration_registry: &'a IntegrationRegistry, +fn create_html_stream_processor( + origin_host: &str, + request_host: &str, + request_scheme: &str, + settings: &Settings, + integration_registry: &IntegrationRegistry, ad_slots_script: Option, ad_bids_state: Arc>>, - suppress_datadome_client_side_tag: bool, - gpt_diagnostics: Option, -} - -fn create_html_stream_processor( - params: HtmlStreamProcessorParams<'_>, ) -> Result, Report> { use crate::html_processor::{HtmlProcessorConfig, create_html_processor}; let config = HtmlProcessorConfig::from_settings( - params.settings, - params.integration_registry, - params.origin_host, - params.request_host, - params.request_scheme, + settings, + integration_registry, + origin_host, + request_host, + request_scheme, ) - .with_ad_state(params.ad_slots_script, params.ad_bids_state) - .with_gpt_diagnostics(params.gpt_diagnostics) - .with_datadome_client_tag_suppression(params.suppress_datadome_client_side_tag); + .with_ad_state(ad_slots_script, ad_bids_state); Ok(create_html_processor(config)) } @@ -1066,6 +1074,33 @@ pub(crate) fn classify_response_route( ResponseRoute::Stream } +fn response_cache_control_is_private_or_no_store(response: &Response) -> bool { + cache_control_headers_are_private_or_no_store(response.headers()) +} + +fn apply_publisher_asset_cache_policy( + settings: &Settings, + path: &str, + cache_rule_method: bool, + edge_header: EdgeCacheHeader, + response: &mut Response, +) -> Result<(), Report> { + if !cache_rule_method || response_cache_control_is_private_or_no_store(response) { + return Ok(()); + } + + let status = response.status(); + if !(status.is_success() || status == StatusCode::NOT_MODIFIED) { + return Ok(()); + } + + if let Some(policy) = settings.asset_cache_policy_for_path(path)? { + policy.apply_to_headers(response.headers_mut(), edge_header); + } + + Ok(()) +} + /// Owned version of [`ProcessResponseParams`] for returning from /// [`handle_publisher_request`] without lifetime issues. pub struct OwnedProcessResponseParams { @@ -1087,11 +1122,6 @@ pub struct OwnedProcessResponseParams { pub(crate) dispatched_auction: Option, /// Price granularity used to bucket bids when building `tsjs.bids`. pub(crate) price_granularity: PriceGranularity, - /// Whether to omit Trusted Server's automatic `DataDome` client-side tag. - pub(crate) suppress_datadome_client_side_tag: bool, - /// Request-scoped conditional diagnostics delivery decision. - pub(crate) gpt_diagnostics: - Option, } /// Buffers a [`PublisherResponse`] into a single [`Response`], collecting the @@ -1424,30 +1454,6 @@ pub async fn publisher_response_into_streaming_response( } } -/// Returns whether a request can render an HTML document context. -fn is_html_document_request(req: &Request) -> bool { - if let Some(destination) = req - .headers() - .get("sec-fetch-dest") - .and_then(|value| value.to_str().ok()) - { - return matches!( - destination.trim().to_ascii_lowercase().as_str(), - "document" | "embed" | "fencedframe" | "frame" | "iframe" | "object" - ); - } - - is_navigation_request(req) -} - -/// Removes request headers that can produce a bodyless or partial origin response. -fn strip_conditional_and_range_headers(req: &mut Request) { - req.headers_mut().remove(header::IF_NONE_MATCH); - req.headers_mut().remove(header::IF_MODIFIED_SINCE); - req.headers_mut().remove(header::RANGE); - req.headers_mut().remove(header::IF_RANGE); -} - /// Returns `true` when a buffered publisher response should carry a body and a /// recomputed `Content-Length`. /// @@ -1463,21 +1469,6 @@ fn response_carries_body(method: &Method, status: StatusCode) -> bool { && status != StatusCode::NOT_MODIFIED } -/// Prevent shared caches from replaying tag-suppressed HTML to other clients. -fn apply_datadome_client_tag_cache_privacy( - response: &mut Response, - method: &Method, - suppress_datadome_client_side_tag: bool, - content_type: &str, -) { - if suppress_datadome_client_side_tag - && response_carries_body(method, response.status()) - && is_html_content_type(content_type) - { - enforce_synthesized_html_cache_privacy(response); - } -} - /// Drop a bodiless response's body and correct its framing headers. /// /// The response keeps no body, and its `Content-Length` is corrected where the @@ -1594,8 +1585,6 @@ pub fn stream_publisher_body( integration_registry, ad_slots_script: params.ad_slots_script.as_deref(), ad_bids_state: ¶ms.ad_bids_state, - suppress_datadome_client_side_tag: params.suppress_datadome_client_side_tag, - gpt_diagnostics: params.gpt_diagnostics.as_ref(), }; process_response_streaming(body, output, &borrowed) } @@ -1680,17 +1669,15 @@ pub async fn stream_publisher_body_async( // HTML: build the processor once and drive it chunk by chunk. // One-behind buffer: stream chunk N-1 immediately; hold chunk N until origin // EOF, then await auction and process chunk N (which contains ). - let mut processor = match create_html_stream_processor(HtmlStreamProcessorParams { - origin_host: ¶ms.origin_host, - request_host: ¶ms.request_host, - request_scheme: ¶ms.request_scheme, + let mut processor = match create_html_stream_processor( + ¶ms.origin_host, + ¶ms.request_host, + ¶ms.request_scheme, settings, integration_registry, - ad_slots_script: params.ad_slots_script.as_deref().map(str::to_string), - ad_bids_state: params.ad_bids_state.clone(), - suppress_datadome_client_side_tag: params.suppress_datadome_client_side_tag, - gpt_diagnostics: params.gpt_diagnostics.clone(), - }) { + params.ad_slots_script.as_deref().map(str::to_string), + params.ad_bids_state.clone(), + ) { Ok(processor) => processor, Err(err) => { emit_abandoned_auction( @@ -1843,25 +1830,21 @@ pub(crate) fn write_bids_to_state( settings: &Settings, request_origin: &str, include_debug_bid: bool, - auction_id: Option<&str>, -) -> std::collections::HashSet { +) { log::debug!( "write_bids_to_state: {} winning bid(s): [{}]", winning_bids.len(), winning_bids.keys().cloned().collect::>().join(", ") ); - let bid_map = build_bid_map_with_auction_id( + let bid_map = build_bid_map( winning_bids, price_granularity, settings, request_origin, include_debug_bid, - auction_id, ); - let delivered_winner_slots = bid_map.keys().cloned().collect(); let bids_script = build_bids_script(&bid_map); *ad_bids_state.lock().expect("should lock bid state") = Some(bids_script); - delivered_winner_slots } /// Maximum serialized size (in bytes) of a dump embedded in the `ts-debug` @@ -2446,10 +2429,6 @@ async fn collect_non_html_auction( services: &RuntimeServices, settings: &Settings, ) { - let auction_id = telemetry - .auction_request - .as_ref() - .and_then(|_| diagnostics_auction_id(settings)); let placeholder = mediator_placeholder_request(); let result = orchestrator .collect_dispatched_auction( @@ -2458,15 +2437,6 @@ async fn collect_non_html_auction( &make_collect_context(settings, services, &placeholder), ) .await; - let delivered_winner_slots = write_bids_to_state( - &result.winning_bids, - params.price_granularity, - ¶ms.ad_bids_state, - settings, - &request_origin(¶ms.request_scheme, ¶ms.request_host), - settings.debug.inject_adm_for_testing, - auction_id.as_deref(), - ); if let (Some(observation), Some(auction_request)) = (telemetry.observation, telemetry.auction_request.as_ref()) { @@ -2476,12 +2446,19 @@ async fn collect_non_html_auction( AuctionTerminalOutcome::Completed { request: auction_request, result: &result, - delivered_winner_slots: Some(&delivered_winner_slots), }, ) }) .await; } + write_bids_to_state( + &result.winning_bids, + params.price_granularity, + ¶ms.ad_bids_state, + settings, + &request_origin(¶ms.request_scheme, ¶ms.request_host), + settings.debug.inject_adm_for_testing, + ); } // Private orchestration helper called only from `body_close_hold_loop`. @@ -2500,29 +2477,12 @@ async fn collect_stream_auction( settings, request_origin, } = deps; - let auction_id = telemetry - .auction_request - .as_ref() - .and_then(|_| diagnostics_auction_id(settings)); log::info!("body_close_hold_loop: collecting dispatched auction before held body tail"); let placeholder = mediator_placeholder_request(); let collect_ctx = make_collect_context(settings, services, &placeholder); let result = orchestrator .collect_dispatched_auction(dispatched, services, &collect_ctx) .await; - log::info!( - "body_close_hold_loop: collect complete - {} winning bid(s)", - result.winning_bids.len() - ); - let delivered_winner_slots = write_bids_to_state( - &result.winning_bids, - *price_granularity, - ad_bids_state, - settings, - request_origin, - settings.debug.inject_adm_for_testing, - auction_id.as_deref(), - ); if let (Some(observation), Some(auction_request)) = (telemetry.observation, telemetry.auction_request.as_ref()) { @@ -2532,12 +2492,23 @@ async fn collect_stream_auction( AuctionTerminalOutcome::Completed { request: auction_request, result: &result, - delivered_winner_slots: Some(&delivered_winner_slots), }, ) }) .await; } + log::info!( + "body_close_hold_loop: collect complete - {} winning bid(s)", + result.winning_bids.len() + ); + write_bids_to_state( + &result.winning_bids, + *price_granularity, + ad_bids_state, + settings, + request_origin, + settings.debug.inject_adm_for_testing, + ); if settings.debug.auction_html_comment { prepend_auction_debug_comment("stream", &result, ad_bids_state); @@ -2604,14 +2575,10 @@ pub async fn handle_publisher_request( ec_context: &mut EcContext, auction: AuctionDispatch<'_>, mut req: Request, + edge_header: EdgeCacheHeader, ) -> Result> { log::debug!("Proxying request to publisher_origin"); - // Adapter fallbacks prepare this before EC/cookie handling. Keep this - // idempotent call as a direct-handler safety net and for focused tests. - let gpt_diagnostics = - crate::integrations::gpt_diagnostics::prepare_request(settings, &mut req)?; - // Prebid.js requests are not intercepted here anymore. The HTML processor removes // publisher-supplied Prebid scripts; the unified TSJS bundle includes Prebid.js when enabled. @@ -2693,17 +2660,16 @@ pub async fn handle_publisher_request( let request_path = req.uri().path().to_string(); let is_get = req.method() == http::Method::GET; + let cache_rule_method = req.method() == Method::GET || req.method() == Method::HEAD; let is_prefetch = is_prefetch_request(&req); let is_bot = is_bot_user_agent(&req); - let matched_slots = if is_get { - settings - .creative_opportunities - .as_ref() - .map_or_else(Vec::new, |co_config| { - match_renderable_slots(auction.slots, co_config, &request_path) - }) + let matched_slots: Vec<_> = if settings.creative_opportunities.is_some() && is_get { + crate::creative_opportunities::match_slots(auction.slots, &request_path) + .into_iter() + .cloned() + .collect() } else { Vec::new() }; @@ -2894,18 +2860,6 @@ pub async fn handle_publisher_request( } ); - let suppress_datadome_client_side_tag = req - .extensions() - .get::() - .is_some(); - if should_run_ad_stack || (suppress_datadome_client_side_tag && is_html_document_request(&req)) - { - // HTML document contexts whose output may be synthesized must not - // receive a cached 304 or partial 206. Non-document subresources contain - // no executable injected tag, so retain their validators and ranges. - strip_conditional_and_range_headers(&mut req); - } - // Only advertise encodings the rewrite pipeline can decode and re-encode. restrict_accept_encoding(&mut req); // Strip the internal `fastly-ssl` scheme signal before forwarding to the @@ -2929,14 +2883,10 @@ pub async fn handle_publisher_request( // sets the flag unconditionally and tolerates buffered fallback): adapters // without streaming support may reject the flag outright rather than // silently buffering, which would fail every publisher fetch. - let request_method = req.method().clone(); let mut platform_request = PlatformHttpRequest::new(req, backend_name); if services.http_client().supports_streaming_responses() { platform_request = platform_request.with_stream_response(); } - if should_run_ad_stack { - platform_request = platform_request.with_cache_bypass(); - } let mut response = match services.http_client().send(platform_request).await { Ok(platform_response) => platform_response.response, @@ -2962,44 +2912,18 @@ pub async fn handle_publisher_request( response.headers().len() ); - if should_run_ad_stack && response.status() == StatusCode::NOT_MODIFIED { - if let Some(dispatched) = dispatched_auction.take() { - emit_abandoned_auction( - services, - auction_observation.take(), - dispatched, - "unexpected_origin_304", - ) - .await; - } - - let response = Response::builder() - .status(StatusCode::BAD_GATEWAY) - .header(header::CACHE_CONTROL, "private, no-store") - .header(header::CONTENT_TYPE, "text/plain; charset=utf-8") - .body(EdgeBody::from( - "Publisher origin returned an invalid conditional response", - )) - .change_context(TrustedServerError::Proxy { - message: "failed to build unexpected origin 304 response".to_string(), - })?; - return Ok(PublisherResponse::Buffered(response)); - } - - crate::integrations::gpt_diagnostics::finalize_response(&gpt_diagnostics, &mut response); - let ad_slots_script = if should_run_ad_stack { settings .creative_opportunities .as_ref() - .map(|co_config| build_ad_slots_script(&matched_slots, co_config, &request_path)) + .map(|co_config| build_ad_slots_script(&matched_slots, co_config)) } else { None }; // §4.7: HTML with synthesized per-navigation auction state must not be // stored or validated as an origin representation. Strip both browser and - // surrogate validators/cache directives before returning it. + // edge-cache validators/directives before returning it. // // Gate on `should_run_ad_stack` rather than content-type alone: when no slot // matched, the feature is disabled, or this is not an ad-eligible navigation, @@ -3017,6 +2941,14 @@ pub async fn handle_publisher_request( enforce_synthesized_html_cache_privacy(&mut response); } + apply_publisher_asset_cache_policy( + settings, + &request_path, + cache_rule_method, + edge_header, + &mut response, + )?; + let content_type = response .headers() .get(header::CONTENT_TYPE) @@ -3106,12 +3038,6 @@ pub async fn handle_publisher_request( content_encoding ); - apply_datadome_client_tag_cache_privacy( - &mut response, - &request_method, - suppress_datadome_client_side_tag, - &content_type, - ); let body = std::mem::replace(response.body_mut(), EdgeBody::empty()); response.headers_mut().remove(header::CONTENT_LENGTH); @@ -3127,12 +3053,10 @@ pub async fn handle_publisher_request( content_type, ad_slots_script: ad_slots_script.clone(), ad_bids_state: ad_bids_state.clone(), - suppress_datadome_client_side_tag, auction_observation, auction_request: auction_request_for_telemetry, dispatched_auction, price_granularity, - gpt_diagnostics: Some(gpt_diagnostics), }), }) } @@ -3231,11 +3155,10 @@ pub(crate) fn build_auction_request( // so SSPs, injected creatives, and brand-safety pixels see the publisher's // own origin. On the SSAT proxy path `request_info.host` is the trusted // server edge host, which must not leak into the bid request. - let page_candidate = format!( + let page_url = format!( "{}://{}{}", request_info.scheme, publisher_domain, slots_ctx.request_path ); - let page_url = sanitize_publisher_page_url(Some(&page_candidate), publisher_domain); let ec_id = ec_id.filter(|id| !id.is_empty()); let request_id = ec_id.map_or_else( || format!("ts-req-{}", uuid::Uuid::new_v4().simple()), @@ -3266,21 +3189,6 @@ pub(crate) fn build_auction_request( } } -/// Mint the browser-visible auction correlation token for GPT diagnostics. -/// -/// The token is freshly generated per auction and carries no user identity. -/// [`AuctionRequest::id`] must never be used here: for a consented visitor it is -/// `ts-{ec_id}`, so publishing it in `window.tsjs.bids` would hand the `HttpOnly` -/// EC identifier to any script on the page, and — being stable per visitor — it -/// could not distinguish one auction from the next either. -/// -/// Returns `None` unless the GPT diagnostics integration is enabled, since -/// nothing else consumes the value. -fn diagnostics_auction_id(settings: &Settings) -> Option { - crate::integrations::gpt_diagnostics::is_enabled(settings) - .then(|| format!("ts-auc-{}", uuid::Uuid::new_v4().simple())) -} - /// Escape a JSON string so it is safe to embed inside a JS double-quoted string literal /// inside an HTML `", + "", escaped ) } @@ -3635,14 +3394,12 @@ pub(crate) fn build_empty_bids_script() -> String { /// [`handle_page_bids`] (SPA navigation) so the slot wire shape has a single /// definition and the two paths cannot silently diverge. Property names match /// what the client-side TSJS bundle expects: `gam_unit_path`, `div_id`, -/// `formats`, and `targeting`. Returns `None` when the slot's dynamic GAM unit -/// path exceeds its rendering limit. -pub(crate) fn build_slot_json( +/// `formats`, and `targeting`. +fn build_slot_json( slot: &crate::creative_opportunities::CreativeOpportunitySlot, co_config: &crate::creative_opportunities::CreativeOpportunitiesConfig, - section: &str, -) -> Option { - let gam_path = slot.render_gam_unit_path(&co_config.gam_network_id, section)?; +) -> serde_json::Value { + let gam_path = slot.resolved_gam_unit_path(&co_config.gam_network_id); let div_id = slot.resolved_div_id(); let formats: Vec = slot .formats @@ -3654,40 +3411,13 @@ pub(crate) fn build_slot_json( .iter() .map(|(k, v)| (k.clone(), serde_json::Value::String(v.clone()))) .collect(); - Some(serde_json::json!({ + serde_json::json!({ "id": slot.id, "gam_unit_path": gam_path, "div_id": div_id, "formats": formats, "targeting": targeting, - })) -} - -/// Match creative-opportunity slots and omit dynamic GAM paths that cannot be -/// rendered for this request before they can enter an auction. -fn match_renderable_slots( - slots: &[crate::creative_opportunities::CreativeOpportunitySlot], - co_config: &crate::creative_opportunities::CreativeOpportunitiesConfig, - request_path: &str, -) -> Vec { - let section = co_config.section_for_path(request_path); - crate::creative_opportunities::match_slots(slots, request_path) - .into_iter() - .filter_map(|slot| { - if slot - .render_gam_unit_path(&co_config.gam_network_id, §ion) - .is_none() - { - log::warn!( - "Omitting slot `{}`: dynamic gam_unit_path exceeds the render limit for path `{}`", - slot.id, - request_path - ); - return None; - } - Some(slot.clone()) - }) - .collect() + }) } /// Build the `tsjs.adSlots` `"); @@ -8767,91 +7622,6 @@ mod tests { assert!(!inner.contains('>'), "no unescaped > in script content"); } - #[test] - fn ad_slots_script_omits_only_over_limit_dynamic_slot() { - let mut over_limit = make_slot(); - over_limit.id = "over_limit_dynamic".to_string(); - over_limit.gam_unit_path = Some("/{section}/{section}".to_string()); - over_limit - .compile_unit_template() - .expect("template should compile"); - let mut valid_static = make_slot(); - valid_static.id = "valid_static_sibling".to_string(); - valid_static.gam_unit_path = Some("/12345/example/static".to_string()); - let slots = vec![over_limit, valid_static]; - let config = make_config(); - let request_path = format!("/{}", "a".repeat(60)); - - let script = build_ad_slots_script(&slots, &config, &request_path); - - assert!( - !script.contains("over_limit_dynamic"), - "should omit the over-limit dynamic slot" - ); - assert!( - script.contains("valid_static_sibling"), - "should retain the valid static sibling" - ); - } - - #[test] - fn build_slot_json_renders_section_from_request_path() { - let mut config = make_config(); - config.gam_network_id = "99999".to_string(); - config.section_root = Some("homepage".to_string()); - let mut slot = make_slot(); - slot.gam_unit_path = Some("/{network_id}/example/{section}".to_string()); - slot.compile_unit_template() - .expect("template should compile"); - - let news_section = config.section_for_path("/news/article-123"); - let news = crate::publisher::build_slot_json(&slot, &config, &news_section) - .expect("should render slot"); - assert_eq!( - news["gam_unit_path"], "/99999/example/news", - "section should derive from the first path segment" - ); - - let home_section = config.section_for_path("/"); - let home = crate::publisher::build_slot_json(&slot, &config, &home_section) - .expect("should render slot"); - assert_eq!( - home["gam_unit_path"], "/99999/example/homepage", - "root path should use section_root" - ); - } - - #[test] - fn build_slot_json_honours_configured_section_segment() { - // Locale-prefixed publisher: `/en/news/article` must resolve to the - // `news` unit, not `en`. - let mut config = make_config(); - config.gam_network_id = "99999".to_string(); - config.section_root = Some("homepage".to_string()); - config.section_segment = Some(1); - let mut slot = make_slot(); - slot.gam_unit_path = Some("/{network_id}/example/{section}".to_string()); - slot.compile_unit_template() - .expect("template should compile"); - - let news_section = config.section_for_path("/en/news/article-123"); - let news = crate::publisher::build_slot_json(&slot, &config, &news_section) - .expect("should render slot"); - assert_eq!( - news["gam_unit_path"], "/99999/example/news", - "section should derive from the configured segment index" - ); - - let locale_root_section = config.section_for_path("/en"); - let locale_root = - crate::publisher::build_slot_json(&slot, &config, &locale_root_section) - .expect("should render slot"); - assert_eq!( - locale_root["gam_unit_path"], "/99999/example/homepage", - "a path with no segment at the configured index should use section_root" - ); - } - #[test] fn bid_map_includes_nurl_and_burl() { let mut winning_bids = HashMap::new(); @@ -8902,139 +7672,6 @@ mod tests { ); } - /// Guards the browser-visible token every auction path shares: it must - /// be fresh per auction and absent unless diagnostics can consume it. - #[test] - fn diagnostics_auction_id_is_fresh_and_gated() { - let mut settings = test_settings(); - assert_eq!( - diagnostics_auction_id(&settings), - None, - "no token should be minted without the diagnostics integration" - ); - - settings - .integrations - .insert_config("gpt_diagnostics", &serde_json::json!({ "enabled": true })) - .expect("should enable diagnostics"); - let first = - diagnostics_auction_id(&settings).expect("enabled diagnostics should mint a token"); - let second = - diagnostics_auction_id(&settings).expect("enabled diagnostics should mint a token"); - - assert!( - first.starts_with("ts-auc-"), - "token should use the diagnostics prefix, got `{first}`" - ); - assert_ne!(first, second, "each auction should mint its own token"); - } - - #[test] - fn initial_document_bids_script_includes_auction_id_only_for_winning_bids() { - let slot = make_slot(); - let slots = [slot]; - let slots_ctx = MatchedSlotsContext { - matched_slots: &slots, - request_path: "/2024/01/my-article/", - }; - let request_info = RequestInfo { - host: "publisher.example.com".to_string(), - scheme: "https".to_string(), - }; - let mut auction_request = build_auction_request( - &slots_ctx, - None, - &ConsentContext::default(), - &request_info, - "publisher.example.com", - Some("Mozilla/5.0"), - ); - auction_request.id = "initial-auction-example-123".to_string(); - let mut winning_bids = HashMap::new(); - winning_bids.insert( - "atf_sidebar_ad".to_string(), - make_bid( - "atf_sidebar_ad", - 1.50, - "example_bidder", - "abc123", - "https://example.com/win", - "https://example.com/bill", - ), - ); - - let state = std::sync::Arc::new(std::sync::Mutex::new(None)); - write_bids_to_state( - &winning_bids, - PriceGranularity::Dense, - &state, - &test_settings(), - "", - false, - Some(&auction_request.id), - ); - let script = state - .lock() - .expect("should lock initial bid state") - .clone() - .expect("should generate initial-document bids script"); - let bid_json = script - .strip_prefix( - "", - ) - }) - .expect("should emit the initial-document tsjs.bids script shape"); - let bid_json: String = serde_json::from_str(&format!("\"{bid_json}\"")) - .expect("should decode initial-document JSON.parse input"); - let bids: serde_json::Value = serde_json::from_str(&bid_json) - .expect("should serialize initial-document bids as JSON"); - - assert_eq!( - bids["atf_sidebar_ad"]["hb_auction_id"], auction_request.id, - "initial-document bids should expose the current request ID only on the winner" - ); - - write_bids_to_state( - &HashMap::new(), - PriceGranularity::Dense, - &state, - &test_settings(), - "", - false, - Some(&auction_request.id), - ); - let empty_script = state - .lock() - .expect("should lock empty initial bid state") - .clone() - .expect("should generate empty initial-document bids script"); - let empty_bid_json = empty_script - .strip_prefix( - "", - ) - }) - .expect("should emit the empty initial-document tsjs.bids script shape"); - let empty_bid_json: String = serde_json::from_str(&format!("\"{empty_bid_json}\"")) - .expect("should decode empty initial-document JSON.parse input"); - let empty_bids: serde_json::Value = serde_json::from_str(&empty_bid_json) - .expect("should serialize empty initial-document bids as JSON"); - assert!( - empty_bids - .as_object() - .expect("initial-document bids should be an object") - .is_empty(), - "initial-document bids should not fabricate metadata without a winner" - ); - } - #[test] fn bid_map_omits_zero_creative_dimensions() { // Missing OpenRTB w/h parse to 0. Emitting w:0/h:0 would make the @@ -9152,13 +7789,10 @@ mod tests { #[test] fn build_bid_map_sanitizes_hostile_adm() { - // The inline-adm path must run the same opt-in creative-processing - // boundary as the `/auction` path (sanitize → rewrite) before the - // creative reaches window.tsjs.bids, so with sanitization enabled - // hostile executable markup never lands in the client-facing `adm` - // for the Prebid Universal Creative to run. - let mut settings = test_settings(); - settings.auction.sanitize_creatives = true; + // The inline-adm path must run the same creative-processing boundary + // as the `/auction` path (sanitize → rewrite) before the creative + // reaches window.tsjs.bids, so hostile executable markup never lands + // in the client-facing `adm` for the Prebid Universal Creative to run. let mut winning_bids = HashMap::new(); let mut bid = make_bid( "atf_sidebar_ad", @@ -9175,7 +7809,13 @@ mod tests { ); winning_bids.insert("atf_sidebar_ad".to_string(), bid); - let map = build_bid_map(&winning_bids, PriceGranularity::Dense, &settings, "", false); + let map = build_bid_map( + &winning_bids, + PriceGranularity::Dense, + &test_settings(), + "", + false, + ); let adm = map .get("atf_sidebar_ad") .and_then(|v| v.as_object()) @@ -9202,9 +7842,8 @@ mod tests { } #[test] - fn build_bid_map_can_skip_rewriting_while_sanitizing() { + fn build_bid_map_can_skip_rewriting_but_not_sanitization() { let mut settings = test_settings(); - settings.auction.sanitize_creatives = true; settings.auction.rewrite_creatives = false; let mut winning_bids = HashMap::new(); let mut bid = make_bid( @@ -9261,11 +7900,10 @@ mod tests { #[test] fn build_bid_map_omits_oversized_adm() { - // Creatives larger than the 1 MiB cap are rejected (empty result) - // in every processing mode, so the bid is omitted rather than - // recording a blank winner or shipping an unbounded creative to the - // client. Runs with default settings to cover the shipped - // configuration. + // Creatives larger than the sanitize pass's 1 MiB cap are rejected + // (empty result), so the inline `adm` is omitted and the pbRender + // bridge falls back to the PBS Cache coordinates instead of shipping + // an unbounded creative to the client. let mut winning_bids = HashMap::new(); let mut bid = make_bid( "atf_sidebar_ad", @@ -9278,110 +7916,6 @@ mod tests { bid.creative = Some(format!("
{}
", "a".repeat(1024 * 1024 + 1))); winning_bids.insert("atf_sidebar_ad".to_string(), bid); - let map = build_bid_map( - &winning_bids, - PriceGranularity::Dense, - &test_settings(), - "", - false, - ); - assert!( - !map.contains_key("atf_sidebar_ad"), - "should omit the bid when the creative exceeds the 1 MiB cap" - ); - } - - // A supplied creative that processing rejects must not fall back to the - // PBS Cache coordinates: the GPT bridge fetches the cached bid's ORIGINAL - // adm, which would undo sanitization and the size cap entirely. - fn cached_bid_with_creative(creative: &str) -> Bid { - Bid { - slot_id: "atf_sidebar_ad".to_string(), - price: Some(1.50), - currency: "USD".to_string(), - creative: Some(creative.to_string()), - adomain: None, - bidder: "prebid".to_string(), - width: 300, - height: 250, - nurl: None, - burl: None, - ad_id: Some("bid-impression-id".to_string()), - bid_id: Some("openrtb-bid-id".to_string()), - creative_id: None, - // No typed renderer: these cases assert what happens when the - // supplied markup is the bid's only render source. - renderer: None, - cache_id: Some("cache-uuid".to_string()), - cache_host: Some("prebid-cache.example.com".to_string()), - cache_path: Some("/cache".to_string()), - metadata: Default::default(), - } - } - - // These fixtures carry cache coordinates but no typed renderer, so a - // rejected creative leaves the bid with no render source at all and it - // is dropped outright — which subsumes the property under test: the - // cache coordinates never reach the client, so the cached (unprocessed) - // copy of the markup cannot be fetched in place of what was refused. - fn assert_no_render_source(settings: &Settings, creative: String, case: &str) { - let mut winning_bids = HashMap::new(); - let mut bid = cached_bid_with_creative(""); - bid.creative = Some(creative); - winning_bids.insert("atf_sidebar_ad".to_string(), bid); - - let map = build_bid_map(&winning_bids, PriceGranularity::Dense, settings, "", false); - - match map.get("atf_sidebar_ad").and_then(|v| v.as_object()) { - None => {} - Some(obj) => { - assert!( - obj.get("adm").is_none(), - "{case}: rejected creative should not emit adm" - ); - assert!( - obj.get("hb_cache_host").is_none(), - "{case}: rejected creative should suppress hb_cache_host" - ); - assert!( - obj.get("hb_cache_path").is_none(), - "{case}: rejected creative should suppress hb_cache_path" - ); - } - } - } - - #[test] - fn build_bid_map_suppresses_cache_fallback_for_rejected_creatives() { - let mut sanitizing = test_settings(); - sanitizing.auction.sanitize_creatives = true; - - // Script-only creative: sanitization strips everything. - assert_no_render_source( - &sanitizing, - "".to_string(), - "script-only", - ); - // Oversized creative: rejected by the cap in every mode. - assert_no_render_source( - &test_settings(), - format!("
{}
", "a".repeat(1024 * 1024 + 1)), - "oversized", - ); - // An explicit empty `adm` is a supplied creative, not an absent one: - // classifying it as absent would re-enable the raw cache fallback. - assert_no_render_source(&test_settings(), String::new(), "explicit-empty"); - } - - #[test] - fn build_bid_map_keeps_cache_fallback_for_absent_creatives() { - // A bid with no supplied creative is the legitimate PBS Cache case: - // the coordinates are the only render source. - let mut winning_bids = HashMap::new(); - let mut bid = cached_bid_with_creative(""); - bid.creative = None; - winning_bids.insert("atf_sidebar_ad".to_string(), bid); - let map = build_bid_map( &winning_bids, PriceGranularity::Dense, @@ -9393,16 +7927,9 @@ mod tests { .get("atf_sidebar_ad") .and_then(|v| v.as_object()) .expect("should have a bid entry"); - - assert_eq!( - obj.get("hb_cache_host").and_then(|v| v.as_str()), - Some("prebid-cache.example.com"), - "absent creative should keep hb_cache_host" - ); - assert_eq!( - obj.get("hb_cache_path").and_then(|v| v.as_str()), - Some("/cache"), - "absent creative should keep hb_cache_path" + assert!( + obj.get("adm").is_none(), + "should omit the inline adm when the creative exceeds the 1 MiB cap" ); } @@ -9414,7 +7941,6 @@ mod tests { // root-relative `/first-party/proxy` would resolve against GAM and 404. // The tsjs bundle must NOT be injected into that foreign-origin iframe. let mut settings = test_settings(); - settings.auction.rewrite_creatives = true; settings.publisher.domain = "example.com".to_string(); let mut winning_bids = HashMap::new(); @@ -9464,7 +7990,6 @@ mod tests { // origin the visitor is on (here an HTTP dev host with a port), not the // configured publisher domain. let mut settings = test_settings(); - settings.auction.rewrite_creatives = true; settings.publisher.domain = "example.com".to_string(); let mut winning_bids = HashMap::new(); @@ -9709,9 +8234,6 @@ mod tests { height: 250, nurl: None, burl: None, - bid_id: None, - creative_id: None, - renderer: None, ad_id: Some("bid-impression-id".to_string()), cache_id: Some("f47447a0-b759-4f2f-9887-af458b79b570".to_string()), cache_host: Some("openads.adsrvr.org".to_string()), @@ -9764,10 +8286,7 @@ mod tests { height: 250, nurl: None, burl: None, - bid_id: None, ad_id: Some("aps-bid-token".to_string()), - creative_id: None, - renderer: None, cache_id: None, cache_host: None, cache_path: None, @@ -9801,65 +8320,6 @@ mod tests { ); } - #[test] - fn bid_map_exposes_aps_renderer_and_selected_bid_id() { - // Sanitization is opt-in, so enable it: the script-only creative - // below is what drives this bid onto the renderer path. Left at the - // default it would survive processing as an ordinary creative. - let mut settings = test_settings(); - settings.auction.sanitize_creatives = true; - let mut bid = make_bid("atf_sidebar_ad", 1.50, "aps", "fallback-ad", "", ""); - bid.bid_id = Some("selected-bid".to_string()); - bid.creative = Some("".to_string()); - bid.nurl = None; - bid.burl = None; - bid.renderer = Some(BidRenderer::Aps(ApsRendererV1 { - version: 1, - account_id: "example-account".to_string(), - bid_id: "selected-bid".to_string(), - creative_id: None, - tag_type: ApsTagType::Iframe, - creative_url: "https://creative.example/render".to_string(), - aax_response: "fictional-base64".to_string(), - width: 300, - height: 250, - })); - let winning_bids = HashMap::from([("atf_sidebar_ad".to_string(), bid)]); - - let map = build_bid_map(&winning_bids, PriceGranularity::Dense, &settings, "", false); - let obj = map["atf_sidebar_ad"] - .as_object() - .expect("should include APS bid"); - - assert_eq!(obj["hb_bidder"], "aps"); - assert_eq!(obj["hb_adid"], "selected-bid"); - assert_eq!(obj["renderer"]["type"], "aps"); - assert_eq!(obj["renderer"]["bidId"], "selected-bid"); - assert!(obj.get("adm").is_none()); - - let script = build_bids_script(&map); - assert!(!script.contains("")); - assert!(script.contains("\\u003C/script\\u003E")); - } - - #[test] - fn bid_map_omits_creative_rejected_by_processing_without_renderer() { - // Sanitization is opt-in, so enable it: script-only markup is what - // makes processing reject this bid's only render source. - let mut settings = test_settings(); - settings.auction.sanitize_creatives = true; - let mut bid = make_bid("atf_sidebar_ad", 1.50, "kargo", "fallback-ad", "", ""); - bid.creative = Some("".to_string()); - let winning_bids = HashMap::from([("atf_sidebar_ad".to_string(), bid)]); - - let map = build_bid_map(&winning_bids, PriceGranularity::Dense, &settings, "", false); - - assert!( - !map.contains_key("atf_sidebar_ad"), - "should omit a bid whose only creative was rejected" - ); - } - #[test] fn bid_map_omits_hb_adid_when_both_cache_id_and_ad_id_absent() { let mut winning_bids = HashMap::new(); @@ -9876,9 +8336,6 @@ mod tests { height: 250, nurl: None, burl: None, - bid_id: None, - creative_id: None, - renderer: None, ad_id: None, cache_id: None, cache_host: None, @@ -9920,9 +8377,6 @@ mod tests { height: 250, nurl: None, burl: None, - bid_id: None, - creative_id: None, - renderer: None, ad_id: None, cache_id: None, cache_host: None, @@ -9956,72 +8410,24 @@ mod tests { } #[test] - fn bids_script_schedules_ad_init_without_retry_timer() { - let mut map = serde_json::Map::new(); - map.insert("atf".to_string(), serde_json::json!({"hb_pb": "1.00"})); - - let script = build_bids_script(&map); - - assert!( - script.contains("t.scheduleInitialAdInit"), - "should hand off bids to the deferred adInit scheduler" - ); - assert!( - !script.contains("setTimeout"), - "should not retry adInit on a timer" - ); - assert!( - !script.contains("prevGptSlots"), - "should not use TS-owned slots as adInit success signal" - ); - } - - #[test] - fn bids_script_defers_ad_init_until_after_hydration() { + fn bids_script_calls_ad_init_without_retry_timer() { let mut map = serde_json::Map::new(); map.insert("atf".to_string(), serde_json::json!({"hb_pb": "1.00"})); - - let script = build_bids_script(&map); - - // adInit() mutates ad-slot subtrees (GPT defineSlot on the - // `-container` wrapper). Running it synchronously at body-parse time - // lands those mutations inside React's hydration window and trips a - // #418 hydration mismatch. The deferral lifecycle (window `load`, - // double `requestAnimationFrame`, generation-0 pinning via - // `tsjs.navGeneration`) lives in the GPT bundle module (with a - // head-injected fallback in gpt_bootstrap.js) where it is executable - // under Vitest (schedule_initial_ad_init.test.ts); this inline - // script must only delegate to that scheduler. - assert!( - script.contains("var s=t.scheduleInitialAdInit"), - "should delegate deferral to the installed scheduler" - ); - // The bids payload is handed to the scheduler (which applies it only - // while the page is still on navigation generation 0) instead of - // being assigned unconditionally, so a faster SPA navigation's live - // bids cannot be clobbered by the stale SSR payload. - assert!( - script.contains("if(typeof s===\"function\")s(b)"), - "should pass the SSR bids payload to the scheduler" - ); - assert!( - script.contains("else t.bids=b"), - "should fall back to a plain bids assignment without a scheduler" - ); - assert!( - !script.contains(".bids=JSON.parse"), - "should not assign the SSR payload unconditionally" - ); - // The one hydration-unsafe thing this script could do is invoke - // adInit synchronously at body-parse time — it must not. + + let script = build_bids_script(&map); + assert!( - !script.contains("adInit()"), - "should not invoke adInit synchronously at parse time" + script.contains("window.tsjs.adInit"), + "should hand off bids to adInit" ); assert!( !script.contains("setTimeout"), "should not retry adInit on a timer" ); + assert!( + !script.contains("prevGptSlots"), + "should not use TS-owned slots as adInit success signal" + ); } #[test] @@ -10091,58 +8497,12 @@ mod tests { ); assert_eq!( request.publisher.page_url.as_deref(), - Some("https://www.example.com/2024/01/my-article/"), - "page_url should use configured publisher identity without client query data" - ); - assert_eq!( - site.page, "https://www.example.com/2024/01/my-article/", - "site.page should use configured publisher identity without client query data" - ); - } - - #[test] - fn auction_request_preserves_configured_publisher_domain_with_query() { - // On the SSAT proxy path the browser addresses the trusted-server - // edge host, but the auction must advertise the configured - // publisher domain to SSPs — otherwise injected creatives and the - // brand-safety pixel leak the edge/staging host. - let slot = make_slot(); - let slots = [slot]; - let slots_ctx = MatchedSlotsContext { - matched_slots: &slots, - request_path: "/2024/01/my-article/?edition=fictional", - }; - let request_info = RequestInfo { - host: "ts.example.com".to_string(), - scheme: "https".to_string(), - }; - - let request = build_auction_request( - &slots_ctx, - None, - &ConsentContext::default(), - &request_info, - "www.example.com", - Some("Mozilla/5.0"), - ); - - assert_eq!( - request.publisher.domain, "www.example.com", - "publisher.domain should be the configured publisher domain, not the edge host" - ); - let site = request.site.expect("should populate site metadata"); - assert_eq!( - site.domain, "www.example.com", - "site.domain should be the configured publisher domain, not the edge host" - ); - assert_eq!( - request.publisher.page_url.as_deref(), - Some("https://www.example.com/2024/01/my-article/"), - "page_url should remove client query data" + Some("https://www.example.com/2024/01/my-article/?edition=fictional"), + "page_url host should be the configured publisher domain, not the edge host" ); assert_eq!( - site.page, "https://www.example.com/2024/01/my-article/", - "site.page should remove client query data" + site.page, "https://www.example.com/2024/01/my-article/?edition=fictional", + "site.page host should be the configured publisher domain, not the edge host" ); } @@ -10226,108 +8586,11 @@ mod tests { mod page_bids_no_match_tests { use super::super::*; - use super::build_services_with_http_client; use crate::auction::AuctionOrchestrator; - use crate::auction::provider::{AuctionProvider, ProviderRequestOutcome}; - use crate::auction::types::{AuctionRequest, AuctionResponse, Bid}; use crate::creative_opportunities::{CreativeOpportunityFormat, CreativeOpportunitySlot}; - use crate::platform::test_support::{StubHttpClient, noop_services}; - use crate::platform::{PlatformHttpRequest, PlatformResponse}; + use crate::platform::test_support::noop_services; use crate::test_support::tests::crate_test_settings_str; - use error_stack::{Report, ResultExt}; use http::Method; - use std::sync::{Arc, Mutex}; - - const AUCTION_ID_TEST_PROVIDER: &str = "auction_id_test_provider"; - const AUCTION_ID_TEST_BACKEND: &str = "auction-id-test-backend"; - - struct AuctionIdTestProvider { - captured_request: Arc>>, - winning_bid: bool, - } - - #[async_trait::async_trait(?Send)] - impl AuctionProvider for AuctionIdTestProvider { - fn provider_name(&self) -> &'static str { - AUCTION_ID_TEST_PROVIDER - } - - async fn request_bids( - &self, - request: &AuctionRequest, - context: &AuctionContext<'_>, - ) -> Result> { - *self - .captured_request - .lock() - .expect("should lock captured auction request") = Some(request.clone()); - let request = PlatformHttpRequest::new( - Request::builder() - .method(Method::POST) - .uri("https://bidder.example.test/bids") - .body(EdgeBody::empty()) - .expect("should build test bidder request"), - AUCTION_ID_TEST_BACKEND, - ); - context - .services - .http_client() - .send_async(request) - .await - .change_context(TrustedServerError::Auction { - message: "test bidder launch failed".to_string(), - }) - .map(ProviderRequestOutcome::pending) - } - - async fn parse_response( - &self, - _response: PlatformResponse, - response_time_ms: u64, - ) -> Result> { - let bids = if self.winning_bid { - vec![Bid { - slot_id: "atf".to_string(), - price: Some(1.50), - currency: "USD".to_string(), - creative: None, - adomain: None, - bidder: AUCTION_ID_TEST_PROVIDER.to_string(), - width: 300, - height: 250, - nurl: None, - burl: None, - bid_id: None, - creative_id: None, - renderer: None, - ad_id: Some("winner-123".to_string()), - cache_id: None, - cache_host: None, - cache_path: None, - metadata: Default::default(), - }] - } else { - Vec::new() - }; - Ok(AuctionResponse::success( - AUCTION_ID_TEST_PROVIDER, - bids, - response_time_ms, - )) - } - - fn timeout_ms(&self) -> u32 { - 100 - } - - fn backend_name( - &self, - _services: &RuntimeServices, - _timeout_ms: u32, - ) -> Option { - Some(AUCTION_ID_TEST_BACKEND.to_string()) - } - } fn settings_with_co() -> Settings { let toml = format!( @@ -10408,7 +8671,6 @@ mod tests { targeting: Default::default(), providers: Default::default(), compiled_patterns: Vec::new(), - compiled_unit: None, }] } @@ -10472,206 +8734,6 @@ mod tests { .expect("should return ok response") } - fn auction_id_test_orchestrator( - settings: &Settings, - captured_request: Arc>>, - winning_bid: bool, - ) -> AuctionOrchestrator { - let mut orchestrator = AuctionOrchestrator::new(settings.auction.clone()); - orchestrator.register_provider(Arc::new(AuctionIdTestProvider { - captured_request, - winning_bid, - })); - orchestrator - } - - #[tokio::test] - async fn page_bids_response_includes_auction_id_only_for_winning_bids() { - let mut settings = settings_with_co(); - settings.auction.providers = vec![AUCTION_ID_TEST_PROVIDER.to_string()]; - settings - .integrations - .insert_config("gpt_diagnostics", &serde_json::json!({ "enabled": true })) - .expect("should enable diagnostics"); - let slots = article_slot(); - let winning_stub = Arc::new(StubHttpClient::new()); - winning_stub.push_response(200, b"winner".to_vec()); - let winning_services = build_services_with_http_client( - Arc::clone(&winning_stub) as Arc - ); - let winning_request = Arc::new(Mutex::new(None)); - let winning_orchestrator = - auction_id_test_orchestrator(&settings, Arc::clone(&winning_request), true); - let ec_context = EcContext::new_for_test( - Some("page-auction-example-123".to_string()), - crate::consent::ConsentContext { - jurisdiction: crate::consent::jurisdiction::Jurisdiction::NonRegulated, - ..Default::default() - }, - ); - - let winning_response = handle_page_bids( - &settings, - &winning_services, - None, - AuctionDispatch { - orchestrator: &winning_orchestrator, - slots: &slots, - registry: None, - }, - &ec_context, - make_page_bids_request("/2024/01/my-article/"), - ) - .await - .expect("should return winning page-bids response"); - let winning_body: serde_json::Value = serde_json::from_slice( - &winning_response - .into_body() - .into_bytes() - .expect("should read winning page-bids response body"), - ) - .expect("should serialize winning page-bids response as JSON"); - let auction_request = winning_request - .lock() - .expect("should lock captured winning request") - .clone() - .expect("should dispatch a winning auction request"); - - assert_eq!( - auction_request.id, "ts-page-auction-example-123", - "test EC ID should produce a deterministic auction request ID" - ); - let winning_auction_id = winning_body["bids"]["atf"]["hb_auction_id"] - .as_str() - .expect("page-bids should expose an auction ID on the winner") - .to_string(); - assert!( - winning_auction_id.starts_with("ts-auc-"), - "page-bids should expose a freshly minted diagnostics token, got `{winning_auction_id}`" - ); - assert_ne!( - winning_auction_id, auction_request.id, - "browser-visible auction ID must not be the EC-derived request ID" - ); - assert!( - !winning_auction_id.contains("page-auction-example-123"), - "browser-visible auction ID must not embed the EC ID" - ); - - let no_winner_stub = Arc::new(StubHttpClient::new()); - no_winner_stub.push_response(200, b"no-bid".to_vec()); - let no_winner_services = build_services_with_http_client( - Arc::clone(&no_winner_stub) as Arc - ); - let no_winner_orchestrator = - auction_id_test_orchestrator(&settings, Arc::new(Mutex::new(None)), false); - let no_winner_response = handle_page_bids( - &settings, - &no_winner_services, - None, - AuctionDispatch { - orchestrator: &no_winner_orchestrator, - slots: &slots, - registry: None, - }, - &ec_context, - make_page_bids_request("/2024/01/my-article/"), - ) - .await - .expect("should return no-winner page-bids response"); - let no_winner_body: serde_json::Value = serde_json::from_slice( - &no_winner_response - .into_body() - .into_bytes() - .expect("should read no-winner page-bids response body"), - ) - .expect("should serialize no-winner page-bids response as JSON"); - - assert!( - no_winner_body["bids"] - .as_object() - .expect("page-bids should return a bids object") - .is_empty(), - "page-bids should not fabricate auction metadata without a winner" - ); - } - - /// The browser-visible auction ID is minted per auction and only for - /// deployments that run the diagnostics integration, so it can neither - /// carry EC identity across auctions nor reach pages that ignore it. - #[tokio::test] - async fn page_bids_auction_id_is_per_auction_and_gated_on_diagnostics() { - async fn winning_auction_id(settings: &Settings) -> Option { - let slots = article_slot(); - let stub = Arc::new(StubHttpClient::new()); - stub.push_response(200, b"winner".to_vec()); - let services = build_services_with_http_client( - Arc::clone(&stub) as Arc - ); - let orchestrator = - auction_id_test_orchestrator(settings, Arc::new(Mutex::new(None)), true); - let ec_context = EcContext::new_for_test( - Some("page-auction-example-123".to_string()), - crate::consent::ConsentContext { - jurisdiction: crate::consent::jurisdiction::Jurisdiction::NonRegulated, - ..Default::default() - }, - ); - let response = handle_page_bids( - settings, - &services, - None, - AuctionDispatch { - orchestrator: &orchestrator, - slots: &slots, - registry: None, - }, - &ec_context, - make_page_bids_request("/2024/01/my-article/"), - ) - .await - .expect("should return page-bids response"); - let body: serde_json::Value = serde_json::from_slice( - &response - .into_body() - .into_bytes() - .expect("should read page-bids response body"), - ) - .expect("should serialize page-bids response as JSON"); - body["bids"]["atf"]["hb_auction_id"] - .as_str() - .map(str::to_string) - } - - let mut settings = settings_with_co(); - settings.auction.providers = vec![AUCTION_ID_TEST_PROVIDER.to_string()]; - settings - .integrations - .insert_config("gpt_diagnostics", &serde_json::json!({ "enabled": true })) - .expect("should enable diagnostics"); - - let first = winning_auction_id(&settings) - .await - .expect("first auction should expose a diagnostics token"); - let second = winning_auction_id(&settings) - .await - .expect("second auction should expose a diagnostics token"); - assert_ne!( - first, second, - "each auction for the same visitor should mint its own token" - ); - - settings - .integrations - .insert_config("gpt_diagnostics", &serde_json::json!({ "enabled": false })) - .expect("should disable diagnostics"); - assert_eq!( - winning_auction_id(&settings).await, - None, - "no auction metadata should reach the page without the diagnostics integration" - ); - } - /// The deprecated `/__ts/page-bids` alias must be handled identically to /// the canonical path — same status, same JSON body. /// @@ -10972,46 +9034,6 @@ mod tests { ); } - #[tokio::test] - async fn page_bids_omits_only_over_limit_dynamic_slot() { - let settings = settings_with_co(); - let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); - let mut over_limit = article_slot() - .into_iter() - .next() - .expect("should build over-limit slot"); - over_limit.id = "over_limit_dynamic".to_string(); - over_limit.page_patterns = vec!["/*".to_string()]; - over_limit.gam_unit_path = Some("/{section}/{section}".to_string()); - over_limit - .compile_unit_template() - .expect("template should compile"); - let mut valid_static = article_slot() - .into_iter() - .next() - .expect("should build valid static slot"); - valid_static.id = "valid_static_sibling".to_string(); - valid_static.page_patterns = vec!["/*".to_string()]; - valid_static.gam_unit_path = Some("/12345/example/static".to_string()); - let slots = vec![over_limit, valid_static]; - let request_path = format!("/{}", "a".repeat(60)); - let mut req = make_page_bids_request(&request_path); - set_test_header(&mut req, "sec-purpose", "prefetch"); - - let body = run_page_bids_consent_allowed(&settings, &orchestrator, &slots, req).await; - let returned_slots = body["slots"].as_array().expect("slots should be array"); - - assert_eq!( - returned_slots.len(), - 1, - "should omit only the over-limit dynamic slot" - ); - assert_eq!( - returned_slots[0]["id"], "valid_static_sibling", - "should retain the valid static sibling" - ); - } - #[tokio::test] async fn url_not_matching_any_pattern_returns_empty_response() { // Slots exist but request path does not match — no auction, no injection. @@ -11181,7 +9203,7 @@ mod tests { /// the handler emitted. mod navigation_publisher_domain_tests { use super::*; - use crate::auction::provider::{AuctionProvider, ProviderRequestOutcome}; + use crate::auction::provider::AuctionProvider; use crate::auction::telemetry::{AuctionEventBatch, AuctionTelemetrySink}; use crate::auction::types::AuctionRequest; use crate::auction::{AuctionContext, AuctionOrchestrator}; @@ -11189,7 +9211,7 @@ mod tests { use crate::platform::test_support::{ NoopConfigStore, NoopGeo, NoopSecretStore, StubBackend, }; - use crate::platform::{ClientInfo, PlatformResponse}; + use crate::platform::{ClientInfo, PlatformPendingRequest, PlatformResponse}; use crate::test_support::tests::crate_test_settings_str; use std::sync::Mutex; @@ -11218,7 +9240,7 @@ mod tests { &self, request: &AuctionRequest, _context: &AuctionContext<'_>, - ) -> Result> { + ) -> Result> { *self.captured.lock().expect("should lock captured request") = Some(request.clone()); Err(Report::new(TrustedServerError::Auction { @@ -11291,42 +9313,9 @@ mod tests { targeting: Default::default(), providers: Default::default(), compiled_patterns: Vec::new(), - compiled_unit: None, }] } - fn slots_with_over_limit_dynamic_sibling() -> Vec { - let mut over_limit = article_slot() - .into_iter() - .next() - .expect("should build over-limit slot"); - over_limit.id = "over_limit_dynamic".to_string(); - over_limit.page_patterns = vec!["/*".to_string()]; - over_limit.gam_unit_path = Some("/{section}/{section}".to_string()); - - let mut valid_static = article_slot() - .into_iter() - .next() - .expect("should build valid static slot"); - valid_static.id = "valid_static_sibling".to_string(); - valid_static.page_patterns = vec!["/*".to_string()]; - valid_static.gam_unit_path = Some("/12345/example/static".to_string()); - - vec![over_limit, valid_static] - } - - fn assert_only_renderable_slot_was_auctioned( - captured: &Arc>>, - ) { - let request = captured - .lock() - .expect("should lock captured request") - .clone() - .expect("should dispatch an auction request"); - let slot_ids: Vec<_> = request.slots.iter().map(|slot| slot.id.as_str()).collect(); - assert_eq!(slot_ids, vec!["valid_static_sibling"]); - } - /// [`EcContext`] whose consent context permits the server-side auction. fn consent_allowing_ec_context() -> EcContext { let consent = crate::consent::ConsentContext { @@ -11501,90 +9490,5 @@ mod tests { assert_configured_domain(&captured, &telemetry_sink); } - - #[tokio::test] - async fn initial_navigation_auctions_only_renderable_slots() { - let settings = settings_with_capturing_provider(); - let captured = Arc::new(Mutex::new(None)); - let orchestrator = orchestrator_capturing_request(&settings, &captured); - let telemetry_sink = Arc::new(RecordingTelemetrySink::default()); - let stub = Arc::new(StubHttpClient::new()); - stub.push_response(200, b"ok".to_vec()); - let services = services_with( - Arc::clone(&stub) as Arc, - telemetry_sink, - ); - let mut ec_context = consent_allowing_ec_context(); - let request_path = format!("/{}", "a".repeat(60)); - let req = HttpRequest::builder() - .method(Method::GET) - .uri(format!("https://{EDGE_HOST}{request_path}")) - .header(header::HOST, EDGE_HOST) - .header("sec-fetch-dest", "document") - .body(EdgeBody::empty()) - .expect("should build test request"); - let slots = slots_with_over_limit_dynamic_sibling(); - - let _ = handle_publisher_request( - &settings, - &services, - None, - &mut ec_context, - AuctionDispatch { - orchestrator: &orchestrator, - slots: &slots, - registry: None, - }, - req, - ) - .await - .expect("should proxy publisher request"); - - assert_only_renderable_slot_was_auctioned(&captured); - } - - #[tokio::test] - async fn page_bids_auctions_only_renderable_slots() { - let settings = settings_with_capturing_provider(); - let captured = Arc::new(Mutex::new(None)); - let orchestrator = orchestrator_capturing_request(&settings, &captured); - let telemetry_sink = Arc::new(RecordingTelemetrySink::default()); - let services = services_with( - Arc::new(crate::platform::test_support::NoopHttpClient), - telemetry_sink, - ); - let ec_context = consent_allowing_ec_context(); - let request_path = format!("/{}", "a".repeat(60)); - let mut req = HttpRequest::builder() - .method(Method::GET) - .uri(format!( - "https://{EDGE_HOST}/_ts/page-bids?path={request_path}" - )) - .header(header::HOST, EDGE_HOST) - .body(EdgeBody::empty()) - .expect("should build test request"); - req.headers_mut().insert( - header::HeaderName::from_static("sec-fetch-site"), - HeaderValue::from_static("same-origin"), - ); - let slots = slots_with_over_limit_dynamic_sibling(); - - let _ = handle_page_bids( - &settings, - &services, - None, - AuctionDispatch { - orchestrator: &orchestrator, - slots: &slots, - registry: None, - }, - &ec_context, - req, - ) - .await - .expect("should return ok response"); - - assert_only_renderable_slot_was_auctioned(&captured); - } } } diff --git a/crates/trusted-server-core/src/response_privacy.rs b/crates/trusted-server-core/src/response_privacy.rs index 21ba9f20b..40650d7e7 100644 --- a/crates/trusted-server-core/src/response_privacy.rs +++ b/crates/trusted-server-core/src/response_privacy.rs @@ -11,38 +11,31 @@ use edgezero_core::http::{HeaderName, HeaderValue, Response, header}; +use crate::cache_policy::{ + CacheControlPolicy, EdgeCacheHeader, cache_control_headers_are_private_or_no_store, + is_edge_cache_header_name, remove_edge_cache_headers, +}; use crate::settings::Settings; -/// CDN-targeted cache headers stripped from every cookie-bearing response. -/// -/// A single source of truth so the adapter copies of the privacy downgrade -/// cannot drift apart. -pub const CDN_CACHE_HEADERS: &[&str] = &[ - "surrogate-control", - "fastly-surrogate-control", - "cdn-cache-control", - "cloudflare-cdn-cache-control", -]; - -fn strip_cdn_cache_headers(response: &mut Response) { - for name in CDN_CACHE_HEADERS { - response.headers_mut().remove(*name); - } +/// Runtime edge-cache headers stripped from private or cookie-bearing responses. +pub use crate::cache_policy::EDGE_CACHE_HEADER_NAMES as SURROGATE_CACHE_HEADERS; +/// Backwards-compatible name used by integrations that clear every edge-cache directive. +pub use crate::cache_policy::EDGE_CACHE_HEADER_NAMES as CDN_CACHE_HEADERS; + +fn cache_control_is_private_or_no_store(response: &Response) -> bool { + cache_control_headers_are_private_or_no_store(response.headers()) } /// Forces synthesized HTML to be private and non-storable. /// /// Use this exact policy whenever Trusted Server changes an origin HTML /// representation with request-specific content: force `private, no-store`, -/// remove origin validators, and remove all CDN-targeted cache directives. +/// remove origin validators, and remove all runtime edge-cache directives. pub(crate) fn enforce_synthesized_html_cache_privacy(response: &mut Response) { - response.headers_mut().insert( - header::CACHE_CONTROL, - HeaderValue::from_static("private, no-store"), - ); + CacheControlPolicy::NoStorePrivate + .apply_to_headers(response.headers_mut(), EdgeCacheHeader::None); response.headers_mut().remove(header::ETAG); response.headers_mut().remove(header::LAST_MODIFIED); - strip_cdn_cache_headers(response); } /// Forces cookie-bearing responses to stay private to shared caches. @@ -58,19 +51,14 @@ pub fn enforce_set_cookie_cache_privacy(response: &mut Response) { if !response.headers().contains_key(header::SET_COOKIE) { return; } - // Shared-cache control headers must come off every cookie-bearing response, even - // one already carrying a stricter `no-store`/`private` directive — they are + // Edge-cache headers must come off every cookie-bearing response, even one + // already carrying a stricter `no-store`/`private` directive — they are // independent of Cache-Control and would otherwise let a shared cache store // and replay one visitor's Set-Cookie. - strip_cdn_cache_headers(response); + remove_edge_cache_headers(response.headers_mut()); // Cache-Control directives are case-insensitive (RFC 9111 §5.2), so match // against a lowercased copy — `No-Store` / `Private` must count. - let already_uncacheable = response - .headers() - .get(header::CACHE_CONTROL) - .and_then(|v| v.to_str().ok()) - .map(str::to_ascii_lowercase) - .is_some_and(|v| v.contains("private") || v.contains("no-store")); + let already_uncacheable = cache_control_is_private_or_no_store(response); if !already_uncacheable { response.headers_mut().insert( header::CACHE_CONTROL, @@ -85,10 +73,10 @@ pub fn enforce_set_cookie_cache_privacy(response: &mut Response) { /// First downgrades cookie-bearing responses via /// [`enforce_set_cookie_cache_privacy`], then applies operator headers — but on /// an uncacheable (`private`/`no-store`) response the cache-controlling headers -/// (`Cache-Control` and the surrogate cache headers) are skipped so operators +/// (`Cache-Control` and runtime edge-cache headers) are skipped so operators /// cannot re-enable shared caching for per-user payloads. After the operator /// headers are applied the cookie-privacy downgrade runs once more, so a -/// configured `Set-Cookie` combined with public/surrogate cache headers cannot +/// configured `Set-Cookie` combined with public edge-cache headers cannot /// produce a shared-cacheable cookie-bearing response. /// /// Invalid header names/values are logged and skipped rather than panicking, so @@ -96,19 +84,15 @@ pub fn enforce_set_cookie_cache_privacy(response: &mut Response) { pub fn apply_response_headers_with_cache_privacy(settings: &Settings, response: &mut Response) { enforce_set_cookie_cache_privacy(response); - let response_is_uncacheable = response - .headers() - .get(header::CACHE_CONTROL) - .and_then(|v| v.to_str().ok()) - .map(str::to_ascii_lowercase) - .is_some_and(|v| v.contains("private") || v.contains("no-store")); + let response_is_uncacheable = cache_control_is_private_or_no_store(response); + if response_is_uncacheable { + remove_edge_cache_headers(response.headers_mut()); + } for (key, value) in &settings.response_headers { if response_is_uncacheable && (key.eq_ignore_ascii_case(header::CACHE_CONTROL.as_str()) - || CDN_CACHE_HEADERS - .iter() - .any(|name| key.eq_ignore_ascii_case(name))) + || is_edge_cache_header_name(key)) { continue; } @@ -129,10 +113,14 @@ pub fn apply_response_headers_with_cache_privacy(settings: &Settings, response: response.headers_mut().insert(header_name, header_value); } + if cache_control_is_private_or_no_store(response) { + remove_edge_cache_headers(response.headers_mut()); + } + // Operator headers can themselves introduce Set-Cookie (alongside public - // or surrogate cache headers) onto a previously cookieless response, which - // the pre-apply pass could not see. Re-run the downgrade so the final - // response can never pair Set-Cookie with shared cacheability. + // edge-cache headers) onto a previously cookieless response, which the + // pre-apply pass could not see. Re-run the downgrade so the final response + // can never pair Set-Cookie with shared cacheability. enforce_set_cookie_cache_privacy(response); } @@ -169,7 +157,7 @@ mod tests { } #[test] - fn synthesized_html_is_forced_no_store_without_validators_or_cdn_headers() { + fn synthesized_html_is_forced_no_store_without_validators_or_edge_headers() { let mut response = response_builder() .header(header::CACHE_CONTROL, "private, max-age=600") .header(header::ETAG, "\"origin\"") @@ -185,12 +173,12 @@ mod tests { assert_eq!( response.headers()[header::CACHE_CONTROL], - "private, no-store", + "no-store, private", "synthesized HTML should always be non-storable" ); for header_name in [header::ETAG.as_str(), header::LAST_MODIFIED.as_str()] .into_iter() - .chain(CDN_CACHE_HEADERS.iter().copied()) + .chain(SURROGATE_CACHE_HEADERS.iter().copied()) { assert!( !response.headers().contains_key(header_name), @@ -205,7 +193,6 @@ mod tests { let mut response = response_builder() .header(header::SET_COOKIE, "id=abc") .header("surrogate-control", "max-age=600") - .header("fastly-surrogate-control", "max-age=600") .header("cdn-cache-control", "max-age=600") .header("cloudflare-cdn-cache-control", "max-age=600") .body(edgezero_core::body::Body::empty()) @@ -221,12 +208,14 @@ mod tests { Some("private, max-age=0"), "operator public Cache-Control must not override cookie privacy downgrade" ); - for header_name in CDN_CACHE_HEADERS { - assert!( - !response.headers().contains_key(*header_name), - "CDN cache header {header_name} must be stripped on cookie responses" - ); - } + assert!( + !response.headers().contains_key("surrogate-control") + && !response.headers().contains_key("cdn-cache-control") + && !response + .headers() + .contains_key("cloudflare-cdn-cache-control"), + "edge cache headers must be stripped on cookie responses" + ); } #[test] @@ -237,9 +226,8 @@ mod tests { ("set-cookie", "operator=abc"), ("cache-control", "public, max-age=600"), ("surrogate-control", "max-age=600"), - ("fastly-surrogate-control", "max-age=600"), - ("cdn-cache-control", "public, max-age=600"), - ("cloudflare-cdn-cache-control", "public, max-age=600"), + ("cdn-cache-control", "max-age=600"), + ("cloudflare-cdn-cache-control", "max-age=600"), ]); let mut response = response_builder() .body(edgezero_core::body::Body::empty()) @@ -255,18 +243,49 @@ mod tests { Some("private, max-age=0"), "operator Set-Cookie plus public Cache-Control must be re-downgraded to private" ); - for header_name in CDN_CACHE_HEADERS { - assert!( - !response.headers().contains_key(*header_name), - "CDN cache header {header_name} must be stripped when operator headers add Set-Cookie" - ); - } + assert!( + !response.headers().contains_key("surrogate-control") + && !response.headers().contains_key("cdn-cache-control") + && !response + .headers() + .contains_key("cloudflare-cdn-cache-control"), + "edge cache headers must be stripped when operator headers add Set-Cookie" + ); assert!( response.headers().contains_key(header::SET_COOKIE), "the operator Set-Cookie itself should still be applied" ); } + #[test] + fn cookie_privacy_does_not_treat_pseudo_directives_as_uncacheable() { + let settings = settings_with_response_headers(&[]); + let mut response = response_builder() + .header(header::SET_COOKIE, "id=abc") + .header( + header::CACHE_CONTROL, + "public, max-age=600, no-storey, not-private", + ) + .header("surrogate-control", "max-age=600") + .body(edgezero_core::body::Body::empty()) + .expect("should build response"); + + apply_response_headers_with_cache_privacy(&settings, &mut response); + + assert_eq!( + response + .headers() + .get(header::CACHE_CONTROL) + .and_then(|v| v.to_str().ok()), + Some("private, max-age=0"), + "pseudo-directives must not prevent the cookie privacy downgrade" + ); + assert!( + !response.headers().contains_key("surrogate-control"), + "cookie privacy downgrade should still strip edge-cache headers" + ); + } + #[test] fn preserves_private_no_store_against_operator_cache_headers_without_cookie() { let settings = settings_with_response_headers(&[ @@ -288,7 +307,7 @@ mod tests { "private, no-store", "operator cache headers must not weaken an existing private response" ); - for header_name in CDN_CACHE_HEADERS { + for header_name in SURROGATE_CACHE_HEADERS { assert!( !response.headers().contains_key(*header_name), "operator headers must not restore shared caching through {header_name}" @@ -297,42 +316,47 @@ mod tests { } #[test] - fn applies_operator_headers_on_cookieless_response() { - let settings = settings_with_response_headers(&[("x-operator", "value")]); + fn strips_edge_headers_from_uncacheable_cookieless_response() { + let settings = settings_with_response_headers(&[ + ("cdn-cache-control", "max-age=600"), + ("cloudflare-cdn-cache-control", "max-age=600"), + ]); let mut response = response_builder() + .header(header::CACHE_CONTROL, "private, max-age=0") + .header("surrogate-control", "max-age=600") + .header("cdn-cache-control", "max-age=600") + .header("cloudflare-cdn-cache-control", "max-age=600") .body(edgezero_core::body::Body::empty()) .expect("should build response"); apply_response_headers_with_cache_privacy(&settings, &mut response); - assert_eq!( - response - .headers() - .get("x-operator") - .and_then(|v| v.to_str().ok()), - Some("value"), - "operator headers should still apply to cacheable responses" + assert!( + !response.headers().contains_key("surrogate-control") + && !response.headers().contains_key("cdn-cache-control") + && !response + .headers() + .contains_key("cloudflare-cdn-cache-control"), + "uncacheable responses must not retain or receive edge-cache headers" ); } #[test] - fn uncacheable_response_rejects_operator_cdn_cache_headers() { - let settings = settings_with_response_headers(&[ - ("cdn-cache-control", "public, max-age=600"), - ("cloudflare-cdn-cache-control", "public, max-age=600"), - ]); + fn applies_operator_headers_on_cookieless_response() { + let settings = settings_with_response_headers(&[("x-operator", "value")]); let mut response = response_builder() - .header(header::CACHE_CONTROL, "private, no-store") .body(edgezero_core::body::Body::empty()) .expect("should build response"); apply_response_headers_with_cache_privacy(&settings, &mut response); - for header_name in ["cdn-cache-control", "cloudflare-cdn-cache-control"] { - assert!( - !response.headers().contains_key(header_name), - "operator headers must not restore shared caching through {header_name}" - ); - } + assert_eq!( + response + .headers() + .get("x-operator") + .and_then(|v| v.to_str().ok()), + Some("value"), + "operator headers should still apply to cacheable responses" + ); } } diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index 598c00056..ad17595f9 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -1,6 +1,7 @@ #[cfg(test)] use config::{Config, Environment, File, FileFormat}; use error_stack::{Report, ResultExt}; +use glob::Pattern; use regex::Regex; use serde::{Deserialize, Deserializer, Serialize, de::DeserializeOwned}; use serde_json::Value as JsonValue; @@ -8,10 +9,12 @@ use std::collections::{HashMap, HashSet}; use std::ops::{Deref, DerefMut}; use std::str::FromStr; use std::sync::OnceLock; +use std::time::Duration; use url::Url; use validator::{Validate, ValidationError}; use crate::auction_config_types::AuctionConfig; +use crate::cache_policy::{CachePolicy, CacheVisibility}; use crate::consent_config::ConsentConfig; use crate::creative_opportunities::CreativeOpportunitiesConfig; use crate::error::TrustedServerError; @@ -1866,6 +1869,322 @@ fn validate_tinybird_secret(value: &str, setting: &str) -> Result<(), Report, +} + +impl CacheSettings { + fn normalize(&mut self) { + for rule in &mut self.asset_rules { + rule.normalize(); + } + } + + /// Eagerly validate runtime-only cache settings artifacts. + /// + /// # Errors + /// + /// Returns a configuration error if any rule ID is duplicate, matcher shape + /// is invalid, or a configured regex/glob cannot compile. + pub fn prepare_runtime(&self) -> Result<(), Report> { + let mut seen_ids = HashSet::new(); + for rule in &self.asset_rules { + if rule.id.is_empty() { + return Err(Report::new(TrustedServerError::Configuration { + message: "cache.asset_rules id must not be empty".to_string(), + })); + } + if !seen_ids.insert(rule.id.clone()) { + return Err(Report::new(TrustedServerError::Configuration { + message: format!("cache.asset_rules contains duplicate id `{}`", rule.id), + })); + } + rule.prepare_runtime()?; + } + Ok(()) + } + + /// Resolve the first enabled asset cache rule that matches `path`. + /// + /// # Errors + /// + /// Returns a configuration error if a lazily prepared matcher unexpectedly + /// fails to compile. + pub fn asset_policy_for_path( + &self, + path: &str, + ) -> Result, Report> { + for rule in &self.asset_rules { + if rule.matches_path(path)? { + return Ok(Some(rule.cache_policy())); + } + } + Ok(None) + } +} + +/// A configurable cache rule for publisher-origin or rehosted static assets. +#[derive(Debug, Default, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct CacheAssetRule { + /// Stable operator-facing identifier for logs/tests/config errors. + pub id: String, + /// Whether this rule participates in matching. + #[serde(default)] + pub enabled: bool, + /// Built-in framework/static preset matcher. + #[serde(default)] + pub preset: Option, + /// Raw path prefix matcher. + #[serde(default)] + pub path_prefix: Option, + /// Single glob matcher retained for concise configs. + #[serde(default)] + pub path_glob: Option, + /// Multiple glob matchers. + #[serde(default)] + pub path_globs: Vec, + /// Regex matcher applied to the request path. + #[serde(default)] + pub path_regex: Option, + /// File extensions matched against the request path, case-insensitively. + #[serde(default)] + pub extensions: Vec, + /// Require a hash-like token in the final path segment before the rule matches. + #[serde(default)] + pub requires_hash_in_filename: bool, + /// Browser-facing cache visibility. + #[serde(default)] + pub visibility: CachePolicyVisibility, + /// Browser cache TTL rendered as `max-age`. + #[serde(default)] + pub browser_ttl_seconds: Option, + /// Shared edge cache TTL rendered as runtime-specific edge control. + #[serde(default)] + pub edge_ttl_seconds: Option, + /// Optional stale-while-revalidate duration. + #[serde(default)] + pub stale_while_revalidate_seconds: Option, + /// Optional stale-if-error duration. + #[serde(default)] + pub stale_if_error_seconds: Option, + /// Whether browser caches may treat the response as immutable. + #[serde(default)] + pub immutable: bool, + #[serde(skip)] + compiled_regex: OnceLock>, + #[serde(skip)] + compiled_globs: OnceLock, String>>, +} + +impl CacheAssetRule { + fn normalize(&mut self) { + self.id = self.id.trim().to_string(); + self.path_prefix = self + .path_prefix + .take() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()); + self.path_glob = self + .path_glob + .take() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()); + self.path_globs = self + .path_globs + .iter() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .collect(); + self.path_regex = self + .path_regex + .take() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()); + self.extensions = self + .extensions + .iter() + .map(|value| value.trim().trim_start_matches('.').to_ascii_lowercase()) + .filter(|value| !value.is_empty()) + .collect(); + } + + fn prepare_runtime(&self) -> Result<(), Report> { + self.validate_matcher_shape()?; + self.compiled_regex().map(|_| ())?; + self.compiled_globs().map(|_| ())?; + Ok(()) + } + + fn validate_matcher_shape(&self) -> Result<(), Report> { + if self.path_glob.is_some() && !self.path_globs.is_empty() { + return Err(Report::new(TrustedServerError::Configuration { + message: format!( + "cache.asset_rules `{}` must use path_glob or path_globs, not both", + self.id + ), + })); + } + + let matcher_count = usize::from(self.preset.is_some()) + + usize::from(self.path_prefix.is_some()) + + usize::from(self.path_glob.is_some() || !self.path_globs.is_empty()) + + usize::from(self.path_regex.is_some()) + + usize::from(!self.extensions.is_empty()); + + if matcher_count != 1 { + return Err(Report::new(TrustedServerError::Configuration { + message: format!( + "cache.asset_rules `{}` must configure exactly one matcher", + self.id + ), + })); + } + Ok(()) + } + + fn compiled_regex(&self) -> Result, Report> { + let Some(pattern) = self.path_regex.as_deref() else { + return Ok(None); + }; + match self + .compiled_regex + .get_or_init(|| Regex::new(pattern).map_err(|err| err.to_string())) + { + Ok(regex) => Ok(Some(regex)), + Err(message) => Err(Report::new(TrustedServerError::Configuration { + message: format!( + "cache.asset_rules `{}` path_regex `{pattern}` failed to compile: {message}", + self.id + ), + })), + } + } + + fn compiled_globs(&self) -> Result, Report> { + if self.path_glob.is_none() && self.path_globs.is_empty() { + return Ok(None); + } + + match self.compiled_globs.get_or_init(|| { + if let Some(glob) = self.path_glob.as_deref() { + Pattern::new(glob) + .map(|pattern| vec![pattern]) + .map_err(|err| err.to_string()) + } else { + self.path_globs + .iter() + .map(|pattern| Pattern::new(pattern).map_err(|err| err.to_string())) + .collect() + } + }) { + Ok(patterns) => Ok(Some(patterns.as_slice())), + Err(message) => Err(Report::new(TrustedServerError::Configuration { + message: format!( + "cache.asset_rules `{}` glob matcher failed to compile: {message}", + self.id + ), + })), + } + } + + fn matches_path(&self, path: &str) -> Result> { + if !self.enabled { + return Ok(false); + } + if self.requires_hash_in_filename && !filename_contains_hash(path) { + return Ok(false); + } + + if let Some(preset) = self.preset { + return Ok(preset.matches_path(path)); + } + if let Some(prefix) = self.path_prefix.as_deref() { + return Ok(path.starts_with(prefix)); + } + if let Some(patterns) = self.compiled_globs()? { + return Ok(patterns.iter().any(|pattern| pattern.matches(path))); + } + if let Some(regex) = self.compiled_regex()? { + return Ok(regex.is_match(path)); + } + if !self.extensions.is_empty() { + return Ok(path_extension(path).is_some_and(|extension| { + self.extensions + .iter() + .any(|candidate| candidate == &extension) + })); + } + Ok(false) + } + + fn cache_policy(&self) -> CachePolicy { + CachePolicy { + visibility: self.visibility.into(), + browser_ttl: self.browser_ttl_seconds.map(Duration::from_secs), + edge_ttl: self.edge_ttl_seconds.map(Duration::from_secs), + stale_while_revalidate: self.stale_while_revalidate_seconds.map(Duration::from_secs), + stale_if_error: self.stale_if_error_seconds.map(Duration::from_secs), + immutable: self.immutable, + } + } +} + +/// Built-in cache-rule presets that operators can enable explicitly. +#[derive(Debug, Clone, Copy, Deserialize, Serialize, Eq, PartialEq)] +#[serde(rename_all = "kebab-case")] +pub enum CacheAssetPreset { + /// Next.js build output under `/_next/static/`. + #[serde(rename = "nextjs-static")] + NextJsStatic, +} + +impl CacheAssetPreset { + fn matches_path(self, path: &str) -> bool { + match self { + Self::NextJsStatic => path.starts_with("/_next/static/"), + } + } +} + +/// Cache visibility parsed from operator configuration. +#[derive(Debug, Default, Clone, Copy, Deserialize, Serialize, Eq, PartialEq)] +#[serde(rename_all = "kebab-case")] +pub enum CachePolicyVisibility { + /// Public browser/cache visibility. + #[default] + Public, + /// Private browser visibility. + Private, +} + +impl From for CacheVisibility { + fn from(value: CachePolicyVisibility) -> Self { + match value { + CachePolicyVisibility::Public => Self::Public, + CachePolicyVisibility::Private => Self::Private, + } + } +} + +fn path_extension(path: &str) -> Option { + let filename = path.rsplit('/').next()?; + let (_, extension) = filename.rsplit_once('.')?; + (!extension.is_empty()).then(|| extension.to_ascii_lowercase()) +} + +fn filename_contains_hash(path: &str) -> bool { + let filename = path.rsplit('/').next().unwrap_or(path); + filename + .split(['.', '-', '_', '~']) + .any(|segment| segment.len() >= 8 && segment.chars().all(|ch| ch.is_ascii_hexdigit())) +} + /// Debug-only features. All flags default to `false` (off in production). #[derive(Debug, Default, Clone, Deserialize, Serialize)] #[serde(deny_unknown_fields)] @@ -1937,6 +2256,9 @@ pub struct Settings { #[serde(default)] pub consent: ConsentConfig, #[serde(default)] + #[validate(nested)] + pub cache: CacheSettings, + #[serde(default)] pub proxy: Proxy, #[serde(default)] pub creative_opportunities: Option, @@ -2019,6 +2341,8 @@ impl Settings { mut settings: Self, validation_label: &str, ) -> Result> { + settings.integrations.normalize(); + settings.cache.normalize(); settings.proxy.normalize(); settings.image_optimizer.normalize(); settings.consent.validate(); @@ -2052,6 +2376,7 @@ impl Settings { /// opportunity slot is invalid. pub fn prepare_runtime(&mut self) -> Result<(), Report> { self.image_optimizer.prepare_runtime()?; + self.cache.prepare_runtime()?; self.proxy.prepare_runtime()?; self.tinybird.prepare_runtime()?; self.validate_asset_image_optimizer_profile_sets()?; @@ -2167,6 +2492,18 @@ impl Settings { Ok(()) } + /// Resolve the first matching configured asset cache policy for the request path. + /// + /// # Errors + /// + /// Returns a configuration error if matcher preparation unexpectedly fails. + pub fn asset_cache_policy_for_path( + &self, + path: &str, + ) -> Result, Report> { + self.cache.asset_policy_for_path(path) + } + /// Resolve the longest matching asset route for the request path. #[must_use] pub fn asset_route_for_path(&self, path: &str) -> Option<&ProxyAssetRoute> { @@ -2729,6 +3066,143 @@ mod tests { ); } + #[test] + fn cache_asset_rule_nextjs_preset_is_operator_controlled() { + let toml_str = format!( + r#"{} + + [[cache.asset_rules]] + id = "nextjs-static" + enabled = true + preset = "nextjs-static" + visibility = "public" + browser_ttl_seconds = 31536000 + edge_ttl_seconds = 31536000 + immutable = true + "#, + crate_test_settings_str() + ); + let settings = Settings::from_toml(&toml_str).expect("should parse cache asset rule"); + + let policy = settings + .asset_cache_policy_for_path("/_next/static/chunks/app.js") + .expect("should evaluate cache rules") + .expect("should match enabled Next.js preset"); + assert_eq!( + policy, + CachePolicy::public_immutable(Duration::from_secs(31_536_000)), + "enabled preset should produce immutable static policy" + ); + + let disabled_toml = toml_str.replace("enabled = true", "enabled = false"); + let disabled_settings = + Settings::from_toml(&disabled_toml).expect("should parse disabled cache asset rule"); + assert!( + disabled_settings + .asset_cache_policy_for_path("/_next/static/chunks/app.js") + .expect("should evaluate disabled cache rules") + .is_none(), + "disabled preset must not mark framework paths immutable" + ); + } + + #[test] + fn cache_asset_rule_requires_hash_in_filename_when_configured() { + let toml_str = format!( + r#"{} + + [[cache.asset_rules]] + id = "publisher-assets" + enabled = true + path_globs = ["/assets/**/*.js"] + requires_hash_in_filename = true + visibility = "public" + browser_ttl_seconds = 31536000 + edge_ttl_seconds = 31536000 + immutable = true + "#, + crate_test_settings_str() + ); + let settings = Settings::from_toml(&toml_str).expect("should parse cache asset rule"); + + assert!( + settings + .asset_cache_policy_for_path("/assets/app.js") + .expect("should evaluate cache rules") + .is_none(), + "broad allowlist should not match non-fingerprinted files when hash is required" + ); + assert_eq!( + settings + .asset_cache_policy_for_path("/assets/app.0123abcd.js") + .expect("should evaluate cache rules"), + Some(CachePolicy::public_immutable(Duration::from_secs( + 31_536_000 + ))), + "fingerprinted asset should match the allowlist" + ); + } + + #[test] + fn cache_asset_rule_validation_rejects_invalid_config() { + let duplicate_ids = format!( + r#"{} + + [[cache.asset_rules]] + id = "duplicate" + enabled = true + path_prefix = "/assets/" + + [[cache.asset_rules]] + id = "duplicate" + enabled = true + path_prefix = "/static/" + "#, + crate_test_settings_str() + ); + let duplicate_err = + Settings::from_toml(&duplicate_ids).expect_err("should reject duplicate rule ids"); + assert!( + format!("{duplicate_err:?}").contains("duplicate id"), + "should explain duplicate rule id: {duplicate_err:?}" + ); + + let invalid_regex = format!( + r#"{} + + [[cache.asset_rules]] + id = "bad-regex" + enabled = true + path_regex = "[" + "#, + crate_test_settings_str() + ); + let regex_err = + Settings::from_toml(&invalid_regex).expect_err("should reject invalid regex"); + assert!( + format!("{regex_err:?}").contains("path_regex"), + "should explain invalid regex: {regex_err:?}" + ); + + let invalid_shape = format!( + r#"{} + + [[cache.asset_rules]] + id = "too-many-matchers" + enabled = true + path_prefix = "/assets/" + extensions = ["js"] + "#, + crate_test_settings_str() + ); + let shape_err = + Settings::from_toml(&invalid_shape).expect_err("should reject invalid matcher shape"); + assert!( + format!("{shape_err:?}").contains("exactly one matcher"), + "should explain invalid matcher shape: {shape_err:?}" + ); + } + #[test] fn validate_rejects_trailing_slash_in_origin_url() { let toml_str = crate_test_settings_str().replace( diff --git a/crates/trusted-server-core/src/tsjs.rs b/crates/trusted-server-core/src/tsjs.rs index 133e6d011..9f19c62fd 100644 --- a/crates/trusted-server-core/src/tsjs.rs +++ b/crates/trusted-server-core/src/tsjs.rs @@ -1,4 +1,4 @@ -use trusted_server_js::{all_module_ids, concatenated_hash, single_module_hash}; +use trusted_server_js::{concatenated_hash, single_module_hash}; /// `/static` URL for the tsjs bundle with cache-busting hash based on /// the concatenated content of the given module set. @@ -17,25 +17,28 @@ pub fn tsjs_script_tag(module_ids: &[&str]) -> String { ) } -/// `/static` URL for the unified bundle with a conservative cache-busting hash. +/// `/static` URL for the unified bundle when exact module IDs are unavailable. /// -/// Hashes all compiled module IDs so the cache invalidates whenever any module -/// changes. Over-invalidates slightly (includes deferred modules in the hash) -/// but never serves stale content. Use [`tsjs_script_src`] with exact module -/// IDs when `IntegrationRegistry` is available. +/// This intentionally omits `?v=` because the serving path can only mark a URL +/// immutable when the hash matches the exact enabled module set. Use +/// [`tsjs_script_src`] with exact module IDs when [`IntegrationRegistry`] is +/// available. +/// +/// [`IntegrationRegistry`]: crate::integrations::IntegrationRegistry #[must_use] pub fn tsjs_unified_script_src() -> String { - let ids = all_module_ids(); - tsjs_script_src(&ids) + "/static/tsjs=tsjs-unified.min.js".to_string() } -/// `", + tsjs_unified_script_src() + ) } /// `/static` URL for one module with its own cache-busting hash. @@ -171,18 +174,17 @@ mod tests { } #[test] - fn tsjs_unified_helpers_use_all_module_ids() { - let ids = all_module_ids(); + fn tsjs_unified_helpers_use_unversioned_fallback_without_registry() { + let src = tsjs_unified_script_src(); assert_eq!( - tsjs_unified_script_src(), - tsjs_script_src(&ids), - "should hash all module IDs for the unified script source" + src, "/static/tsjs=tsjs-unified.min.js", + "registry-free unified helper should not emit an unverifiable hash" ); assert_eq!( tsjs_unified_script_tag(), - tsjs_script_tag(&ids), - "should wrap the all-module unified script source" + format!(r#""#), + "should wrap the registry-free unified source" ); } @@ -246,14 +248,13 @@ mod tests { } #[test] - fn tsjs_unified_script_src_and_tag_include_cache_busting_hash() { + fn tsjs_unified_script_src_and_tag_omit_unverifiable_cache_busting_hash() { let src = tsjs_unified_script_src(); - assert!( - src.starts_with("/static/tsjs=tsjs-unified.min.js?v="), - "should include unified script URL prefix" + assert_eq!( + src, "/static/tsjs=tsjs-unified.min.js", + "should use the unified script URL without an unverifiable hash" ); - assert_sha256_hex_hash(hash_query_value(&src)); assert_eq!( tsjs_unified_script_tag(), format!(r#""#), diff --git a/crates/trusted-server-js/Cargo.toml b/crates/trusted-server-js/Cargo.toml index f3af9bfcf..67a4ac698 100644 --- a/crates/trusted-server-js/Cargo.toml +++ b/crates/trusted-server-js/Cargo.toml @@ -15,10 +15,11 @@ workspace = true doctest = false name = "trusted_server_js" path = "src/lib.rs" -test = false [build-dependencies] build-print = { workspace = true } +hex = { workspace = true } +sha2 = { workspace = true } which = { workspace = true } [dependencies] diff --git a/crates/trusted-server-js/build.rs b/crates/trusted-server-js/build.rs index 6d6bdde9f..ba6cd88f2 100644 --- a/crates/trusted-server-js/build.rs +++ b/crates/trusted-server-js/build.rs @@ -12,6 +12,7 @@ use std::path::{Path, PathBuf}; use std::process::{Command, ExitStatus}; use build_print::{info, warn}; +use sha2::{Digest as _, Sha256}; fn main() { // Rebuild if TS sources change (belt-and-suspenders): enumerate every file under lib/ @@ -125,7 +126,7 @@ fn main() { // Copy each module file to OUT_DIR for (_, filename) in &modules { - copy_bundle(filename, true, &crate_dir, &dist_dir, &out_dir); + copy_bundle(filename, true, &dist_dir, &out_dir); } // Generate tsjs_modules.rs with include_str!() for each module @@ -139,9 +140,10 @@ fn main() { ) .expect("should write generated module header"); for (id, filename) in &modules { + let sha256 = bundle_sha256(&out_dir.join(filename)); writeln!( codegen, - " TsjsModuleMeta {{\n bundle: include_str!(concat!(env!(\"OUT_DIR\"), \"/{filename}\")),\n id: \"{id}\",\n }},\n" + " TsjsModuleMeta {{\n bundle: include_str!(concat!(env!(\"OUT_DIR\"), \"/{filename}\")),\n id: \"{id}\",\n sha256: \"{sha256}\",\n }},\n" ) .expect("should write generated module entry"); } @@ -149,6 +151,7 @@ fn main() { codegen.push_str("\npub(crate) struct TsjsModuleMeta {\n"); codegen.push_str(" pub bundle: &'static str,\n"); codegen.push_str(" pub id: &'static str,\n"); + codegen.push_str(" pub sha256: &'static str,\n"); codegen.push_str("}\n"); let generated_path = out_dir.join("tsjs_modules.rs"); @@ -160,30 +163,36 @@ fn main() { }); } -fn copy_bundle(filename: &str, required: bool, crate_dir: &Path, dist_dir: &Path, out_dir: &Path) { - let primary = dist_dir.join(filename); - let fallback = crate_dir.join("dist").join(filename); +fn bundle_sha256(path: &Path) -> String { + let content = fs::read(path).unwrap_or_else(|err| { + panic!( + "tsjs: failed to read copied bundle {} for hashing: {err}", + path.display() + ); + }); + hex::encode(Sha256::digest(&content)) +} + +fn copy_bundle(filename: &str, required: bool, dist_dir: &Path, out_dir: &Path) { + let source = dist_dir.join(filename); let target = out_dir.join(filename); - for source in [&primary, &fallback] { - if source.exists() { - if let Err(err) = fs::copy(source, &target) { - assert!( - !required, - "tsjs: failed to copy {} to {}: {err}", - source.display(), - target.display() - ); - } - return; + if source.exists() { + if let Err(err) = fs::copy(&source, &target) { + assert!( + !required, + "tsjs: failed to copy {} to {}: {err}", + source.display(), + target.display() + ); } + return; } assert!( !required, - "tsjs: bundle {filename} not found: {} (and fallback {}). Ensure Node is installed and `npm run build` succeeds, or commit dist/{filename}.", - primary.display(), - fallback.display() + "tsjs: bundle {filename} not found: {}. Ensure Node is installed and `npm run build` succeeds, or commit dist/{filename}.", + source.display() ); fs::write(&target, "").expect("should write optional empty bundle placeholder"); diff --git a/crates/trusted-server-js/src/bundle.rs b/crates/trusted-server-js/src/bundle.rs index 7ddc060ab..83815654a 100644 --- a/crates/trusted-server-js/src/bundle.rs +++ b/crates/trusted-server-js/src/bundle.rs @@ -1,5 +1,5 @@ use std::collections::HashMap; -use std::sync::OnceLock; +use std::sync::{Mutex, MutexGuard, OnceLock}; use hex::encode; use sha2::{Digest as _, Sha256}; @@ -10,7 +10,7 @@ include!(concat!(env!("OUT_DIR"), "/tsjs_modules.rs")); #[must_use] #[inline] pub fn module_bundle(id: &str) -> Option<&'static str> { - module_map().get(id).copied() + module_meta_map().get(id).map(|module| module.bundle) } /// Return all available module IDs, in discovery order (core first). @@ -27,56 +27,159 @@ pub fn all_module_ids() -> Vec<&'static str> { #[must_use] #[inline] pub fn concatenate_modules(ids: &[&str]) -> String { - let map = module_map(); - let mut parts: Vec<&str> = Vec::new(); + let ordered_ids = concatenated_module_ids(ids); + let mut body = String::new(); + visit_concatenated_module_parts(&ordered_ids, |part| body.push_str(part)); + body +} + +/// SHA-256 hash of the concatenated modules, for cache-busting URLs. +/// +/// The hash is computed over the same byte sequence as [`concatenate_modules`] +/// without allocating that concatenated body. Results are cached by ordered +/// module ID list so HTML injection does not re-hash the full JS payload on +/// every page view. +#[must_use] +#[inline] +pub fn concatenated_hash(ids: &[&str]) -> String { + let key = concatenated_module_ids(ids); + if let Some(hash) = lock_concatenated_hash_cache().get(&key).cloned() { + return hash; + } + + let hash = hash_concatenated_modules(&key); + lock_concatenated_hash_cache().insert(key, hash.clone()); + hash +} + +/// SHA-256 hash of a single module's content (without prepending core). +/// +/// Used for cache-busting URLs of deferred modules served individually. +#[must_use] +#[inline] +pub fn single_module_hash(id: &str) -> Option<&'static str> { + module_meta_map().get(id).map(|module| module.sha256) +} + +fn concatenated_module_ids(ids: &[&str]) -> Vec<&'static str> { + let map = module_meta_map(); + let mut ordered = Vec::new(); - // Core always first if let Some(core) = map.get("core") { - parts.push(core); + ordered.push(core.id); } - // Then requested modules (excluding core, already included) for id in ids { if *id == "core" { continue; } - if let Some(bundle) = map.get(id) { - parts.push(bundle); + if let Some(module) = map.get(*id) { + ordered.push(module.id); } } - parts.join(";\n") + ordered } -/// SHA-256 hash of the concatenated modules, for cache-busting URLs. -#[must_use] -#[inline] -pub fn concatenated_hash(ids: &[&str]) -> String { - let body = concatenate_modules(ids); +fn hash_concatenated_modules(ids: &[&'static str]) -> String { let mut hasher = Sha256::new(); - hasher.update(body.as_bytes()); + visit_concatenated_module_parts(ids, |part| hasher.update(part.as_bytes())); encode(hasher.finalize()) } -/// SHA-256 hash of a single module's content (without prepending core). -/// -/// Used for cache-busting URLs of deferred modules served individually. -#[must_use] -#[inline] -pub fn single_module_hash(id: &str) -> Option { - module_bundle(id).map(|content| { - let mut hasher = Sha256::new(); - hasher.update(content.as_bytes()); - encode(hasher.finalize()) - }) +fn visit_concatenated_module_parts(ids: &[&'static str], mut visit: F) +where + F: FnMut(&'static str), +{ + let map = module_meta_map(); + let mut first = true; + + for id in ids { + let Some(module) = map.get(*id) else { + continue; + }; + if first { + first = false; + } else { + visit(";\n"); + } + visit(module.bundle); + } } -fn module_map() -> &'static HashMap<&'static str, &'static str> { - static MAP: OnceLock> = OnceLock::new(); +fn module_meta_map() -> &'static HashMap<&'static str, &'static TsjsModuleMeta> { + static MAP: OnceLock> = OnceLock::new(); MAP.get_or_init(|| { TSJS_MODULES .iter() - .map(|module| (module.id, module.bundle)) + .map(|module| (module.id, module)) .collect() }) } + +fn lock_concatenated_hash_cache() -> MutexGuard<'static, HashMap, String>> { + match concatenated_hash_cache().lock() { + Ok(guard) => guard, + Err(poisoned) => poisoned.into_inner(), + } +} + +fn concatenated_hash_cache() -> &'static Mutex, String>> { + static CACHE: OnceLock, String>>> = OnceLock::new(); + CACHE.get_or_init(|| Mutex::new(HashMap::new())) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sha256_hex(bytes: &[u8]) -> String { + encode(Sha256::digest(bytes)) + } + + #[test] + fn generated_single_module_hashes_match_bundle_contents() { + for id in all_module_ids() { + let bundle = module_bundle(id).expect("should have module bundle"); + let generated_hash = single_module_hash(id).expect("should have generated hash"); + + assert_eq!( + generated_hash, + sha256_hex(bundle.as_bytes()), + "generated hash for module {id} should match included bundle bytes" + ); + } + } + + #[test] + fn concatenated_hash_matches_concatenated_bundle_contents() { + let available_ids = all_module_ids(); + let non_core_ids = available_ids + .iter() + .copied() + .filter(|id| *id != "core") + .take(3) + .collect::>(); + + let mut cases: Vec> = vec![Vec::new()]; + if let Some(first) = non_core_ids.first().copied() { + cases.push(vec![first]); + } + if non_core_ids.len() >= 2 { + cases.push(non_core_ids[..2].to_vec()); + cases.push(non_core_ids[..2].iter().rev().copied().collect()); + } + if non_core_ids.len() >= 3 { + cases.push(non_core_ids[..3].to_vec()); + } + + for ids in cases { + let concatenated = concatenate_modules(&ids); + assert_eq!( + concatenated_hash(&ids), + sha256_hex(concatenated.as_bytes()), + "concatenated hash should match concatenated bundle bytes for {ids:?}" + ); + } + } +} diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index ddb6544ce..9c59448fd 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -72,6 +72,7 @@ fail and the service will return its startup-error response. | `[ec]` | Edge Cookie (EC) ID generation | | `[tester_cookie]` | Optional tester-cookie endpoint | | `[proxy]` | Proxy SSRF allowlist and asset routes | +| `[cache]` | Static/rehosted asset cache policy rules | | `[image_optimizer]` | Reusable Image Optimizer profile sets | | `[request_signing]` | Ed25519 request signing | | `[auction]` | Auction orchestration | @@ -1031,6 +1032,78 @@ when_missing = "smart" See [Asset Routes](/guide/asset-routes) for request flow, S3 auth details, and Image Optimizer behavior. +## Cache Configuration + +Static and rehosted asset cache upgrades are operator-controlled. By default, +Trusted Server leaves arbitrary publisher-origin assets under origin cache +control. Add `[[cache.asset_rules]]` entries only for paths that are known to be +content-addressed or otherwise safe for the configured TTL. + +### `[[cache.asset_rules]]` + +Rules are evaluated in file order; the first enabled matching rule wins. +Disabled rules are ignored, which lets you keep framework presets documented in +config without enabling them for every publisher. + +| Field | Type | Required | Description | +| ---------------------------------- | ------------- | -------- | ----------------------------------------------------------------- | +| `id` | String | Yes | Unique operator-facing rule identifier | +| `enabled` | Boolean | No | Whether the rule participates in matching (default `false`) | +| `preset` | String | Matcher | Built-in preset such as `nextjs-static` | +| `path_prefix` | String | Matcher | Request path prefix | +| `path_glob` | String | Matcher | Single glob matched against the request path | +| `path_globs` | Array[String] | Matcher | Multiple globs matched against the request path | +| `path_regex` | String | Matcher | Regex matched against the request path | +| `extensions` | Array[String] | Matcher | Case-insensitive file extensions | +| `requires_hash_in_filename` | Boolean | No | Require an 8+ hex token in the final path segment before matching | +| `visibility` | String | No | `public` or `private` (default `public`) | +| `browser_ttl_seconds` | Integer | No | Browser `max-age` | +| `edge_ttl_seconds` | Integer | No | Edge/shared-cache TTL | +| `stale_while_revalidate_seconds` | Integer | No | Optional `stale-while-revalidate` | +| `stale_if_error_seconds` | Integer | No | Optional `stale-if-error` | +| `immutable` | Boolean | No | Add `immutable` when browser TTL is positive | + +Exactly one matcher must be configured per rule. `path_glob` and `path_globs` +are mutually exclusive. + +**Next.js preset example** (disabled until the publisher confirms +`/_next/static/` is content-addressed): + +```toml +[[cache.asset_rules]] +id = "nextjs-static" +enabled = false +preset = "nextjs-static" +visibility = "public" +browser_ttl_seconds = 31536000 +edge_ttl_seconds = 31536000 +immutable = true +``` + +**Publisher allowlist example**: + +```toml +[[cache.asset_rules]] +id = "publisher-fingerprinted-assets" +enabled = true +path_globs = [ + "/assets/**/*.js", + "/assets/**/*.css", + "/assets/**/*.png", + "/assets/**/*.webp", +] +requires_hash_in_filename = true +visibility = "public" +browser_ttl_seconds = 31536000 +edge_ttl_seconds = 31536000 +immutable = true +``` + +If `[cache]` is omitted or no enabled rule matches, Trusted Server preserves the +origin cache policy for publisher-origin assets. TS-owned validated hash URLs, +such as `/static/tsjs=...js?v=`, use their built-in cache policy and do +not require an asset rule. + ## Integration Configurations Settings for built-in integrations (Prebid, Next.js, Osano, Permutive, Testlight). For other diff --git a/docs/superpowers/plans/2026-07-06-cache-control-header-implementation-plan.md b/docs/superpowers/plans/2026-07-06-cache-control-header-implementation-plan.md new file mode 100644 index 000000000..f156aa768 --- /dev/null +++ b/docs/superpowers/plans/2026-07-06-cache-control-header-implementation-plan.md @@ -0,0 +1,446 @@ +# Cache-Control Header Strategy Implementation Plan + +**Date:** 2026-07-06 +**Status:** Initial cache-header slice implemented in the current branch +**Spec:** `docs/superpowers/specs/2026-07-06-cache-control-header-design.md` + +## Scope + +Implement the **initial cache-header slice** from the current spec. The latest +spec resolves the initial-slice open questions and defers the larger dynamic +caching, template caching, streaming, and compression-offload work. + +Initial slice goals: + +1. Make TS-owned, hash-versioned TSJS responses cache correctly. +2. Make neutralized publisher Prebid compatibility responses safe to cache. +3. Add a structured, runtime-portable cache-policy model. +4. Add a configurable static/rehosted asset cache-rule engine so framework + assumptions are operator-controlled, not hard-coded. +5. Keep arbitrary publisher-origin assets origin-controlled unless an enabled + rule proves they are immutable-safe. + +Deferred follow-up features are listed separately below and should not be folded +into the initial cache-header PRs. + +## Decisions locked for the initial slice + +- SSAT-assembled HTML remains `Cache-Control: private, max-age=0` and strips + runtime edge-cache headers (`Surrogate-Control`, `Fastly-Surrogate-Control`, + `CDN-Cache-Control`, and `Cloudflare-CDN-Cache-Control`) whenever the ad stack + can inject per-user slot/bid state. +- TSJS keeps the current `/static/tsjs=...js?v=` canonical URL shape. + Matching hash/version requests receive immutable cache headers; missing or + mismatched hash/version requests keep short TTLs rather than redirecting. +- Runtime cache-key configuration must preserve the `v` query parameter for + `/static/tsjs=`. Fastly and Cloudflare include query strings in default cache + keys, but project-specific query normalization must not drop `v`. +- Framework-specific immutable paths, including Next.js `/_next/static/*`, must + be represented as configurable cache-rule presets. Do not add adapter- or + proxy-level hard-coded framework path checks. +- Operators decide which framework presets and publisher allowlists are enabled. + Arbitrary publisher CSS/JS/images remain origin-controlled unless an enabled + cache rule proves they are immutable-safe. +- TS-owned Prebid delivery is covered by deferred TSJS module URLs. Publisher + Prebid script URLs neutralized by TS are compatibility shims at stable URLs and + must use `no-store` or a very short TTL, not a year-long immutable policy. +- Rehosted assets are TS-owned copies once TS rewrites/hosts them. They should + use explicit normalized policies, with immutable only for TS-fingerprinted + rehosted URLs. +- Fastly and Cloudflare are the MVP runtime targets. Akamai mapping is deferred + until Akamai is on the roadmap. +- Dynamic HTML/RSC/API caching, dynamic `Vary`/cache-key normalization, + origin-template caching, transformed-template caching, true publisher-origin + streaming, parser-context bid splice, EdgeZero streaming parity, and SSAT HTML + compression offload are deferred follow-up features. +- All personalized/cookie-bearing response hardening in `response_privacy.rs` and + adapter middleware stays in place and runs after any new policy application. + +## Original baseline before this implementation + +| Area | Current file(s) | Baseline | +| ---------------------------- | --------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | +| TSJS URL injection | `crates/trusted-server-core/src/tsjs.rs` | Injects `/static/tsjs=...js?v=`; current branch is moving hash work out of the hot path. | +| TSJS serving | `publisher.rs`, `http_util.rs` | Historically served through `serve_static_with_etag` with 5-minute browser/edge TTLs. | +| Cache policy primitives | `cache_policy.rs` | Current branch adds typed policy rendering; still needs final alignment with no-store and config rules. | +| Neutralized Prebid shim | `crates/trusted-server-core/src/integrations/prebid.rs` | `handle_script_handler` currently returns an empty JS shim with `public, max-age=31536000`; this must be changed. | +| Cache privacy | `publisher.rs`, `response_privacy.rs`, adapter middleware | Ad-stack HTML and cookie-bearing responses are downgraded to private/shared-uncacheable. | +| Rehosted asset cache policy | `proxy.rs` | Policy is effectively origin-controlled or `no-store, private`; no normalized immutable/SWR policy. | +| Dynamic HTML/RSC/API caching | none | Deferred. No initial-slice `Vary` rewriting or Next router-header special casing. | +| Origin-template cache | none | Deferred. No cache API/override/template key/surrogate-key implementation exists. | + +## Definition of done for the initial slice + +- TSJS hash-version-matching requests emit one-year immutable browser cache and + one-year edge cache headers. +- TSJS missing/mismatched hash requests keep short TTL behavior. +- TSJS injected hash generation no longer concatenates and hashes the full + bundle on every page view. +- Neutralized publisher Prebid shim responses use `no-store` or a very short TTL. +- Cache policy is represented as structured data and can emit Fastly + `Surrogate-Control`, generic `CDN-Cache-Control`, Cloudflare-specific + `Cloudflare-CDN-Cache-Control`, and `s-maxage` fallback headers. +- Cache policy can represent `no-store`/uncacheable responses as well as public + and private TTL policies. +- TS config expresses static/rehosted cache policy through structured rules with + match criteria, policy fields, and `enabled` flags. +- Built-in framework presets, including Next.js `/_next/static/*`, are + implemented through the shared rule engine and can be disabled/overridden. +- Arbitrary publisher-origin assets remain origin-controlled unless matched by an + enabled preset or publisher allowlist. +- TS-owned rehosted assets have explicit normalized policies instead of blindly + passing through third-party defaults. +- MVP adapters emit the correct edge-cache header from shared policy: Fastly + `Surrogate-Control`, Cloudflare `CDN-Cache-Control` / + `Cloudflare-CDN-Cache-Control`, or portable `s-maxage` fallback. +- Deferred features are documented as deferred and are not accidentally + implemented as hard-coded Next.js/dynamic-cache behavior. +- Tests and target-matched checks pass for touched crates/adapters. + +## Proposed PR sequence + +### PR 1 — Structured cache policy primitives + +Status: implemented in the current branch. + +#### Code changes + +- Keep/add a core module such as `crates/trusted-server-core/src/cache_policy.rs`. +- Define structured policy types: + - `CacheVisibility::{Public, Private}` + - `CachePolicy { visibility, browser_ttl, edge_ttl, stale_while_revalidate, +stale_if_error, immutable }` + - a `no-store` / uncacheable representation, either as a policy mode or a + dedicated helper, so neutralized shims and error responses do not need + ad-hoc strings; + - `EdgeCacheHeader::{SurrogateControl, CdnCacheControl, +CloudflareCdnCacheControl, SMaxageFallback, None}`. +- Add helpers that render policy into headers: + - browser `Cache-Control` + - Fastly `Surrogate-Control` + - generic `CDN-Cache-Control` + - Cloudflare-specific `Cloudflare-CDN-Cache-Control` + - portable `s-maxage` fallback. +- Keep helpers side-effect-limited: they should only mutate cache headers they + own and should not bypass `response_privacy` hardening. When applying private + or no-store policies, remove any existing edge-cache headers owned by the + helper so stale `Surrogate-Control`/CDN cache headers cannot survive. +- Add default policy constructors/constants for: + - immutable static; + - short TSJS fallback; + - neutralized Prebid shim (`no-store` or very short TTL); + - uncacheable private. + +#### Tests + +- Unit-test exact header rendering for immutable, short edge/browser split, + private, no-store, SWR/SIE, generic CDN, Cloudflare-specific CDN, and fallback + `s-maxage` policies. +- Test that `immutable` is omitted when browser TTL is absent or zero. +- Test that edge-header output is disabled for private/no-store responses, and + that applying private/no-store removes any pre-existing edge-cache header the + helper owns. + +### PR 2 — TSJS immutable hash-version serving + +Status: implemented in the current branch with runtime-specific edge-header +selection. + +#### Code changes + +- Extend `crates/trusted-server-js/build.rs` generated metadata with per-module + SHA-256 hashes. +- Update `trusted-server-js/src/bundle.rs`: + - `single_module_hash(id)` returns generated hash instead of hashing content; + - `concatenated_hash(ids)` hashes incrementally without concatenating a full + `String`, or caches the result per normalized module-id set; + - `concatenate_modules(ids)` can remain for serving the response body. +- Update `handle_tsjs_dynamic` in `publisher.rs`: + - parse `?v=` from the request URI; + - compare it with the canonical hash for the requested bundle; + - if it matches, apply immutable static policy plus `Vary: Accept-Encoding`, + ETag, and `X-Compress-Hint: on`; + - if missing/mismatched, keep short TTL policy plus ETag and + `X-Compress-Hint: on`. +- Keep the current canonical path shape (`/static/tsjs=...js?v=`). +- Document/verify that runtime cache-key configuration preserves the `v` query + parameter for `/static/tsjs=`. + +#### Tests + +- `tsjs_script_src` and deferred script tests still produce `?v=`. +- Matching `?v=` returns: + - `Cache-Control: public, max-age=31536000, immutable` + - runtime edge header via policy helper; + - `Vary: Accept-Encoding`; + - ETag. +- Missing/mismatched `?v=` returns short TTL and no `immutable`. +- Deferred disabled module still 404s. +- Hash helpers do not allocate the concatenated body just to hash it. + +### PR 3 — Neutralized publisher Prebid shim cache safety + +Fix the stable publisher Prebid compatibility route separately from TS-owned +Prebid delivery. + +#### Code changes + +- Update `PrebidIntegration::handle_script_handler` in + `crates/trusted-server-core/src/integrations/prebid.rs`. +- Replace the current year-long `public, max-age=31536000` response with either: + - `Cache-Control: no-store`, preferred for compatibility when integration + enablement/config can change; or + - a very short TTL if no-store is too conservative. +- Ensure no `Surrogate-Control`/CDN edge header is emitted for the neutralized + stable URL. +- Keep TS-owned Prebid bundle delivery on the deferred TSJS module path, where + matching `?v=` remains immutable. + +#### Tests + +- Neutralized Prebid script handler returns the empty compatibility script with + `no-store` or the chosen short TTL. +- Neutralized Prebid shim does not emit immutable or year-long cache headers. +- TSJS deferred Prebid still receives immutable headers when `?v=` matches. + +### PR 4 — Configurable static asset cache-rule engine + +Introduce operator-configurable static asset rules before applying immutable +upgrades to publisher-origin assets. + +#### Code changes + +- Add cache-rule settings rather than hard-coded path checks. Suggested shape: + - `CacheAssetRule { id, enabled, matcher, policy }` + - `CacheAssetMatcher::{PathPrefix, Glob, Regex, Extension, Preset}` + - `CacheAssetPreset::NextJsStatic` expands to `/_next/static/*` when enabled. +- Add cache settings under `Settings` (and `trusted-server.example.toml`) with + `#[serde(deny_unknown_fields)]` validation consistent with the rest of the + config model. +- Add a shared rule evaluator with deterministic precedence. Prefer an ordered + rule list where the first enabled match wins; reject duplicate rule IDs and + invalid matcher combinations during settings validation. +- Ship framework presets as data/config defaults or documented examples, not as + special cases in proxy/adapters. +- The Next.js preset may be present in example config, but operators must be able + to disable/override it. Do not silently apply it through a hard-coded branch. +- Support publisher-defined allowlist rules for other frameworks or + publisher-specific fingerprinted paths. +- Apply immutable policy only when an enabled rule/preset says the URL is + content-addressed, or for TS-owned validated hash URLs such as TSJS. + +#### Tests + +- With the Next.js preset enabled, `/_next/static/*` gets immutable policy. +- With the Next.js preset disabled, the same `/_next/static/*` remains + origin-controlled. +- Publisher-defined allowlist rule can mark a non-Next fingerprinted path + immutable. +- Non-matching publisher asset remains origin-controlled. +- Rule precedence is deterministic. +- Invalid regex/glob/config fails validation clearly. + +### PR 5 — MVP runtime edge-header mapping and docs + +Make the shared policy output explicit per runtime before wiring the rule engine +into more routes. This prevents new code from copying the current core +Fastly-specific `Surrogate-Control` behavior. + +#### Code changes + +- Stop requiring core helpers such as `handle_tsjs_dynamic` or + `serve_static_with_etag` to hard-code Fastly's `Surrogate-Control`. +- Choose one adapter boundary pattern and use it consistently: + - pass the runtime `EdgeCacheHeader`/policy emitter into core handlers; or + - return cache-policy metadata in response extensions and let adapters render + runtime-specific headers after route handling. +- Fastly adapter emits `Surrogate-Control` for edge TTLs. +- Cloudflare adapter emits `CDN-Cache-Control` or + `Cloudflare-CDN-Cache-Control`, depending on the chosen adapter convention. +- Portable/local fallback can use `s-maxage` inside `Cache-Control` when no + runtime-specific edge header is available. +- Akamai mapping remains absent/deferred; do not add untested Akamai behavior. +- Update `trusted-server.example.toml` and docs with disabled framework preset + examples and operator-owned allowlist examples. + +#### Tests + +- Fastly TSJS/static policy application emits `Surrogate-Control`. +- Cloudflare TSJS/static policy application emits the selected Cloudflare CDN + cache header and does not emit Fastly-only `Surrogate-Control`. +- Fallback policy emits `s-maxage` only for public/shared-cacheable responses. +- Private/no-store responses remove or avoid all edge-cache headers. + +### PR 6 — Apply static/rehosted policies to proxy responses + +Wire the rule engine into the routes that emit publisher-origin or rehosted +assets, using the runtime edge-header mapping from PR 5. + +#### Code changes + +- Extend `AssetProxyCachePolicy` in `proxy.rs` beyond + `OriginControlled`/`NoStorePrivate`, for example: + - `OriginControlled` + - `NoStorePrivate` + - `Normalized(CachePolicy)` from a matched enabled rule. +- Apply normalized policy after route finalization but before final response + privacy hardening. +- Preserve existing no-store/private handling for errors, signed failures, or + responses that set cookies/security headers. +- Ensure operator `response_headers` cannot weaken protected private/no-store + decisions. +- For TS-owned rehosted copies: + - use immutable only for fingerprinted TS-owned URLs; + - use conservative edge/browser TTLs for stable rehosted URLs; + - keep dynamic/personalized endpoints uncached. + +#### Tests + +- Rehosted/fingerprinted route matched by an enabled rule gets immutable policy. +- Stable rehosted route gets the configured conservative policy, not a borrowed + third-party `no-store` unless configured. +- Rehosted error responses keep `no-store, private`. +- `Set-Cookie` response remains private/no-store and loses surrogate headers. +- Operator response headers cannot re-enable shared caching for protected + responses. + +## Initial config sketch + +Exact names can change during implementation, but keep the shape structured and +operator-controlled. + +```toml +[[cache.asset_rules]] +id = "nextjs-static" +enabled = false # operators may enable for Next.js publishers +preset = "nextjs-static" +visibility = "public" +browser_ttl_seconds = 31536000 +edge_ttl_seconds = 31536000 +immutable = true + +[[cache.asset_rules]] +id = "publisher-fingerprinted-assets-example" +enabled = false +path_globs = [ + "/assets/**/*.js", + "/assets/**/*.css", + "/assets/**/*.png", + "/assets/**/*.jpg", + "/assets/**/*.webp", + "/assets/**/*.avif", +] +requires_hash_in_filename = true +visibility = "public" +browser_ttl_seconds = 31536000 +edge_ttl_seconds = 31536000 +immutable = true + +[cache.tsjs.versioned] +visibility = "public" +browser_ttl_seconds = 31536000 +edge_ttl_seconds = 31536000 +immutable = true + +[cache.tsjs.fallback] +visibility = "public" +browser_ttl_seconds = 300 +edge_ttl_seconds = 300 +stale_while_revalidate_seconds = 60 +stale_if_error_seconds = 86400 + +[cache.prebid_neutralized] +mode = "no-store" +``` + +Defaults should preserve current behavior unless a rule is explicitly enabled or +unless the response is TS-owned and hash-validated, such as TSJS. + +## Deferred follow-up backlog + +These remain valuable, but are intentionally outside the initial cache-header +slice. + +| Follow-up | Why deferred | Notes | +| --------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | +| True publisher-origin streaming | Requires platform/body boundary changes and adapter streaming semantics | Includes avoiding full `take_body_bytes()` materialization on Fastly and documenting/implementing non-Fastly streaming parity. | +| Parser-context bid splice | Requires HTML pipeline redesign | Replace raw `` | `Cache-Control: public, max-age=31536000, immutable` plus runtime edge header | The serving path must validate that `v` matches the bytes served. | +| TSJS missing/mismatched `?v=` | Short TTL or redirect to canonical hashed URL | Do not mark immutable. | +| TSJS deferred modules, including Prebid | Same as TSJS hash-matching policy | Example: `/static/tsjs=tsjs-prebid.min.js?v=`. | +| Publisher Prebid URL neutralized by TS | `no-store` or very short TTL | The empty compatibility shim is config-dependent and served at a stable publisher URL. Do not cache it for a year. | +| Enabled framework preset static, e.g. Next.js `/_next/static/*` | `Cache-Control: public, max-age=31536000, immutable` | Applied through configurable preset/allowlist rules. | +| TS-fingerprinted rehosted asset | `Cache-Control: public, max-age=31536000, immutable` | Safe only when TS owns the fingerprinted URL. | +| Stable TS-owned/rehosted URL | Conservative short browser TTL, optional longer edge TTL | Do not use `immutable`. | +| Arbitrary publisher-origin CSS/JS/images | Origin-controlled by default | TS may upgrade only via enabled framework preset or publisher allowlist. | +| SSAT-assembled ad-stack HTML | `Cache-Control: private, max-age=0`; strip runtime edge-cache headers | Must never enter shared cache because it can contain per-user slot/bid data. | +| Dynamic HTML/RSC/API | Origin-controlled in this slice | Future dynamic caching belongs to #859. | + +## TSJS-specific requirements + +Current TSJS URLs already include a content hash query string, for example: + +```text +/static/tsjs=tsjs-unified.min.js?v= +/static/tsjs=tsjs-prebid.min.js?v= +``` + +The current serving path still emits a short cache policy. Update it so that: + +- hash-matching requests emit one-year immutable browser caching; +- hash-matching requests emit the runtime edge header with equivalent long edge TTL; +- missing or mismatched hash requests do not receive immutable caching; +- cache-key configuration preserves the `v` query parameter; +- TSJS hashes used in injected URLs are generated at build time or cached so HTML injection does not re-concatenate and re-hash large bundles per pageview; +- `Vary: Accept-Encoding` remains on compressed/static responses; +- ETags may remain as a fallback for clients or intermediaries that revalidate anyway. + +Fastly and Cloudflare include query strings in default cache keys, but TS must still avoid any project-specific query normalization that drops `v` for `/static/tsjs=`. + +## SSAT HTML privacy requirement + +SSAT-assembled ad-stack HTML can contain per-user data such as slot state or bid data. It must remain: + +```http +Cache-Control: private, max-age=0 +``` + +and must strip runtime edge-cache headers, including: + +```http +Surrogate-Control +Fastly-Surrogate-Control +CDN-Cache-Control +Cloudflare-CDN-Cache-Control +``` + +This requirement applies to the browser-facing assembled response. Origin-template caching is separate follow-up work in #859. + +## Runtime header mapping for MVP + +Adapters should render the shared policy as follows: + +| Runtime | Edge/shared-cache header | +| ----------------- | ---------------------------------------------------- | +| Fastly | `Surrogate-Control` | +| Cloudflare | `CDN-Cache-Control` / `Cloudflare-CDN-Cache-Control` | +| Portable fallback | `s-maxage` in `Cache-Control` | + +Akamai mapping is deferred until Akamai is on the roadmap. + +## Acceptance criteria + +- [ ] Cache policy is represented as structured fields, not hard-coded header strings. +- [ ] Built-in framework presets, including a disableable/overrideable Next.js `/_next/static/*` rule, are implemented through the shared cache-policy rule engine. +- [ ] TSJS hash-matching requests for unified and deferred modules emit `public, max-age=31536000, immutable` plus the runtime edge header. +- [ ] TSJS missing/mismatched hash requests do not get immutable caching. +- [ ] TSJS hash generation is build-time or cached enough that HTML injection does not re-concatenate/re-hash the bundle per pageview. +- [ ] Runtime cache-key configuration preserves the `v` query parameter for `/static/tsjs=`. +- [ ] Neutralized publisher Prebid shim responses use `no-store` or a short TTL, not a year-long policy. +- [ ] Arbitrary publisher-origin assets remain origin-controlled unless covered by an enabled framework preset or publisher allowlist. +- [ ] TS-owned rehosted assets have explicit normalized cache policy; immutable is used only for TS-fingerprinted rehosted URLs. +- [ ] SSAT-assembled ad-stack HTML continues to emit `private, max-age=0` and strips all runtime edge-cache headers. +- [ ] Fastly and Cloudflare adapters emit the correct edge-cache header from the shared policy, with portable `s-maxage` fallback where needed. +- [ ] Dynamic HTML/RSC/API Vary/cache-key normalization is not hard-coded in this PR and is deferred to #859. +- [ ] SSAT streaming fixes and compression offload are not included in this PR and remain tracked by #857 and #858. diff --git a/trusted-server.example.toml b/trusted-server.example.toml index 19ecda4a5..2b46336ab 100644 --- a/trusted-server.example.toml +++ b/trusted-server.example.toml @@ -116,6 +116,27 @@ enabled = false # Required for integrations.prebid.external_bundle_url and first-party proxy redirects. # allowed_domains = ["ads.example.com", "assets.example.com", "*.cdn.example.com"] +# Static/rehosted asset cache policies are operator-controlled. Keep framework +# presets disabled unless the matched publisher paths are known content-addressed. +# [[cache.asset_rules]] +# id = "nextjs-static" +# enabled = false +# preset = "nextjs-static" +# visibility = "public" +# browser_ttl_seconds = 31536000 +# edge_ttl_seconds = 31536000 +# immutable = true +# +# [[cache.asset_rules]] +# id = "publisher-fingerprinted-assets" +# enabled = false +# path_globs = ["/assets/**/*.js", "/assets/**/*.css", "/assets/**/*.png", "/assets/**/*.webp"] +# requires_hash_in_filename = true +# visibility = "public" +# browser_ttl_seconds = 31536000 +# edge_ttl_seconds = 31536000 +# immutable = true + [auction] enabled = false # Defaults to true. Keep this leaf present when using the EdgeZero v0.0.4 From d6328f42929d7aeade9574afefa288eca24ba26b Mon Sep 17 00:00:00 2001 From: Christian Date: Wed, 8 Jul 2026 12:13:01 -0500 Subject: [PATCH 2/8] Format cache configuration docs --- docs/guide/configuration.md | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 9c59448fd..258a9a4bc 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -1045,23 +1045,23 @@ Rules are evaluated in file order; the first enabled matching rule wins. Disabled rules are ignored, which lets you keep framework presets documented in config without enabling them for every publisher. -| Field | Type | Required | Description | -| ---------------------------------- | ------------- | -------- | ----------------------------------------------------------------- | -| `id` | String | Yes | Unique operator-facing rule identifier | -| `enabled` | Boolean | No | Whether the rule participates in matching (default `false`) | -| `preset` | String | Matcher | Built-in preset such as `nextjs-static` | -| `path_prefix` | String | Matcher | Request path prefix | -| `path_glob` | String | Matcher | Single glob matched against the request path | -| `path_globs` | Array[String] | Matcher | Multiple globs matched against the request path | -| `path_regex` | String | Matcher | Regex matched against the request path | -| `extensions` | Array[String] | Matcher | Case-insensitive file extensions | -| `requires_hash_in_filename` | Boolean | No | Require an 8+ hex token in the final path segment before matching | -| `visibility` | String | No | `public` or `private` (default `public`) | -| `browser_ttl_seconds` | Integer | No | Browser `max-age` | -| `edge_ttl_seconds` | Integer | No | Edge/shared-cache TTL | -| `stale_while_revalidate_seconds` | Integer | No | Optional `stale-while-revalidate` | -| `stale_if_error_seconds` | Integer | No | Optional `stale-if-error` | -| `immutable` | Boolean | No | Add `immutable` when browser TTL is positive | +| Field | Type | Required | Description | +| -------------------------------- | ------------- | -------- | ----------------------------------------------------------------- | +| `id` | String | Yes | Unique operator-facing rule identifier | +| `enabled` | Boolean | No | Whether the rule participates in matching (default `false`) | +| `preset` | String | Matcher | Built-in preset such as `nextjs-static` | +| `path_prefix` | String | Matcher | Request path prefix | +| `path_glob` | String | Matcher | Single glob matched against the request path | +| `path_globs` | Array[String] | Matcher | Multiple globs matched against the request path | +| `path_regex` | String | Matcher | Regex matched against the request path | +| `extensions` | Array[String] | Matcher | Case-insensitive file extensions | +| `requires_hash_in_filename` | Boolean | No | Require an 8+ hex token in the final path segment before matching | +| `visibility` | String | No | `public` or `private` (default `public`) | +| `browser_ttl_seconds` | Integer | No | Browser `max-age` | +| `edge_ttl_seconds` | Integer | No | Edge/shared-cache TTL | +| `stale_while_revalidate_seconds` | Integer | No | Optional `stale-while-revalidate` | +| `stale_if_error_seconds` | Integer | No | Optional `stale-if-error` | +| `immutable` | Boolean | No | Add `immutable` when browser TTL is positive | Exactly one matcher must be configured per rule. `path_glob` and `path_globs` are mutually exclusive. From 0e413c2b62e33e9dd29c7dcdacd0b97ff6362ce5 Mon Sep 17 00:00:00 2001 From: Christian Date: Wed, 15 Jul 2026 11:44:46 -0500 Subject: [PATCH 3/8] Address cache policy review feedback --- .../wrangler.ci.toml | 3 + .../wrangler.toml | 3 + .../trusted-server-adapter-fastly/src/app.rs | 2 +- .../trusted-server-adapter-fastly/src/main.rs | 7 +- .../src/middleware.rs | 27 +- .../src/integrations/gpt_diagnostics.rs | 4 +- crates/trusted-server-core/src/proxy.rs | 51 +++- crates/trusted-server-core/src/publisher.rs | 2 +- .../src/response_privacy.rs | 58 +++- crates/trusted-server-core/src/settings.rs | 282 ++++++++++++++++-- docs/guide/configuration.md | 95 ++++-- ...ache-control-header-implementation-plan.md | 34 ++- .../2026-07-06-cache-control-header-design.md | 54 ++-- trusted-server.example.toml | 8 +- 14 files changed, 514 insertions(+), 116 deletions(-) diff --git a/crates/trusted-server-adapter-cloudflare/wrangler.ci.toml b/crates/trusted-server-adapter-cloudflare/wrangler.ci.toml index e6891eb79..88a2dfc74 100644 --- a/crates/trusted-server-adapter-cloudflare/wrangler.ci.toml +++ b/crates/trusted-server-adapter-cloudflare/wrangler.ci.toml @@ -6,6 +6,9 @@ compatibility_date = "2024-09-23" compatibility_flags = ["nodejs_compat", "cache_option_enabled"] # No [build] section — bundle is pre-built in CI; wrangler dev must not rebuild. +[cache] +enabled = true + [[kv_namespaces]] binding = "TRUSTED_SERVER_KV" id = "ci-local-kv" diff --git a/crates/trusted-server-adapter-cloudflare/wrangler.toml b/crates/trusted-server-adapter-cloudflare/wrangler.toml index 7c91173fc..9acdb13ab 100644 --- a/crates/trusted-server-adapter-cloudflare/wrangler.toml +++ b/crates/trusted-server-adapter-cloudflare/wrangler.toml @@ -11,6 +11,9 @@ compatibility_date = "2024-09-23" # (auction-eligible publisher navigations), so this is a hard requirement. compatibility_flags = ["nodejs_compat", "cache_option_enabled"] +[cache] +enabled = true + [build] command = "bash build.sh" diff --git a/crates/trusted-server-adapter-fastly/src/app.rs b/crates/trusted-server-adapter-fastly/src/app.rs index ca93eb3c2..7991f488d 100644 --- a/crates/trusted-server-adapter-fastly/src/app.rs +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -96,7 +96,7 @@ use edgezero_core::router::RouterService; use error_stack::Report; use trusted_server_core::auction::AuctionTelemetrySink; use trusted_server_core::auction::endpoints::handle_auction; -use trusted_server_core::auction::{build_orchestrator, AuctionOrchestrator}; +use trusted_server_core::auction::{AuctionOrchestrator, build_orchestrator}; use trusted_server_core::cache_policy::EdgeCacheHeader; use trusted_server_core::constants::{COOKIE_SHAREDID, COOKIE_TS_EIDS}; use trusted_server_core::ec::EcContext; diff --git a/crates/trusted-server-adapter-fastly/src/main.rs b/crates/trusted-server-adapter-fastly/src/main.rs index 24be7ad20..e28c0726f 100644 --- a/crates/trusted-server-adapter-fastly/src/main.rs +++ b/crates/trusted-server-adapter-fastly/src/main.rs @@ -333,10 +333,11 @@ fn send_edgezero_response( effects.apply_to_response(&mut response); } - // Final cache guard: EC finalization and request-filter effects may have - // added a per-user Set-Cookie after `apply_finalize_headers` ran, so - // re-apply the privacy downgrade before send. + // Final cache guards: EC finalization and request-filter effects may have + // added a per-user Set-Cookie or a private/no-store directive after + // `apply_finalize_headers` and normalized asset policy reapplication ran. crate::middleware::enforce_set_cookie_cache_privacy(&mut response); + crate::middleware::enforce_uncacheable_cache_privacy(&mut response); let (parts, body) = response.into_parts(); diff --git a/crates/trusted-server-adapter-fastly/src/middleware.rs b/crates/trusted-server-adapter-fastly/src/middleware.rs index 2c00ac2ff..153d90295 100644 --- a/crates/trusted-server-adapter-fastly/src/middleware.rs +++ b/crates/trusted-server-adapter-fastly/src/middleware.rs @@ -235,7 +235,9 @@ pub(crate) fn apply_finalize_headers( /// entry point (`main.rs`) can re-apply it after /// [`ec_finalize_response`](trusted_server_core::ec::finalize::ec_finalize_response) /// writes the EC identity `Set-Cookie`, using the single shared implementation. -pub(crate) use trusted_server_core::response_privacy::enforce_set_cookie_cache_privacy; +pub(crate) use trusted_server_core::response_privacy::{ + enforce_set_cookie_cache_privacy, enforce_uncacheable_cache_privacy, +}; // --------------------------------------------------------------------------- // Tests @@ -496,6 +498,29 @@ mod tests { ); } + #[test] + fn enforce_uncacheable_cache_privacy_handles_late_filter_headers() { + let mut response = response_with_headers(&[ + ("cache-control", "private, max-age=0"), + ("surrogate-control", "max-age=600"), + ]); + + enforce_uncacheable_cache_privacy(&mut response); + + assert_eq!( + response + .headers() + .get("cache-control") + .and_then(|value| value.to_str().ok()), + Some("private, max-age=0"), + "should preserve the late private directive" + ); + assert!( + response.headers().get("surrogate-control").is_none(), + "should strip the normalized edge header after late filter effects" + ); + } + // --------------------------------------------------------------------------- // FinalizeResponseMiddleware::handle tests // --------------------------------------------------------------------------- diff --git a/crates/trusted-server-core/src/integrations/gpt_diagnostics.rs b/crates/trusted-server-core/src/integrations/gpt_diagnostics.rs index 5bd86d19e..3e63f3d35 100644 --- a/crates/trusted-server-core/src/integrations/gpt_diagnostics.rs +++ b/crates/trusted-server-core/src/integrations/gpt_diagnostics.rs @@ -12,9 +12,9 @@ use validator::Validate; use edgezero_core::body::Body as EdgeBody; +use crate::cache_policy::EDGE_CACHE_HEADER_NAMES; use crate::error::TrustedServerError; use crate::http_util::is_navigation_request; -use crate::response_privacy::CDN_CACHE_HEADERS; use crate::settings::{IntegrationConfig, Settings}; use crate::tsjs; @@ -257,7 +257,7 @@ pub fn finalize_response( header::CACHE_CONTROL, HeaderValue::from_static("private, no-store"), ); - for name in CDN_CACHE_HEADERS { + for name in EDGE_CACHE_HEADER_NAMES { response.headers_mut().remove(*name); } } diff --git a/crates/trusted-server-core/src/proxy.rs b/crates/trusted-server-core/src/proxy.rs index 0270c4292..b51cd75f6 100644 --- a/crates/trusted-server-core/src/proxy.rs +++ b/crates/trusted-server-core/src/proxy.rs @@ -14,7 +14,9 @@ use std::time::Duration; use web_time::{SystemTime, UNIX_EPOCH}; use crate::cache_policy::{ - apply_no_store_private_to_headers, CachePolicy, EdgeCacheHeader, NO_STORE_PRIVATE_CACHE_CONTROL, + CachePolicy, EdgeCacheHeader, NO_STORE_PRIVATE_CACHE_CONTROL, + apply_no_store_private_to_headers, cache_control_headers_are_private_or_no_store, + remove_edge_cache_headers, }; use crate::constants::{ HEADER_ACCEPT, HEADER_ACCEPT_ENCODING, HEADER_ACCEPT_LANGUAGE, HEADER_REFERER, @@ -127,7 +129,11 @@ impl AssetProxyCachePolicy { Self::OriginControlled => {} Self::NoStorePrivate => apply_no_store_cache_control(response), Self::Normalized(policy) => { - policy.apply_to_headers(response.headers_mut(), edge_header) + if cache_control_headers_are_private_or_no_store(response.headers()) { + remove_edge_cache_headers(response.headers_mut()); + } else { + policy.apply_to_headers(response.headers_mut(), edge_header); + } } } } @@ -4329,7 +4335,7 @@ mod tests { } #[test] - fn handle_asset_proxy_request_applies_configured_normalized_cache_policy() { + fn handle_asset_proxy_request_replaces_third_party_cache_policy_for_rehosted_asset() { futures::executor::block_on(async { let stub = Arc::new(StubHttpClient::new()); stub.push_response_with_headers( @@ -4379,7 +4385,7 @@ mod tests { assert_eq!( response_header(&response, header::CACHE_CONTROL), Some("public, max-age=31536000, immutable"), - "core response should apply browser cache policy immediately" + "configured rehost policy should replace the third-party no-store directive" ); assert!( response.headers().get("surrogate-control").is_none(), @@ -4401,6 +4407,43 @@ mod tests { }); } + #[test] + fn normalized_asset_policy_preserves_final_private_or_no_store_directives() { + for cache_control in ["private, max-age=0", "no-store"] { + let mut response = edge_response_builder() + .header(header::CACHE_CONTROL, cache_control) + .header("surrogate-control", "max-age=31536000") + .header("cdn-cache-control", "max-age=31536000") + .header("cloudflare-cdn-cache-control", "max-age=31536000") + .body(EdgeBody::empty()) + .expect("should build asset response"); + + AssetProxyCachePolicy::Normalized(CachePolicy::public_immutable(Duration::from_secs( + 31_536_000, + ))) + .apply_after_route_finalization(&mut response, EdgeCacheHeader::SurrogateControl); + + assert_eq!( + response + .headers() + .get(header::CACHE_CONTROL) + .and_then(|value| value.to_str().ok()), + Some(cache_control), + "final privacy directive should veto normalized cache policy" + ); + assert!( + [ + "surrogate-control", + "cdn-cache-control", + "cloudflare-cdn-cache-control", + ] + .iter() + .all(|name| !response.headers().contains_key(*name)), + "final privacy directive should remove every edge-cache header" + ); + } + } + #[test] fn handle_asset_proxy_request_leaves_non_matching_assets_origin_controlled() { futures::executor::block_on(async { diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 1da702196..674d3d718 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -48,7 +48,7 @@ use crate::auction::types::{ AuctionContext, AuctionRequest, Bid, DeviceInfo, PublisherInfo, SiteInfo, UserInfo, }; use crate::cache_policy::{ - cache_control_headers_are_private_or_no_store, CachePolicy, EdgeCacheHeader, + CachePolicy, EdgeCacheHeader, cache_control_headers_are_private_or_no_store, }; use crate::consent::{consent_allows_server_side_auction, gate_eids_by_consent}; use crate::constants::{COOKIE_TS_EIDS, HEADER_X_COMPRESS_HINT}; diff --git a/crates/trusted-server-core/src/response_privacy.rs b/crates/trusted-server-core/src/response_privacy.rs index 40650d7e7..8ccda97bd 100644 --- a/crates/trusted-server-core/src/response_privacy.rs +++ b/crates/trusted-server-core/src/response_privacy.rs @@ -17,11 +17,6 @@ use crate::cache_policy::{ }; use crate::settings::Settings; -/// Runtime edge-cache headers stripped from private or cookie-bearing responses. -pub use crate::cache_policy::EDGE_CACHE_HEADER_NAMES as SURROGATE_CACHE_HEADERS; -/// Backwards-compatible name used by integrations that clear every edge-cache directive. -pub use crate::cache_policy::EDGE_CACHE_HEADER_NAMES as CDN_CACHE_HEADERS; - fn cache_control_is_private_or_no_store(response: &Response) -> bool { cache_control_headers_are_private_or_no_store(response.headers()) } @@ -38,6 +33,17 @@ pub(crate) fn enforce_synthesized_html_cache_privacy(response: &mut Response) { response.headers_mut().remove(header::LAST_MODIFIED); } +/// Removes runtime edge-cache headers from a response finalized as uncacheable. +/// +/// Call this after any late response-header mutations so a final `private` or +/// `no-store` directive cannot coexist with an independently authoritative edge +/// cache header. +pub fn enforce_uncacheable_cache_privacy(response: &mut Response) { + if cache_control_is_private_or_no_store(response) { + remove_edge_cache_headers(response.headers_mut()); + } +} + /// Forces cookie-bearing responses to stay private to shared caches. /// /// Any response that sets a per-user cookie (notably the EC identity cookie) @@ -85,9 +91,7 @@ pub fn apply_response_headers_with_cache_privacy(settings: &Settings, response: enforce_set_cookie_cache_privacy(response); let response_is_uncacheable = cache_control_is_private_or_no_store(response); - if response_is_uncacheable { - remove_edge_cache_headers(response.headers_mut()); - } + enforce_uncacheable_cache_privacy(response); for (key, value) in &settings.response_headers { if response_is_uncacheable @@ -113,9 +117,7 @@ pub fn apply_response_headers_with_cache_privacy(settings: &Settings, response: response.headers_mut().insert(header_name, header_value); } - if cache_control_is_private_or_no_store(response) { - remove_edge_cache_headers(response.headers_mut()); - } + enforce_uncacheable_cache_privacy(response); // Operator headers can themselves introduce Set-Cookie (alongside public // edge-cache headers) onto a previously cookieless response, which the @@ -130,6 +132,8 @@ mod tests { use edgezero_core::http::response_builder; + use crate::cache_policy::EDGE_CACHE_HEADER_NAMES; + fn settings_with_response_headers(headers: &[(&str, &str)]) -> Settings { let mut s = Settings::from_toml( r#" @@ -178,7 +182,7 @@ mod tests { ); for header_name in [header::ETAG.as_str(), header::LAST_MODIFIED.as_str()] .into_iter() - .chain(SURROGATE_CACHE_HEADERS.iter().copied()) + .chain(EDGE_CACHE_HEADER_NAMES.iter().copied()) { assert!( !response.headers().contains_key(header_name), @@ -307,7 +311,7 @@ mod tests { "private, no-store", "operator cache headers must not weaken an existing private response" ); - for header_name in SURROGATE_CACHE_HEADERS { + for header_name in EDGE_CACHE_HEADER_NAMES { assert!( !response.headers().contains_key(*header_name), "operator headers must not restore shared caching through {header_name}" @@ -341,6 +345,34 @@ mod tests { ); } + #[test] + fn final_uncacheable_guard_strips_edge_headers_without_a_cookie() { + let mut response = response_builder() + .header(header::CACHE_CONTROL, "no-store") + .header("surrogate-control", "max-age=600") + .header("cdn-cache-control", "max-age=600") + .header("cloudflare-cdn-cache-control", "max-age=600") + .body(edgezero_core::body::Body::empty()) + .expect("should build response"); + + enforce_uncacheable_cache_privacy(&mut response); + + assert_eq!( + response + .headers() + .get(header::CACHE_CONTROL) + .and_then(|value| value.to_str().ok()), + Some("no-store"), + "final guard should preserve the uncacheable directive" + ); + assert!( + EDGE_CACHE_HEADER_NAMES + .iter() + .all(|name| !response.headers().contains_key(*name)), + "final guard should remove every edge-cache header" + ); + } + #[test] fn applies_operator_headers_on_cookieless_response() { let settings = settings_with_response_headers(&[("x-operator", "value")]); diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index ad17595f9..effbf613f 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -1870,7 +1870,7 @@ fn validate_tinybird_secret(value: &str, setting: &str) -> Result<(), Report Result<(), Report> { let mut seen_ids = HashSet::new(); for rule in &self.asset_rules { @@ -1904,6 +1904,8 @@ impl CacheSettings { message: format!("cache.asset_rules contains duplicate id `{}`", rule.id), })); } + } + for rule in &self.asset_rules { rule.prepare_runtime()?; } Ok(()) @@ -1955,7 +1957,7 @@ pub struct CacheAssetRule { /// File extensions matched against the request path, case-insensitively. #[serde(default)] pub extensions: Vec, - /// Require a hash-like token in the final path segment before the rule matches. + /// Require a supported bundler fingerprint suffix in the filename before matching. #[serde(default)] pub requires_hash_in_filename: bool, /// Browser-facing cache visibility. @@ -2015,9 +2017,14 @@ impl CacheAssetRule { } fn prepare_runtime(&self) -> Result<(), Report> { + if !self.enabled { + return Ok(()); + } + self.validate_matcher_shape()?; self.compiled_regex().map(|_| ())?; self.compiled_globs().map(|_| ())?; + self.validate_policy_shape()?; Ok(()) } @@ -2048,6 +2055,46 @@ impl CacheAssetRule { Ok(()) } + fn validate_policy_shape(&self) -> Result<(), Report> { + if self.browser_ttl_seconds.is_none() && self.edge_ttl_seconds.is_none() { + return Err(Report::new(TrustedServerError::Configuration { + message: format!( + "cache.asset_rules `{}` must configure browser_ttl_seconds or edge_ttl_seconds", + self.id + ), + })); + } + + if !self.immutable { + return Ok(()); + } + + if self + .browser_ttl_seconds + .is_none_or(|browser_ttl| browser_ttl == 0) + { + return Err(Report::new(TrustedServerError::Configuration { + message: format!( + "cache.asset_rules `{}` sets immutable without a positive browser_ttl_seconds", + self.id + ), + })); + } + + let preset_is_content_addressed = + matches!(self.preset, Some(CacheAssetPreset::NextJsStatic)); + if !preset_is_content_addressed && !self.requires_hash_in_filename { + return Err(Report::new(TrustedServerError::Configuration { + message: format!( + "cache.asset_rules `{}` sets immutable without requires_hash_in_filename or a content-addressed preset", + self.id + ), + })); + } + + Ok(()) + } + fn compiled_regex(&self) -> Result, Report> { let Some(pattern) = self.path_regex.as_deref() else { return Ok(None); @@ -2094,13 +2141,22 @@ impl CacheAssetRule { } fn matches_path(&self, path: &str) -> Result> { - if !self.enabled { + if !self.enabled || !self.matcher_matches_path(path)? { return Ok(false); } - if self.requires_hash_in_filename && !filename_contains_hash(path) { + + if self.requires_hash_in_filename && !filename_contains_fingerprint(path) { + log::debug!( + "cache asset rule `{}` rejects path `{path}` because the filename has no supported fingerprint", + self.id + ); return Ok(false); } + Ok(true) + } + + fn matcher_matches_path(&self, path: &str) -> Result> { if let Some(preset) = self.preset { return Ok(preset.matches_path(path)); } @@ -2178,11 +2234,44 @@ fn path_extension(path: &str) -> Option { (!extension.is_empty()).then(|| extension.to_ascii_lowercase()) } -fn filename_contains_hash(path: &str) -> bool { +fn filename_contains_fingerprint(path: &str) -> bool { let filename = path.rsplit('/').next().unwrap_or(path); - filename - .split(['.', '-', '_', '~']) - .any(|segment| segment.len() >= 8 && segment.chars().all(|ch| ch.is_ascii_hexdigit())) + let Some((stem, extension)) = filename.rsplit_once('.') else { + return false; + }; + if stem.is_empty() || extension.is_empty() { + return false; + } + + stem.char_indices() + .filter(|(_, ch)| matches!(ch, '.' | '-' | '_' | '~')) + .any(|(separator_index, separator)| { + let candidate_start = separator_index + separator.len_utf8(); + let prefix = &stem[..separator_index]; + let candidate = &stem[candidate_start..]; + !prefix.is_empty() && fingerprint_candidate_is_supported(candidate) + }) +} + +fn fingerprint_candidate_is_supported(candidate: &str) -> bool { + let is_hex = candidate.len() >= 8 + && candidate.chars().all(|ch| ch.is_ascii_hexdigit()) + && candidate.chars().any(|ch| ch.is_ascii_alphabetic()); + let is_esbuild_base32 = candidate.len() == 8 + && candidate + .chars() + .all(|ch| ch.is_ascii_uppercase() || matches!(ch, '2'..='7')) + && candidate.chars().any(|ch| ch.is_ascii_alphabetic()); + let is_vite_base64url = candidate.len() == 8 + && candidate + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_')) + && candidate.chars().any(|ch| ch.is_ascii_uppercase()) + && candidate + .chars() + .any(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || matches!(ch, '-' | '_')); + + is_hex || is_esbuild_base32 || is_vite_base64url } /// Debug-only features. All flags default to `false` (off in production). @@ -2256,7 +2345,6 @@ pub struct Settings { #[serde(default)] pub consent: ConsentConfig, #[serde(default)] - #[validate(nested)] pub cache: CacheSettings, #[serde(default)] pub proxy: Proxy, @@ -3124,22 +3212,155 @@ mod tests { crate_test_settings_str() ); let settings = Settings::from_toml(&toml_str).expect("should parse cache asset rule"); + let expected_policy = CachePolicy::public_immutable(Duration::from_secs(31_536_000)); + + for path in [ + "/assets/app.0123abcd.js", + "/assets/index-DA15JTLU.js", + "/assets/index-BsELY24f.js", + "/assets/app-VRTVD5R5.js", + "/assets/app-VCMCQCKZ.js", + ] { + assert_eq!( + settings + .asset_cache_policy_for_path(path) + .expect("should evaluate cache rules"), + Some(expected_policy), + "supported fingerprint should match asset rule for {path}" + ); + } + + for path in ["/assets/app.js", "/assets/deadbeef.js"] { + assert!( + settings + .asset_cache_policy_for_path(path) + .expect("should evaluate cache rules") + .is_none(), + "non-fingerprinted filename should not match asset rule for {path}" + ); + } + } + + #[test] + fn filename_fingerprint_gate_supports_conservative_bundler_suffixes() { + for (path, expected) in [ + ("/assets/index-DA15JTLU.js", true), + ("/assets/index-BsELY24f.js", true), + ("/assets/index-aB_cD-12.js", true), + ("/assets/app-VRTVD5R5.js", true), + ("/assets/app-VCMCQCKZ.js", true), + ("/assets/app.a1B2c3D4.js", true), + ("/assets/main.a1b2c3d4e5f6.js", true), + ("/assets/index.8f3a2b1c.js", true), + ("/assets/app.deadbeef.js", true), + ("/assets/deadbeef.js", false), + ("/assets/VCMCQCKZ.js", false), + ("/assets/app.js", false), + ("/assets/app-manifest.js", false), + ("/assets/app-release2.js", false), + ("/assets/app.20260714.js", false), + ("/assets/app-abc123.js", false), + ("/assets/deadbeef/app.js", false), + ] { + assert_eq!( + filename_contains_fingerprint(path), + expected, + "fingerprint result should match for {path}" + ); + } + } + #[test] + fn disabled_cache_asset_rules_defer_matcher_and_policy_validation() { + let toml_str = format!( + r#"{} + + [[cache.asset_rules]] + id = "disabled-invalid-regex" + enabled = false + path_regex = "[" + + [[cache.asset_rules]] + id = "disabled-placeholder" + enabled = false + + [[cache.asset_rules]] + id = "disabled-unsafe-immutable" + enabled = false + path_prefix = "/assets/" + immutable = true + "#, + crate_test_settings_str() + ); + + let settings = + Settings::from_toml(&toml_str).expect("should defer disabled rule validation"); assert!( settings - .asset_cache_policy_for_path("/assets/app.js") - .expect("should evaluate cache rules") + .asset_cache_policy_for_path("/assets/app-DA15JTLU.js") + .expect("should evaluate disabled cache rules") .is_none(), - "broad allowlist should not match non-fingerprinted files when hash is required" + "disabled rules should never match" ); - assert_eq!( - settings - .asset_cache_policy_for_path("/assets/app.0123abcd.js") - .expect("should evaluate cache rules"), - Some(CachePolicy::public_immutable(Duration::from_secs( - 31_536_000 - ))), - "fingerprinted asset should match the allowlist" + } + + #[test] + fn cache_asset_rule_policy_validation_rejects_unsafe_config() { + let missing_ttl = format!( + r#"{} + + [[cache.asset_rules]] + id = "missing-ttl" + enabled = true + path_prefix = "/assets/" + "#, + crate_test_settings_str() + ); + let missing_ttl_err = + Settings::from_toml(&missing_ttl).expect_err("should reject rule without a TTL"); + assert!( + format!("{missing_ttl_err:?}").contains("browser_ttl_seconds or edge_ttl_seconds"), + "should explain missing TTL: {missing_ttl_err:?}" + ); + + let immutable_without_fingerprint = format!( + r#"{} + + [[cache.asset_rules]] + id = "unsafe-immutable" + enabled = true + path_prefix = "/assets/" + browser_ttl_seconds = 31536000 + immutable = true + "#, + crate_test_settings_str() + ); + let fingerprint_err = Settings::from_toml(&immutable_without_fingerprint) + .expect_err("should reject immutable rule without fingerprint requirement"); + assert!( + format!("{fingerprint_err:?}").contains("requires_hash_in_filename"), + "should explain immutable fingerprint requirement: {fingerprint_err:?}" + ); + + let immutable_without_browser_ttl = format!( + r#"{} + + [[cache.asset_rules]] + id = "immutable-without-browser-ttl" + enabled = true + path_prefix = "/assets/" + requires_hash_in_filename = true + browser_ttl_seconds = 0 + edge_ttl_seconds = 31536000 + immutable = true + "#, + crate_test_settings_str() + ); + let browser_ttl_err = Settings::from_toml(&immutable_without_browser_ttl) + .expect_err("should reject immutable rule without positive browser TTL"); + assert!( + format!("{browser_ttl_err:?}").contains("positive browser_ttl_seconds"), + "should explain immutable browser TTL requirement: {browser_ttl_err:?}" ); } @@ -3201,6 +3422,23 @@ mod tests { format!("{shape_err:?}").contains("exactly one matcher"), "should explain invalid matcher shape: {shape_err:?}" ); + + let missing_matcher = format!( + r#"{} + + [[cache.asset_rules]] + id = "missing-matcher" + enabled = true + browser_ttl_seconds = 60 + "#, + crate_test_settings_str() + ); + let missing_matcher_err = + Settings::from_toml(&missing_matcher).expect_err("should reject missing matcher"); + assert!( + format!("{missing_matcher_err:?}").contains("exactly one matcher"), + "should explain missing matcher: {missing_matcher_err:?}" + ); } #[test] diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 258a9a4bc..e085a23c1 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -1042,29 +1042,48 @@ content-addressed or otherwise safe for the configured TTL. ### `[[cache.asset_rules]]` Rules are evaluated in file order; the first enabled matching rule wins. -Disabled rules are ignored, which lets you keep framework presets documented in -config without enabling them for every publisher. - -| Field | Type | Required | Description | -| -------------------------------- | ------------- | -------- | ----------------------------------------------------------------- | -| `id` | String | Yes | Unique operator-facing rule identifier | -| `enabled` | Boolean | No | Whether the rule participates in matching (default `false`) | -| `preset` | String | Matcher | Built-in preset such as `nextjs-static` | -| `path_prefix` | String | Matcher | Request path prefix | -| `path_glob` | String | Matcher | Single glob matched against the request path | -| `path_globs` | Array[String] | Matcher | Multiple globs matched against the request path | -| `path_regex` | String | Matcher | Regex matched against the request path | -| `extensions` | Array[String] | Matcher | Case-insensitive file extensions | -| `requires_hash_in_filename` | Boolean | No | Require an 8+ hex token in the final path segment before matching | -| `visibility` | String | No | `public` or `private` (default `public`) | -| `browser_ttl_seconds` | Integer | No | Browser `max-age` | -| `edge_ttl_seconds` | Integer | No | Edge/shared-cache TTL | -| `stale_while_revalidate_seconds` | Integer | No | Optional `stale-while-revalidate` | -| `stale_if_error_seconds` | Integer | No | Optional `stale-if-error` | -| `immutable` | Boolean | No | Add `immutable` when browser TTL is positive | - -Exactly one matcher must be configured per rule. `path_glob` and `path_globs` -are mutually exclusive. +Disabled rules never match, and their matcher and policy validation is deferred +until they are enabled. Rule IDs are always normalized and must remain nonempty +and unique, including for disabled placeholders. + +| Field | Type | Required | Description | +| -------------------------------- | ------------- | -------- | --------------------------------------------------------------- | +| `id` | String | Yes | Unique operator-facing rule identifier | +| `enabled` | Boolean | No | Whether the rule participates in matching (default `false`) | +| `preset` | String | Matcher | Built-in preset such as `nextjs-static` | +| `path_prefix` | String | Matcher | Request path prefix | +| `path_glob` | String | Matcher | Single glob matched against the request path | +| `path_globs` | Array[String] | Matcher | Multiple globs matched against the request path | +| `path_regex` | String | Matcher | Regex matched against the request path | +| `extensions` | Array[String] | Matcher | Case-insensitive file extensions | +| `requires_hash_in_filename` | Boolean | No | Require a supported bundler fingerprint suffix before matching | +| `visibility` | String | No | `public` or `private` (default `public`) | +| `browser_ttl_seconds` | Integer | Policy | Browser `max-age`; must be positive when `immutable = true` | +| `edge_ttl_seconds` | Integer | Policy | TTL emitted through the runtime-specific shared-cache directive | +| `stale_while_revalidate_seconds` | Integer | No | Optional `stale-while-revalidate` | +| `stale_if_error_seconds` | Integer | No | Optional `stale-if-error` | +| `immutable` | Boolean | No | Add `immutable` for a validated content-addressed rule | + +An enabled rule must configure exactly one matcher and at least one of +`browser_ttl_seconds` or `edge_ttl_seconds`. `path_glob` and `path_globs` are +mutually exclusive. `immutable = true` additionally requires a positive browser +TTL and either the content-addressed `nextjs-static` preset or +`requires_hash_in_filename = true`. + +The filename fingerprint check is intentionally conservative. It examines the +suffix immediately before the final extension, requires a nonempty filename +prefix separated by `.`, `-`, `_`, or `~`, and recognizes: + +- hexadecimal suffixes of at least eight characters containing a letter; +- eight-character esbuild-style uppercase Base32 suffixes; +- eight-character Vite/Base64URL-style suffixes with a mixed character class. + +For example, `app.0123abcd.js`, `app-VRTVD5R5.js`, and +`index-DA15JTLU.js` match, while `app.js`, `deadbeef.js`, and +`app.20260714.js` do not. This heuristic is not proof of content addressing; +confirm the publisher's bundler output before enabling a long immutable TTL. A +base rule that matches while this fingerprint check fails emits a debug log with +the rule ID and rejected path. **Next.js preset example** (disabled until the publisher confirms `/_next/static/` is content-addressed): @@ -1080,12 +1099,13 @@ edge_ttl_seconds = 31536000 immutable = true ``` -**Publisher allowlist example**: +**Publisher allowlist example** (enable only after verifying the filename +convention): ```toml [[cache.asset_rules]] id = "publisher-fingerprinted-assets" -enabled = true +enabled = false path_globs = [ "/assets/**/*.js", "/assets/**/*.css", @@ -1100,9 +1120,28 @@ immutable = true ``` If `[cache]` is omitted or no enabled rule matches, Trusted Server preserves the -origin cache policy for publisher-origin assets. TS-owned validated hash URLs, -such as `/static/tsjs=...js?v=`, use their built-in cache policy and do -not require an asset rule. +origin cache policy for publisher-origin assets. On the publisher pass-through +path, an origin `private` or `no-store` directive vetoes a matching rule. Other +origin cache directives, including `no-cache`, are replaced by the configured +policy. `Vary` is preserved, so do not assign a public immutable rule to paths +that vary by cookies or other user-specific request state. + +On a configured Fastly asset-rehost route, a matching rule is authoritative +over the third-party origin's cache defaults, including `no-store`, because +Trusted Server owns the rehosted copy. A later Trusted Server or operator-applied +`private` or `no-store` directive still vetoes public policy reapplication and +removes shared-cache headers. + +TS-owned validated hash URLs such as `/static/tsjs=...js?v=` use their +built-in cache policy and do not require an asset rule. Shared-cache keys for +`/static/tsjs=` must preserve `v`; otherwise a matching immutable response can +collide with the missing or mismatched version's short-TTL response. + +`edge_ttl_seconds` only emits the selected runtime's shared-cache directive. The +runtime or service must also enable and consume that directive. The checked-in +Cloudflare manifests enable Workers Cache. Fastly synthetic and final egress +responses still require explicit runtime cache integration, tracked in +[#908](https://github.com/IABTechLab/trusted-server/issues/908). ## Integration Configurations diff --git a/docs/superpowers/plans/2026-07-06-cache-control-header-implementation-plan.md b/docs/superpowers/plans/2026-07-06-cache-control-header-implementation-plan.md index f156aa768..ce2c37956 100644 --- a/docs/superpowers/plans/2026-07-06-cache-control-header-implementation-plan.md +++ b/docs/superpowers/plans/2026-07-06-cache-control-header-implementation-plan.md @@ -44,11 +44,14 @@ into the initial cache-header PRs. - TS-owned Prebid delivery is covered by deferred TSJS module URLs. Publisher Prebid script URLs neutralized by TS are compatibility shims at stable URLs and must use `no-store` or a very short TTL, not a year-long immutable policy. -- Rehosted assets are TS-owned copies once TS rewrites/hosts them. They should - use explicit normalized policies, with immutable only for TS-fingerprinted - rehosted URLs. -- Fastly and Cloudflare are the MVP runtime targets. Akamai mapping is deferred - until Akamai is on the roadmap. +- Fastly rehosted assets are TS-owned copies once TS rewrites/hosts them. A + matching rehost rule is authoritative over third-party origin cache defaults. Use + immutable only for TS-fingerprinted rehosted URLs, and preserve any later + TS/operator `private` or `no-store` decision as the final veto. +- Fastly and Cloudflare are the MVP runtime targets. This slice emits their + runtime-specific directives; actual Fastly storage integration and cache-key + verification are tracked in [#908](https://github.com/IABTechLab/trusted-server/issues/908). + Akamai mapping is deferred until Akamai is on the roadmap. - Dynamic HTML/RSC/API caching, dynamic `Vary`/cache-key normalization, origin-template caching, transformed-template caching, true publisher-origin streaming, parser-context bid splice, EdgeZero streaming parity, and SSAT HTML @@ -88,11 +91,13 @@ into the initial cache-header PRs. implemented through the shared rule engine and can be disabled/overridden. - Arbitrary publisher-origin assets remain origin-controlled unless matched by an enabled preset or publisher allowlist. -- TS-owned rehosted assets have explicit normalized policies instead of blindly - passing through third-party defaults. +- Fastly TS-owned rehosted assets have explicit normalized policies instead of + blindly passing through third-party defaults. - MVP adapters emit the correct edge-cache header from shared policy: Fastly `Surrogate-Control`, Cloudflare `CDN-Cache-Control` / - `Cloudflare-CDN-Cache-Control`, or portable `s-maxage` fallback. + `Cloudflare-CDN-Cache-Control`, or portable `s-maxage` fallback. Header + emission is complete; runtime storage and cache-key verification remain in + #908. - Deferred features are documented as deferred and are not accidentally implemented as hard-coded Next.js/dynamic-cache behavior. - Tests and target-matched checks pass for touched crates/adapters. @@ -244,7 +249,8 @@ upgrades to publisher-origin assets. Make the shared policy output explicit per runtime before wiring the rule engine into more routes. This prevents new code from copying the current core -Fastly-specific `Surrogate-Control` behavior. +Fastly-specific `Surrogate-Control` behavior. This phase covers directive +rendering only; runtime storage is tracked in #908. #### Code changes @@ -283,10 +289,14 @@ assets, using the runtime edge-header mapping from PR 5. - `OriginControlled` - `NoStorePrivate` - `Normalized(CachePolicy)` from a matched enabled rule. -- Apply normalized policy after route finalization but before final response - privacy hardening. +- Apply normalized policy at the asset handler, then reapply its runtime edge + directive after route finalization only when the finalized response is still + cacheable. Final `private` or `no-store` directives veto reapplication and + remove edge-cache headers. - Preserve existing no-store/private handling for errors, signed failures, or - responses that set cookies/security headers. + responses that set cookies/security headers. A matched TS-owned rehost rule + intentionally replaces the third-party origin's cache defaults before this + final privacy veto. - Ensure operator `response_headers` cannot weaken protected private/no-store decisions. - For TS-owned rehosted copies: diff --git a/docs/superpowers/specs/2026-07-06-cache-control-header-design.md b/docs/superpowers/specs/2026-07-06-cache-control-header-design.md index 8dae6aca2..1924381f1 100644 --- a/docs/superpowers/specs/2026-07-06-cache-control-header-design.md +++ b/docs/superpowers/specs/2026-07-06-cache-control-header-design.md @@ -42,7 +42,7 @@ Origin ──▶ TS edge/shared cache ──▶ Browser cache - Portable fallback: `s-maxage` inside `Cache-Control` - **Browser cache:** controlled by `max-age` and related `Cache-Control` directives. -A single `max-age` cannot express “hold at the edge for a year, but revalidate in the browser daily” or the reverse. TS should model these tiers separately and let adapters render the appropriate headers. +A single `max-age` cannot express “hold at the edge for a year, but revalidate in the browser daily” or the reverse. TS should model these tiers separately and let adapters render the appropriate headers. Header rendering alone does not enable a runtime cache: Cloudflare Workers Cache must be enabled, and Fastly synthetic/final egress responses require explicit cache integration tracked in [#908](https://github.com/IABTechLab/trusted-server/issues/908). ## Policy model @@ -63,18 +63,18 @@ Rules should be configurable. Built-in framework presets, such as Next.js `/_nex ## Target behavior by response class -| Response class | Target policy | Notes | -| --------------------------------------------------------------- | ----------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | -| TSJS with matching `?v=` | `Cache-Control: public, max-age=31536000, immutable` plus runtime edge header | The serving path must validate that `v` matches the bytes served. | -| TSJS missing/mismatched `?v=` | Short TTL or redirect to canonical hashed URL | Do not mark immutable. | -| TSJS deferred modules, including Prebid | Same as TSJS hash-matching policy | Example: `/static/tsjs=tsjs-prebid.min.js?v=`. | -| Publisher Prebid URL neutralized by TS | `no-store` or very short TTL | The empty compatibility shim is config-dependent and served at a stable publisher URL. Do not cache it for a year. | -| Enabled framework preset static, e.g. Next.js `/_next/static/*` | `Cache-Control: public, max-age=31536000, immutable` | Applied through configurable preset/allowlist rules. | -| TS-fingerprinted rehosted asset | `Cache-Control: public, max-age=31536000, immutable` | Safe only when TS owns the fingerprinted URL. | -| Stable TS-owned/rehosted URL | Conservative short browser TTL, optional longer edge TTL | Do not use `immutable`. | -| Arbitrary publisher-origin CSS/JS/images | Origin-controlled by default | TS may upgrade only via enabled framework preset or publisher allowlist. | -| SSAT-assembled ad-stack HTML | `Cache-Control: private, max-age=0`; strip runtime edge-cache headers | Must never enter shared cache because it can contain per-user slot/bid data. | -| Dynamic HTML/RSC/API | Origin-controlled in this slice | Future dynamic caching belongs to #859. | +| Response class | Target policy | Notes | +| --------------------------------------------------------------- | ----------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | +| TSJS with matching `?v=` | `Cache-Control: public, max-age=31536000, immutable` plus runtime edge header | The serving path must validate that `v` matches the bytes served. | +| TSJS missing/mismatched `?v=` | Short TTL or redirect to canonical hashed URL | Do not mark immutable. | +| TSJS deferred modules, including Prebid | Same as TSJS hash-matching policy | Example: `/static/tsjs=tsjs-prebid.min.js?v=`. | +| Publisher Prebid URL neutralized by TS | `no-store` or very short TTL | The empty compatibility shim is config-dependent and served at a stable publisher URL. Do not cache it for a year. | +| Enabled framework preset static, e.g. Next.js `/_next/static/*` | `Cache-Control: public, max-age=31536000, immutable` | Applied through configurable preset/allowlist rules. | +| Fastly TS-fingerprinted rehosted asset | `Cache-Control: public, max-age=31536000, immutable` | Safe only when TS owns the fingerprinted URL. A matched rehost rule is authoritative over third-party origin cache defaults. | +| Stable Fastly TS-owned/rehosted URL | Conservative short browser TTL, optional longer edge TTL | Do not use `immutable`; later TS/operator `private` or `no-store` finalization remains a veto. | +| Arbitrary publisher-origin CSS/JS/images | Origin-controlled by default | TS may upgrade only via enabled framework preset or publisher allowlist. | +| SSAT-assembled ad-stack HTML | `Cache-Control: private, max-age=0`; strip runtime edge-cache headers | Must never enter shared cache because it can contain per-user slot/bid data. | +| Dynamic HTML/RSC/API | Origin-controlled in this slice | Future dynamic caching belongs to #859. | ## TSJS-specific requirements @@ -126,20 +126,20 @@ Adapters should render the shared policy as follows: | Cloudflare | `CDN-Cache-Control` / `Cloudflare-CDN-Cache-Control` | | Portable fallback | `s-maxage` in `Cache-Control` | -Akamai mapping is deferred until Akamai is on the roadmap. +These mappings define emitted directives, not storage by themselves. The runtime must enable or implement the corresponding shared-cache mechanism. Akamai mapping is deferred until Akamai is on the roadmap. ## Acceptance criteria -- [ ] Cache policy is represented as structured fields, not hard-coded header strings. -- [ ] Built-in framework presets, including a disableable/overrideable Next.js `/_next/static/*` rule, are implemented through the shared cache-policy rule engine. -- [ ] TSJS hash-matching requests for unified and deferred modules emit `public, max-age=31536000, immutable` plus the runtime edge header. -- [ ] TSJS missing/mismatched hash requests do not get immutable caching. -- [ ] TSJS hash generation is build-time or cached enough that HTML injection does not re-concatenate/re-hash the bundle per pageview. -- [ ] Runtime cache-key configuration preserves the `v` query parameter for `/static/tsjs=`. -- [ ] Neutralized publisher Prebid shim responses use `no-store` or a short TTL, not a year-long policy. -- [ ] Arbitrary publisher-origin assets remain origin-controlled unless covered by an enabled framework preset or publisher allowlist. -- [ ] TS-owned rehosted assets have explicit normalized cache policy; immutable is used only for TS-fingerprinted rehosted URLs. -- [ ] SSAT-assembled ad-stack HTML continues to emit `private, max-age=0` and strips all runtime edge-cache headers. -- [ ] Fastly and Cloudflare adapters emit the correct edge-cache header from the shared policy, with portable `s-maxage` fallback where needed. -- [ ] Dynamic HTML/RSC/API Vary/cache-key normalization is not hard-coded in this PR and is deferred to #859. -- [ ] SSAT streaming fixes and compression offload are not included in this PR and remain tracked by #857 and #858. +- [x] Cache policy is represented as structured fields, not hard-coded header strings. +- [x] Built-in framework presets, including a disableable/overrideable Next.js `/_next/static/*` rule, are implemented through the shared cache-policy rule engine. +- [x] TSJS hash-matching requests for unified and deferred modules emit `public, max-age=31536000, immutable` plus the runtime edge header. +- [x] TSJS missing/mismatched hash requests do not get immutable caching. +- [x] TSJS hash generation is build-time or cached enough that HTML injection does not re-concatenate/re-hash the bundle per pageview. +- [ ] Runtime cache-key configuration preserves the `v` query parameter for `/static/tsjs=`. Runtime verification is tracked in #908. +- [x] Neutralized publisher Prebid shim responses use `no-store` or a short TTL, not a year-long policy. +- [x] Arbitrary publisher-origin assets remain origin-controlled unless covered by an enabled framework preset or publisher allowlist. +- [x] Fastly TS-owned rehosted assets have explicit normalized cache policy; immutable is used only for TS-fingerprinted rehosted URLs. +- [x] SSAT-assembled ad-stack HTML continues to emit `private, max-age=0` and strips all runtime edge-cache headers. +- [x] Fastly and Cloudflare adapters emit the correct edge-cache header from the shared policy, with portable `s-maxage` fallback where needed. Actual shared-cache storage remains tracked in #908. +- [x] Dynamic HTML/RSC/API Vary/cache-key normalization is not hard-coded in this PR and is deferred to #859. +- [x] SSAT streaming fixes and compression offload are not included in this PR and remain tracked by #857 and #858. diff --git a/trusted-server.example.toml b/trusted-server.example.toml index 2b46336ab..ca566fcb6 100644 --- a/trusted-server.example.toml +++ b/trusted-server.example.toml @@ -116,8 +116,10 @@ enabled = false # Required for integrations.prebid.external_bundle_url and first-party proxy redirects. # allowed_domains = ["ads.example.com", "assets.example.com", "*.cdn.example.com"] -# Static/rehosted asset cache policies are operator-controlled. Keep framework -# presets disabled unless the matched publisher paths are known content-addressed. +# Static/rehosted asset cache policies are operator-controlled. Disabled rules +# do not match, and matcher/policy validation is deferred until they are enabled; +# IDs must still be nonempty and unique. Keep rules disabled unless the matched +# publisher paths are known content-addressed. # [[cache.asset_rules]] # id = "nextjs-static" # enabled = false @@ -131,6 +133,8 @@ enabled = false # id = "publisher-fingerprinted-assets" # enabled = false # path_globs = ["/assets/**/*.js", "/assets/**/*.css", "/assets/**/*.png", "/assets/**/*.webp"] +# Immutable custom rules require a supported name-delimited bundler fingerprint +# immediately before the extension, for example app.0123abcd.js or app-VRTVD5R5.js. # requires_hash_in_filename = true # visibility = "public" # browser_ttl_seconds = 31536000 From 2de213d152682425c4c615cb70ae46c74e0abd11 Mon Sep 17 00:00:00 2001 From: Christian Date: Fri, 17 Jul 2026 10:12:00 -0500 Subject: [PATCH 4/8] Fix cache policy clippy warnings --- .../trusted-server-core/src/cache_policy.rs | 25 +++++++++---------- crates/trusted-server-core/src/proxy.rs | 8 +++--- 2 files changed, 16 insertions(+), 17 deletions(-) diff --git a/crates/trusted-server-core/src/cache_policy.rs b/crates/trusted-server-core/src/cache_policy.rs index 5b2b09a3e..39bac467c 100644 --- a/crates/trusted-server-core/src/cache_policy.rs +++ b/crates/trusted-server-core/src/cache_policy.rs @@ -159,13 +159,12 @@ impl CachePolicy { directives.push(format!("max-age={}", ttl.as_secs())); } - if edge_header == EdgeCacheHeader::SMaxageFallback { - if let Some(ttl) = self + if edge_header == EdgeCacheHeader::SMaxageFallback + && let Some(ttl) = self .edge_ttl .filter(|_| self.visibility == CacheVisibility::Public) - { - directives.push(format!("s-maxage={}", ttl.as_secs())); - } + { + directives.push(format!("s-maxage={}", ttl.as_secs())); } if let Some(ttl) = self.stale_while_revalidate { @@ -226,14 +225,14 @@ impl CachePolicy { ); remove_edge_cache_headers(headers); - if let Some(header_name) = edge_header.header_name() { - if let Some(value) = self.edge_header_value(edge_header) { - headers.insert( - header_name, - HeaderValue::from_str(&value) - .expect("should render a valid edge cache-control header"), - ); - } + if let Some(header_name) = edge_header.header_name() + && let Some(value) = self.edge_header_value(edge_header) + { + headers.insert( + header_name, + HeaderValue::from_str(&value) + .expect("should render a valid edge cache-control header"), + ); } } } diff --git a/crates/trusted-server-core/src/proxy.rs b/crates/trusted-server-core/src/proxy.rs index b51cd75f6..a2ff43fca 100644 --- a/crates/trusted-server-core/src/proxy.rs +++ b/crates/trusted-server-core/src/proxy.rs @@ -1228,10 +1228,10 @@ pub async fn handle_asset_proxy_request( strip_asset_proxy_response_headers(response.response_mut()); let status = response.response().status(); - if status.is_success() || status == StatusCode::NOT_MODIFIED { - if let Some(policy) = settings.asset_cache_policy_for_path(incoming_path)? { - response.apply_normalized_cache_policy(policy); - } + if (status.is_success() || status == StatusCode::NOT_MODIFIED) + && let Some(policy) = settings.asset_cache_policy_for_path(incoming_path)? + { + response.apply_normalized_cache_policy(policy); } Ok(response) From d6920d45d45a23737b8c657568659d862337f49c Mon Sep 17 00:00:00 2001 From: Christian Date: Wed, 29 Jul 2026 10:46:10 -0500 Subject: [PATCH 5/8] Fix publisher test cache policy argument --- crates/trusted-server-core/src/publisher.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 674d3d718..9cd70290a 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -9442,6 +9442,7 @@ mod tests { registry: None, }, req, + EdgeCacheHeader::SurrogateControl, ) .await .expect("should proxy publisher request"); From 3b9063f72d3b210ea4c93b829b9e4f53452b0028 Mon Sep 17 00:00:00 2001 From: Christian Date: Wed, 29 Jul 2026 13:51:09 -0500 Subject: [PATCH 6/8] Preserve integration configuration values on rebase --- crates/trusted-server-core/src/settings.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index effbf613f..870014c2d 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -2429,7 +2429,6 @@ impl Settings { mut settings: Self, validation_label: &str, ) -> Result> { - settings.integrations.normalize(); settings.cache.normalize(); settings.proxy.normalize(); settings.image_optimizer.normalize(); From f4169fb6e277c534cc3fb05c9d8b5aee0ab7463b Mon Sep 17 00:00:00 2001 From: Christian Date: Thu, 30 Jul 2026 12:57:39 -0500 Subject: [PATCH 7/8] Harden configurable asset cache rules --- crates/trusted-server-core/src/proxy.rs | 4 +- crates/trusted-server-core/src/publisher.rs | 15 +- crates/trusted-server-core/src/settings.rs | 316 ++++++++++++------ crates/trusted-server-js/src/bundle.rs | 7 +- docs/guide/configuration.md | 45 +-- ...ache-control-header-implementation-plan.md | 2 +- trusted-server.example.toml | 6 +- 7 files changed, 262 insertions(+), 133 deletions(-) diff --git a/crates/trusted-server-core/src/proxy.rs b/crates/trusted-server-core/src/proxy.rs index a2ff43fca..2ad8091fd 100644 --- a/crates/trusted-server-core/src/proxy.rs +++ b/crates/trusted-server-core/src/proxy.rs @@ -4353,7 +4353,7 @@ mod tests { id = "fingerprinted-assets" enabled = true path_globs = ["/assets/**/*.js"] - requires_hash_in_filename = true + fingerprint_style = "hex" visibility = "public" browser_ttl_seconds = 31536000 edge_ttl_seconds = 31536000 @@ -4463,7 +4463,7 @@ mod tests { id = "fingerprinted-assets" enabled = true path_globs = ["/assets/**/*.js"] - requires_hash_in_filename = true + fingerprint_style = "hex" visibility = "public" browser_ttl_seconds = 31536000 edge_ttl_seconds = 31536000 diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 9cd70290a..df57a3450 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -1081,11 +1081,12 @@ fn response_cache_control_is_private_or_no_store(response: &Response) fn apply_publisher_asset_cache_policy( settings: &Settings, path: &str, - cache_rule_method: bool, + method: &Method, edge_header: EdgeCacheHeader, response: &mut Response, ) -> Result<(), Report> { - if !cache_rule_method || response_cache_control_is_private_or_no_store(response) { + let is_cacheable_method = *method == Method::GET || *method == Method::HEAD; + if !is_cacheable_method || response_cache_control_is_private_or_no_store(response) { return Ok(()); } @@ -2659,8 +2660,8 @@ pub async fn handle_publisher_request( log::debug!("Proxying request to configured publisher backend"); let request_path = req.uri().path().to_string(); - let is_get = req.method() == http::Method::GET; - let cache_rule_method = req.method() == Method::GET || req.method() == Method::HEAD; + let request_method = req.method().clone(); + let is_get = request_method == Method::GET; let is_prefetch = is_prefetch_request(&req); let is_bot = is_bot_user_agent(&req); @@ -2944,7 +2945,7 @@ pub async fn handle_publisher_request( apply_publisher_asset_cache_policy( settings, &request_path, - cache_rule_method, + &request_method, edge_header, &mut response, )?; @@ -4432,7 +4433,7 @@ mod tests { id = "publisher-fingerprinted-assets" enabled = true path_globs = ["/assets/**/*.png"] - requires_hash_in_filename = true + fingerprint_style = "hex" visibility = "public" browser_ttl_seconds = 31536000 edge_ttl_seconds = 31536000 @@ -4523,7 +4524,7 @@ mod tests { id = "publisher-fingerprinted-assets" enabled = true path_globs = ["/assets/**/*.png"] - requires_hash_in_filename = true + fingerprint_style = "hex" visibility = "public" browser_ttl_seconds = 31536000 edge_ttl_seconds = 31536000 diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index 870014c2d..da61dd84a 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -1,7 +1,7 @@ #[cfg(test)] use config::{Config, Environment, File, FileFormat}; use error_stack::{Report, ResultExt}; -use glob::Pattern; +use glob::{MatchOptions, Pattern}; use regex::Regex; use serde::{Deserialize, Deserializer, Serialize, de::DeserializeOwned}; use serde_json::Value as JsonValue; @@ -1957,9 +1957,9 @@ pub struct CacheAssetRule { /// File extensions matched against the request path, case-insensitively. #[serde(default)] pub extensions: Vec, - /// Require a supported bundler fingerprint suffix in the filename before matching. + /// Bundler fingerprint style required in the filename before matching. #[serde(default)] - pub requires_hash_in_filename: bool, + pub fingerprint_style: Option, /// Browser-facing cache visibility. #[serde(default)] pub visibility: CachePolicyVisibility, @@ -2083,10 +2083,10 @@ impl CacheAssetRule { let preset_is_content_addressed = matches!(self.preset, Some(CacheAssetPreset::NextJsStatic)); - if !preset_is_content_addressed && !self.requires_hash_in_filename { + if !preset_is_content_addressed && self.fingerprint_style.is_none() { return Err(Report::new(TrustedServerError::Configuration { message: format!( - "cache.asset_rules `{}` sets immutable without requires_hash_in_filename or a content-addressed preset", + "cache.asset_rules `{}` sets immutable without fingerprint_style or a content-addressed preset", self.id ), })); @@ -2119,16 +2119,16 @@ impl CacheAssetRule { } match self.compiled_globs.get_or_init(|| { - if let Some(glob) = self.path_glob.as_deref() { - Pattern::new(glob) - .map(|pattern| vec![pattern]) - .map_err(|err| err.to_string()) - } else { - self.path_globs - .iter() - .map(|pattern| Pattern::new(pattern).map_err(|err| err.to_string())) - .collect() + let mut compiled = Vec::new(); + let source_patterns = self + .path_glob + .iter() + .chain(self.path_globs.iter()) + .map(String::as_str); + for pattern in source_patterns { + compile_cache_asset_glob_patterns(pattern, &mut compiled)?; } + Ok(compiled) }) { Ok(patterns) => Ok(Some(patterns.as_slice())), Err(message) => Err(Report::new(TrustedServerError::Configuration { @@ -2145,9 +2145,11 @@ impl CacheAssetRule { return Ok(false); } - if self.requires_hash_in_filename && !filename_contains_fingerprint(path) { + if let Some(style) = self.fingerprint_style + && !filename_contains_fingerprint(path, style) + { log::debug!( - "cache asset rule `{}` rejects path `{path}` because the filename has no supported fingerprint", + "cache asset rule `{}` rejects path `{path}` because the filename has no {style:?} fingerprint", self.id ); return Ok(false); @@ -2164,7 +2166,9 @@ impl CacheAssetRule { return Ok(path.starts_with(prefix)); } if let Some(patterns) = self.compiled_globs()? { - return Ok(patterns.iter().any(|pattern| pattern.matches(path))); + return Ok(patterns + .iter() + .any(|pattern| pattern.matches_with(path, CACHE_ASSET_GLOB_MATCH_OPTIONS))); } if let Some(regex) = self.compiled_regex()? { return Ok(regex.is_match(path)); @@ -2191,6 +2195,30 @@ impl CacheAssetRule { } } +const CACHE_ASSET_GLOB_MATCH_OPTIONS: MatchOptions = MatchOptions { + case_sensitive: true, + require_literal_separator: true, + require_literal_leading_dot: false, +}; + +fn compile_cache_asset_glob_patterns( + pattern: &str, + compiled: &mut Vec, +) -> Result<(), String> { + compiled.push(Pattern::new(pattern).map_err(|err| err.to_string())?); + + if let Some(optional_recursive_start) = pattern.find("**/") { + let without_recursive_segment = format!( + "{}{}", + &pattern[..optional_recursive_start], + &pattern[optional_recursive_start + "**/".len()..] + ); + compile_cache_asset_glob_patterns(&without_recursive_segment, compiled)?; + } + + Ok(()) +} + /// Built-in cache-rule presets that operators can enable explicitly. #[derive(Debug, Clone, Copy, Deserialize, Serialize, Eq, PartialEq)] #[serde(rename_all = "kebab-case")] @@ -2234,7 +2262,48 @@ fn path_extension(path: &str) -> Option { (!extension.is_empty()).then(|| extension.to_ascii_lowercase()) } -fn filename_contains_fingerprint(path: &str) -> bool { +/// Operator-selected filename fingerprint convention for an immutable custom rule. +#[derive(Debug, Clone, Copy, Deserialize, Serialize, Eq, PartialEq)] +#[serde(rename_all = "kebab-case")] +pub enum CacheAssetFingerprintStyle { + /// A hexadecimal suffix, such as `app.0123abcd.js`. + Hex, + /// An eight-character uppercase Base32 suffix, such as `app-VRTVD5R5.js`. + EsbuildBase32, + /// An eight-character `Base64URL` suffix, such as `index-BsELY24f.js`. + ViteBase64Url, +} + +impl CacheAssetFingerprintStyle { + fn matches_candidate(self, candidate: &str) -> bool { + match self { + Self::Hex => { + candidate.len() >= 8 + && candidate.chars().all(|ch| ch.is_ascii_hexdigit()) + && candidate.chars().any(|ch| ch.is_ascii_alphabetic()) + } + Self::EsbuildBase32 => { + candidate.len() == 8 + && candidate + .chars() + .all(|ch| ch.is_ascii_uppercase() || matches!(ch, '2'..='7')) + && candidate.chars().any(|ch| ch.is_ascii_alphabetic()) + } + Self::ViteBase64Url => { + candidate.len() == 8 + && candidate + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_')) + && candidate.chars().any(|ch| ch.is_ascii_uppercase()) + && candidate.chars().any(|ch| { + ch.is_ascii_lowercase() || ch.is_ascii_digit() || matches!(ch, '-' | '_') + }) + } + } + } +} + +fn filename_contains_fingerprint(path: &str, style: CacheAssetFingerprintStyle) -> bool { let filename = path.rsplit('/').next().unwrap_or(path); let Some((stem, extension)) = filename.rsplit_once('.') else { return false; @@ -2249,31 +2318,10 @@ fn filename_contains_fingerprint(path: &str) -> bool { let candidate_start = separator_index + separator.len_utf8(); let prefix = &stem[..separator_index]; let candidate = &stem[candidate_start..]; - !prefix.is_empty() && fingerprint_candidate_is_supported(candidate) + !prefix.is_empty() && style.matches_candidate(candidate) }) } -fn fingerprint_candidate_is_supported(candidate: &str) -> bool { - let is_hex = candidate.len() >= 8 - && candidate.chars().all(|ch| ch.is_ascii_hexdigit()) - && candidate.chars().any(|ch| ch.is_ascii_alphabetic()); - let is_esbuild_base32 = candidate.len() == 8 - && candidate - .chars() - .all(|ch| ch.is_ascii_uppercase() || matches!(ch, '2'..='7')) - && candidate.chars().any(|ch| ch.is_ascii_alphabetic()); - let is_vite_base64url = candidate.len() == 8 - && candidate - .chars() - .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_')) - && candidate.chars().any(|ch| ch.is_ascii_uppercase()) - && candidate - .chars() - .any(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || matches!(ch, '-' | '_')); - - is_hex || is_esbuild_base32 || is_vite_base64url -} - /// Debug-only features. All flags default to `false` (off in production). #[derive(Debug, Default, Clone, Deserialize, Serialize)] #[serde(deny_unknown_fields)] @@ -3194,77 +3242,149 @@ mod tests { } #[test] - fn cache_asset_rule_requires_hash_in_filename_when_configured() { - let toml_str = format!( - r#"{} - - [[cache.asset_rules]] - id = "publisher-assets" - enabled = true - path_globs = ["/assets/**/*.js"] - requires_hash_in_filename = true - visibility = "public" - browser_ttl_seconds = 31536000 - edge_ttl_seconds = 31536000 - immutable = true - "#, - crate_test_settings_str() - ); - let settings = Settings::from_toml(&toml_str).expect("should parse cache asset rule"); + fn cache_asset_rule_requires_selected_fingerprint_style() { let expected_policy = CachePolicy::public_immutable(Duration::from_secs(31_536_000)); - - for path in [ - "/assets/app.0123abcd.js", - "/assets/index-DA15JTLU.js", - "/assets/index-BsELY24f.js", - "/assets/app-VRTVD5R5.js", - "/assets/app-VCMCQCKZ.js", + for (style, matching_path, non_matching_path) in [ + ("hex", "/assets/app.0123abcd.js", "/assets/app-VRTVD5R5.js"), + ( + "esbuild-base32", + "/assets/app-VRTVD5R5.js", + "/assets/index-BsELY24f.js", + ), + ( + "vite-base64-url", + "/assets/index-BsELY24f.js", + "/assets/app.0123abcd.js", + ), ] { + let toml_str = format!( + r#"{} + + [[cache.asset_rules]] + id = "publisher-assets" + enabled = true + path_globs = ["/assets/**/*.js"] + fingerprint_style = "{style}" + visibility = "public" + browser_ttl_seconds = 31536000 + edge_ttl_seconds = 31536000 + immutable = true + "#, + crate_test_settings_str() + ); + let settings = Settings::from_toml(&toml_str).expect("should parse cache asset rule"); + assert_eq!( settings - .asset_cache_policy_for_path(path) + .asset_cache_policy_for_path(matching_path) .expect("should evaluate cache rules"), Some(expected_policy), - "supported fingerprint should match asset rule for {path}" + "{style} should match its configured fingerprint convention" ); - } - - for path in ["/assets/app.js", "/assets/deadbeef.js"] { assert!( settings - .asset_cache_policy_for_path(path) + .asset_cache_policy_for_path(non_matching_path) .expect("should evaluate cache rules") .is_none(), - "non-fingerprinted filename should not match asset rule for {path}" + "{style} should not fall through to another fingerprint convention" ); } } #[test] - fn filename_fingerprint_gate_supports_conservative_bundler_suffixes() { - for (path, expected) in [ - ("/assets/index-DA15JTLU.js", true), - ("/assets/index-BsELY24f.js", true), - ("/assets/index-aB_cD-12.js", true), - ("/assets/app-VRTVD5R5.js", true), - ("/assets/app-VCMCQCKZ.js", true), - ("/assets/app.a1B2c3D4.js", true), - ("/assets/main.a1b2c3d4e5f6.js", true), - ("/assets/index.8f3a2b1c.js", true), - ("/assets/app.deadbeef.js", true), - ("/assets/deadbeef.js", false), - ("/assets/VCMCQCKZ.js", false), - ("/assets/app.js", false), - ("/assets/app-manifest.js", false), - ("/assets/app-release2.js", false), - ("/assets/app.20260714.js", false), - ("/assets/app-abc123.js", false), - ("/assets/deadbeef/app.js", false), + fn filename_fingerprint_gate_matches_only_the_selected_style() { + for (style, path, expected) in [ + ( + CacheAssetFingerprintStyle::Hex, + "/assets/app.0123abcd.js", + true, + ), + ( + CacheAssetFingerprintStyle::Hex, + "/assets/hero-Portrait.jpg", + false, + ), + ( + CacheAssetFingerprintStyle::EsbuildBase32, + "/assets/app-VRTVD5R5.js", + true, + ), + ( + CacheAssetFingerprintStyle::EsbuildBase32, + "/assets/hero-Portrait.jpg", + false, + ), + ( + CacheAssetFingerprintStyle::ViteBase64Url, + "/assets/index-BsELY24f.js", + true, + ), + ( + CacheAssetFingerprintStyle::ViteBase64Url, + "/assets/hero-Portrait.jpg", + true, + ), + ( + CacheAssetFingerprintStyle::ViteBase64Url, + "/assets/app.js", + false, + ), + ( + CacheAssetFingerprintStyle::ViteBase64Url, + "/assets/deadbeef.js", + false, + ), ] { assert_eq!( - filename_contains_fingerprint(path), + filename_contains_fingerprint(path, style), expected, - "fingerprint result should match for {path}" + "{style:?} fingerprint result should match for {path}" + ); + } + } + + #[test] + fn cache_asset_rule_globs_respect_path_separators() { + let toml_str = format!( + r#"{} + + [[cache.asset_rules]] + id = "direct-assets" + enabled = true + path_glob = "/assets/*.js" + browser_ttl_seconds = 300 + "#, + crate_test_settings_str() + ); + let settings = Settings::from_toml(&toml_str).expect("should parse cache asset rule"); + + assert!( + settings + .asset_cache_policy_for_path("/assets/app.js") + .expect("should evaluate direct asset rule") + .is_some(), + "single-star glob should match a direct child" + ); + for path in ["/assets/vendor/app.js", "/assets/app.JS"] { + assert!( + settings + .asset_cache_policy_for_path(path) + .expect("should evaluate direct asset rule") + .is_none(), + "single-star glob should not match {path}" + ); + } + + let recursive_toml = toml_str.replace("/assets/*.js", "/assets/**/*.js"); + let recursive_settings = + Settings::from_toml(&recursive_toml).expect("should parse recursive cache asset rule"); + for path in ["/assets/app.js", "/assets/vendor/app.js"] { + assert!( + recursive_settings + .asset_cache_policy_for_path(path) + .expect("should evaluate recursive asset rule") + .is_some(), + "double-star glob should match {path}" ); } } @@ -3322,7 +3442,7 @@ mod tests { "should explain missing TTL: {missing_ttl_err:?}" ); - let immutable_without_fingerprint = format!( + let immutable_without_fingerprint_style = format!( r#"{} [[cache.asset_rules]] @@ -3334,11 +3454,11 @@ mod tests { "#, crate_test_settings_str() ); - let fingerprint_err = Settings::from_toml(&immutable_without_fingerprint) - .expect_err("should reject immutable rule without fingerprint requirement"); + let fingerprint_style_err = Settings::from_toml(&immutable_without_fingerprint_style) + .expect_err("should reject immutable rule without a fingerprint style"); assert!( - format!("{fingerprint_err:?}").contains("requires_hash_in_filename"), - "should explain immutable fingerprint requirement: {fingerprint_err:?}" + format!("{fingerprint_style_err:?}").contains("fingerprint_style"), + "should explain immutable fingerprint-style requirement: {fingerprint_style_err:?}" ); let immutable_without_browser_ttl = format!( @@ -3348,7 +3468,7 @@ mod tests { id = "immutable-without-browser-ttl" enabled = true path_prefix = "/assets/" - requires_hash_in_filename = true + fingerprint_style = "hex" browser_ttl_seconds = 0 edge_ttl_seconds = 31536000 immutable = true diff --git a/crates/trusted-server-js/src/bundle.rs b/crates/trusted-server-js/src/bundle.rs index 83815654a..be5aa35cc 100644 --- a/crates/trusted-server-js/src/bundle.rs +++ b/crates/trusted-server-js/src/bundle.rs @@ -36,9 +36,10 @@ pub fn concatenate_modules(ids: &[&str]) -> String { /// SHA-256 hash of the concatenated modules, for cache-busting URLs. /// /// The hash is computed over the same byte sequence as [`concatenate_modules`] -/// without allocating that concatenated body. Results are cached by ordered -/// module ID list so HTML injection does not re-hash the full JS payload on -/// every page view. +/// without allocating that concatenated body. Results are memoized by ordered +/// module ID list for reused processes or isolates. Fastly creates a fresh Wasm +/// instance per request, but still benefits from hashing without materializing +/// the concatenated body. #[must_use] #[inline] pub fn concatenated_hash(ids: &[&str]) -> String { diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index e085a23c1..96389be78 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -1056,7 +1056,7 @@ and unique, including for disabled placeholders. | `path_globs` | Array[String] | Matcher | Multiple globs matched against the request path | | `path_regex` | String | Matcher | Regex matched against the request path | | `extensions` | Array[String] | Matcher | Case-insensitive file extensions | -| `requires_hash_in_filename` | Boolean | No | Require a supported bundler fingerprint suffix before matching | +| `fingerprint_style` | String | No | Required bundler fingerprint convention before matching | | `visibility` | String | No | `public` or `private` (default `public`) | | `browser_ttl_seconds` | Integer | Policy | Browser `max-age`; must be positive when `immutable = true` | | `edge_ttl_seconds` | Integer | Policy | TTL emitted through the runtime-specific shared-cache directive | @@ -1067,23 +1067,30 @@ and unique, including for disabled placeholders. An enabled rule must configure exactly one matcher and at least one of `browser_ttl_seconds` or `edge_ttl_seconds`. `path_glob` and `path_globs` are mutually exclusive. `immutable = true` additionally requires a positive browser -TTL and either the content-addressed `nextjs-static` preset or -`requires_hash_in_filename = true`. - -The filename fingerprint check is intentionally conservative. It examines the -suffix immediately before the final extension, requires a nonempty filename -prefix separated by `.`, `-`, `_`, or `~`, and recognizes: - -- hexadecimal suffixes of at least eight characters containing a letter; -- eight-character esbuild-style uppercase Base32 suffixes; -- eight-character Vite/Base64URL-style suffixes with a mixed character class. - -For example, `app.0123abcd.js`, `app-VRTVD5R5.js`, and -`index-DA15JTLU.js` match, while `app.js`, `deadbeef.js`, and -`app.20260714.js` do not. This heuristic is not proof of content addressing; -confirm the publisher's bundler output before enabling a long immutable TTL. A -base rule that matches while this fingerprint check fails emits a debug log with -the rule ID and rejected path. +TTL and either the content-addressed `nextjs-static` preset or an explicit +`fingerprint_style`. + +The filename fingerprint check is intentionally conservative and style-specific. +It examines the suffix immediately before the final extension and requires a +nonempty filename prefix separated by `.`, `-`, `_`, or `~`. Set exactly the +style emitted by the publisher's bundler: + +- `hex`: hexadecimal suffixes of at least eight characters containing a letter, + such as `app.0123abcd.js`; +- `esbuild-base32`: eight-character uppercase Base32 suffixes, such as + `app-VRTVD5R5.js`; +- `vite-base64-url`: eight-character Base64URL suffixes with a mixed character + class, such as `index-BsELY24f.js`. + +A style is an explicit operator assertion, not proof of content addressing. +For example, some human-written mixed-case names can resemble a Vite suffix, so +only select `vite-base64-url` after verifying the publisher's build output. A +base rule that matches while its selected fingerprint style fails emits a debug +log with the rule ID and rejected path. + +Glob patterns are case-sensitive. `*` matches within a single path component, +while `**` matches recursively: `/assets/*.js` matches `/assets/app.js` but not +`/assets/vendor/app.js`; `/assets/**/*.js` matches both. **Next.js preset example** (disabled until the publisher confirms `/_next/static/` is content-addressed): @@ -1112,7 +1119,7 @@ path_globs = [ "/assets/**/*.png", "/assets/**/*.webp", ] -requires_hash_in_filename = true +fingerprint_style = "vite-base64-url" visibility = "public" browser_ttl_seconds = 31536000 edge_ttl_seconds = 31536000 diff --git a/docs/superpowers/plans/2026-07-06-cache-control-header-implementation-plan.md b/docs/superpowers/plans/2026-07-06-cache-control-header-implementation-plan.md index ce2c37956..8546ebf6e 100644 --- a/docs/superpowers/plans/2026-07-06-cache-control-header-implementation-plan.md +++ b/docs/superpowers/plans/2026-07-06-cache-control-header-implementation-plan.md @@ -340,7 +340,7 @@ path_globs = [ "/assets/**/*.webp", "/assets/**/*.avif", ] -requires_hash_in_filename = true +fingerprint_style = "vite-base64-url" visibility = "public" browser_ttl_seconds = 31536000 edge_ttl_seconds = 31536000 diff --git a/trusted-server.example.toml b/trusted-server.example.toml index ca566fcb6..ed4afa1cb 100644 --- a/trusted-server.example.toml +++ b/trusted-server.example.toml @@ -133,9 +133,9 @@ enabled = false # id = "publisher-fingerprinted-assets" # enabled = false # path_globs = ["/assets/**/*.js", "/assets/**/*.css", "/assets/**/*.png", "/assets/**/*.webp"] -# Immutable custom rules require a supported name-delimited bundler fingerprint -# immediately before the extension, for example app.0123abcd.js or app-VRTVD5R5.js. -# requires_hash_in_filename = true +# Immutable custom rules require an explicit fingerprint_style selected for the +# publisher's bundler, for example "hex", "esbuild-base32", or "vite-base64-url". +# fingerprint_style = "vite-base64-url" # visibility = "public" # browser_ttl_seconds = 31536000 # edge_ttl_seconds = 31536000 From fd6d832488126f08b9b418b7fe35056a8cfc999b Mon Sep 17 00:00:00 2001 From: Christian Date: Mon, 17 Aug 2026 13:27:56 -0500 Subject: [PATCH 8/8] Resolve cache policy review feedback --- .../wrangler.ci.toml | 3 - .../wrangler.toml | 3 - .../trusted-server-core/src/cache_policy.rs | 32 +- .../src/integrations/registry.rs | 7 + crates/trusted-server-core/src/publisher.rs | 449 +++++++++++++++--- .../src/response_privacy.rs | 29 ++ crates/trusted-server-core/src/settings.rs | 72 ++- .../tests/common/ec.rs | 76 +++ .../tests/integration.rs | 50 ++ docs/guide/configuration.md | 61 +-- 10 files changed, 672 insertions(+), 110 deletions(-) diff --git a/crates/trusted-server-adapter-cloudflare/wrangler.ci.toml b/crates/trusted-server-adapter-cloudflare/wrangler.ci.toml index 88a2dfc74..e6891eb79 100644 --- a/crates/trusted-server-adapter-cloudflare/wrangler.ci.toml +++ b/crates/trusted-server-adapter-cloudflare/wrangler.ci.toml @@ -6,9 +6,6 @@ compatibility_date = "2024-09-23" compatibility_flags = ["nodejs_compat", "cache_option_enabled"] # No [build] section — bundle is pre-built in CI; wrangler dev must not rebuild. -[cache] -enabled = true - [[kv_namespaces]] binding = "TRUSTED_SERVER_KV" id = "ci-local-kv" diff --git a/crates/trusted-server-adapter-cloudflare/wrangler.toml b/crates/trusted-server-adapter-cloudflare/wrangler.toml index 9acdb13ab..7c91173fc 100644 --- a/crates/trusted-server-adapter-cloudflare/wrangler.toml +++ b/crates/trusted-server-adapter-cloudflare/wrangler.toml @@ -11,9 +11,6 @@ compatibility_date = "2024-09-23" # (auction-eligible publisher navigations), so this is a hard requirement. compatibility_flags = ["nodejs_compat", "cache_option_enabled"] -[cache] -enabled = true - [build] command = "bash build.sh" diff --git a/crates/trusted-server-core/src/cache_policy.rs b/crates/trusted-server-core/src/cache_policy.rs index 39bac467c..4d920f54a 100644 --- a/crates/trusted-server-core/src/cache_policy.rs +++ b/crates/trusted-server-core/src/cache_policy.rs @@ -289,14 +289,38 @@ pub fn is_edge_cache_header_name(name: &str) -> bool { /// as `not-private` or `no-storey` do not match `private` / `no-store`. #[must_use] pub fn cache_control_value_has_directive(value: &str, directive: &str) -> bool { - value.split(',').any(|part| { + let part_has_directive = |part: &str| { let part = part.trim(); let directive_name = part .find(['=', ';']) .map_or(part, |end| &part[..end]) .trim(); directive_name.eq_ignore_ascii_case(directive) - }) + }; + + let mut quoted = false; + let mut escaped = false; + let mut part_start = 0; + for (index, character) in value.char_indices() { + if quoted { + if escaped { + escaped = false; + } else if character == '\\' { + escaped = true; + } else if character == '"' { + quoted = false; + } + } else if character == '"' { + quoted = true; + } else if character == ',' { + if part_has_directive(&value[part_start..index]) { + return true; + } + part_start = index + character.len_utf8(); + } + } + + part_has_directive(&value[part_start..]) } /// Return true when any `Cache-Control` header value contains `directive`. @@ -544,6 +568,10 @@ mod tests { !cache_control_value_has_directive("public, no-storey, not-private", "private"), "should not match pseudo-private directives by substring" ); + assert!( + !cache_control_value_has_directive("public, ext=\"a,no-store,b\"", "no-store"), + "should ignore directive-shaped text inside quoted extension values" + ); } #[test] diff --git a/crates/trusted-server-core/src/integrations/registry.rs b/crates/trusted-server-core/src/integrations/registry.rs index 16cbac868..ed7970eaf 100644 --- a/crates/trusted-server-core/src/integrations/registry.rs +++ b/crates/trusted-server-core/src/integrations/registry.rs @@ -1134,6 +1134,13 @@ impl IntegrationRegistry { ids } + /// Return whether an integration is enabled, including integrations whose + /// JavaScript is delivered outside the standard module bundles. + #[must_use] + pub fn is_enabled(&self, integration_id: &str) -> bool { + self.inner.enabled_integration_ids.contains(&integration_id) + } + /// Return JS module IDs for the main (synchronous) bundle, excluding /// modules registered with [`with_deferred_js`](IntegrationRegistrationBuilder::with_deferred_js). #[must_use] diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index df57a3450..6e94f16f6 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -277,11 +277,13 @@ fn accept_encoding_qvalue(header_value: &str, target: &str) -> Option { /// Unified tsjs static serving: `/static/tsjs=` /// -/// Serves two types of bundles: +/// Serves three types of bundles: /// - **Unified bundle** (`tsjs-unified.min.js`): core + immediate (non-deferred) /// integration modules. /// - **Deferred module** (`tsjs-{id}.min.js`): a single self-contained IIFE for -/// modules loaded with `defer` (e.g., prebid). +/// modules loaded with `defer` (e.g., Prebid). +/// - **Standalone diagnostics module** (`tsjs-gpt_diagnostics.min.js`): delivered +/// only when the diagnostics integration is enabled and a document activates it. /// /// # Errors /// @@ -309,9 +311,11 @@ pub fn handle_tsjs_dynamic( } if let Some(module_id) = parse_deferred_module_filename(filename) { - // Only serve if the deferred module is actually enabled let deferred_ids = integration_registry.js_module_ids_deferred(); - if !deferred_ids.contains(&module_id) { + let is_enabled_diagnostics_module = module_id + == crate::integrations::gpt_diagnostics::GPT_DIAGNOSTICS_INTEGRATION_ID + && integration_registry.is_enabled(module_id); + if !deferred_ids.contains(&module_id) && !is_enabled_diagnostics_module { return Ok(not_found_response()); } if let (Some(content), Some(hash)) = ( @@ -381,6 +385,9 @@ struct ProcessResponseParams<'a> { integration_registry: &'a IntegrationRegistry, ad_slots_script: Option<&'a str>, ad_bids_state: &'a Arc>>, + suppress_datadome_client_side_tag: bool, + gpt_diagnostics: + Option<&'a crate::integrations::gpt_diagnostics::GptDiagnosticsRequestDecision>, } struct PublisherBodyProcessor { @@ -397,15 +404,17 @@ impl PublisherBodyProcessor { let is_rsc_flight = content_type_contains_ascii_case_insensitive(¶ms.content_type, "text/x-component"); let inner: Box = if is_html { - Box::new(create_html_stream_processor( - ¶ms.origin_host, - ¶ms.request_host, - ¶ms.request_scheme, + Box::new(create_html_stream_processor(HtmlStreamProcessorParams { + origin_host: ¶ms.origin_host, + request_host: ¶ms.request_host, + request_scheme: ¶ms.request_scheme, settings, integration_registry, - params.ad_slots_script.as_deref().map(str::to_string), - Arc::clone(¶ms.ad_bids_state), - )?) + ad_slots_script: params.ad_slots_script.as_deref().map(str::to_string), + ad_bids_state: Arc::clone(¶ms.ad_bids_state), + suppress_datadome_client_side_tag: params.suppress_datadome_client_side_tag, + gpt_diagnostics: params.gpt_diagnostics.clone(), + })?) } else if is_rsc_flight { Box::new(RscFlightUrlRewriter::new( ¶ms.origin_host, @@ -473,15 +482,17 @@ fn process_response_streaming( let max_pending_decoded_bytes = params.settings.publisher.max_buffered_body_bytes; if is_html { - let processor = create_html_stream_processor( - params.origin_host, - params.request_host, - params.request_scheme, - params.settings, - params.integration_registry, - params.ad_slots_script.map(str::to_string), - params.ad_bids_state.clone(), - )?; + let processor = create_html_stream_processor(HtmlStreamProcessorParams { + origin_host: params.origin_host, + request_host: params.request_host, + request_scheme: params.request_scheme, + settings: params.settings, + integration_registry: params.integration_registry, + ad_slots_script: params.ad_slots_script.map(str::to_string), + ad_bids_state: params.ad_bids_state.clone(), + suppress_datadome_client_side_tag: params.suppress_datadome_client_side_tag, + gpt_diagnostics: params.gpt_diagnostics.cloned(), + })?; StreamingPipeline::new(config, processor) .with_max_pending_decoded_bytes(max_pending_decoded_bytes) .process(body_as_reader(body)?, output)?; @@ -956,25 +967,33 @@ async fn hold_finish_tail_segments( /// `use<>` states that explicitly: without it, Rust 2024 would have the opaque /// type capture every input lifetime, forcing callers to keep the settings and /// registry alive for as long as the processor. -fn create_html_stream_processor( - origin_host: &str, - request_host: &str, - request_scheme: &str, - settings: &Settings, - integration_registry: &IntegrationRegistry, +struct HtmlStreamProcessorParams<'a> { + origin_host: &'a str, + request_host: &'a str, + request_scheme: &'a str, + settings: &'a Settings, + integration_registry: &'a IntegrationRegistry, ad_slots_script: Option, ad_bids_state: Arc>>, + suppress_datadome_client_side_tag: bool, + gpt_diagnostics: Option, +} + +fn create_html_stream_processor( + params: HtmlStreamProcessorParams<'_>, ) -> Result, Report> { use crate::html_processor::{HtmlProcessorConfig, create_html_processor}; let config = HtmlProcessorConfig::from_settings( - settings, - integration_registry, - origin_host, - request_host, - request_scheme, + params.settings, + params.integration_registry, + params.origin_host, + params.request_host, + params.request_scheme, ) - .with_ad_state(ad_slots_script, ad_bids_state); + .with_ad_state(params.ad_slots_script, params.ad_bids_state) + .with_gpt_diagnostics(params.gpt_diagnostics) + .with_datadome_client_tag_suppression(params.suppress_datadome_client_side_tag); Ok(create_html_processor(config)) } @@ -1123,6 +1142,11 @@ pub struct OwnedProcessResponseParams { pub(crate) dispatched_auction: Option, /// Price granularity used to bucket bids when building `tsjs.bids`. pub(crate) price_granularity: PriceGranularity, + /// Whether to omit Trusted Server's automatic `DataDome` client-side tag. + pub(crate) suppress_datadome_client_side_tag: bool, + /// Request-scoped conditional diagnostics delivery decision. + pub(crate) gpt_diagnostics: + Option, } /// Buffers a [`PublisherResponse`] into a single [`Response`], collecting the @@ -1455,6 +1479,45 @@ pub async fn publisher_response_into_streaming_response( } } +/// Returns whether a request can render an HTML document context. +fn is_html_document_request(req: &Request) -> bool { + if let Some(destination) = req + .headers() + .get("sec-fetch-dest") + .and_then(|value| value.to_str().ok()) + { + return matches!( + destination.trim().to_ascii_lowercase().as_str(), + "document" | "embed" | "fencedframe" | "frame" | "iframe" | "object" + ); + } + + is_navigation_request(req) +} + +/// Removes request headers that can produce a bodyless or partial origin response. +fn strip_conditional_and_range_headers(req: &mut Request) { + req.headers_mut().remove(header::IF_NONE_MATCH); + req.headers_mut().remove(header::IF_MODIFIED_SINCE); + req.headers_mut().remove(header::RANGE); + req.headers_mut().remove(header::IF_RANGE); +} + +/// Prevent shared caches from replaying tag-suppressed HTML to other clients. +fn apply_datadome_client_tag_cache_privacy( + response: &mut Response, + method: &Method, + suppress_datadome_client_side_tag: bool, + content_type: &str, +) { + if suppress_datadome_client_side_tag + && response_carries_body(method, response.status()) + && is_html_content_type(content_type) + { + enforce_synthesized_html_cache_privacy(response); + } +} + /// Returns `true` when a buffered publisher response should carry a body and a /// recomputed `Content-Length`. /// @@ -1586,6 +1649,8 @@ pub fn stream_publisher_body( integration_registry, ad_slots_script: params.ad_slots_script.as_deref(), ad_bids_state: ¶ms.ad_bids_state, + suppress_datadome_client_side_tag: params.suppress_datadome_client_side_tag, + gpt_diagnostics: params.gpt_diagnostics.as_ref(), }; process_response_streaming(body, output, &borrowed) } @@ -1670,15 +1735,17 @@ pub async fn stream_publisher_body_async( // HTML: build the processor once and drive it chunk by chunk. // One-behind buffer: stream chunk N-1 immediately; hold chunk N until origin // EOF, then await auction and process chunk N (which contains ). - let mut processor = match create_html_stream_processor( - ¶ms.origin_host, - ¶ms.request_host, - ¶ms.request_scheme, + let mut processor = match create_html_stream_processor(HtmlStreamProcessorParams { + origin_host: ¶ms.origin_host, + request_host: ¶ms.request_host, + request_scheme: ¶ms.request_scheme, settings, integration_registry, - params.ad_slots_script.as_deref().map(str::to_string), - params.ad_bids_state.clone(), - ) { + ad_slots_script: params.ad_slots_script.as_deref().map(str::to_string), + ad_bids_state: params.ad_bids_state.clone(), + suppress_datadome_client_side_tag: params.suppress_datadome_client_side_tag, + gpt_diagnostics: params.gpt_diagnostics.clone(), + }) { Ok(processor) => processor, Err(err) => { emit_abandoned_auction( @@ -2447,6 +2514,7 @@ async fn collect_non_html_auction( AuctionTerminalOutcome::Completed { request: auction_request, result: &result, + delivered_winner_slots: None, }, ) }) @@ -2493,6 +2561,7 @@ async fn collect_stream_auction( AuctionTerminalOutcome::Completed { request: auction_request, result: &result, + delivered_winner_slots: None, }, ) }) @@ -2580,6 +2649,11 @@ pub async fn handle_publisher_request( ) -> Result> { log::debug!("Proxying request to publisher_origin"); + // Adapter fallbacks prepare this before EC/cookie handling. Keep this + // idempotent call as a direct-handler safety net and for focused tests. + let gpt_diagnostics = + crate::integrations::gpt_diagnostics::prepare_request(settings, &mut req)?; + // Prebid.js requests are not intercepted here anymore. The HTML processor removes // publisher-supplied Prebid scripts; the unified TSJS bundle includes Prebid.js when enabled. @@ -2666,11 +2740,13 @@ pub async fn handle_publisher_request( let is_prefetch = is_prefetch_request(&req); let is_bot = is_bot_user_agent(&req); - let matched_slots: Vec<_> = if settings.creative_opportunities.is_some() && is_get { - crate::creative_opportunities::match_slots(auction.slots, &request_path) - .into_iter() - .cloned() - .collect() + let matched_slots = if is_get { + settings + .creative_opportunities + .as_ref() + .map_or_else(Vec::new, |co_config| { + match_renderable_slots(auction.slots, co_config, &request_path) + }) } else { Vec::new() }; @@ -2861,6 +2937,18 @@ pub async fn handle_publisher_request( } ); + let suppress_datadome_client_side_tag = req + .extensions() + .get::() + .is_some(); + if should_run_ad_stack || (suppress_datadome_client_side_tag && is_html_document_request(&req)) + { + // HTML document contexts whose output may be synthesized must not + // receive a cached 304 or partial 206. Non-document subresources contain + // no executable injected tag, so retain their validators and ranges. + strip_conditional_and_range_headers(&mut req); + } + // Only advertise encodings the rewrite pipeline can decode and re-encode. restrict_accept_encoding(&mut req); // Strip the internal `fastly-ssl` scheme signal before forwarding to the @@ -2888,6 +2976,9 @@ pub async fn handle_publisher_request( if services.http_client().supports_streaming_responses() { platform_request = platform_request.with_stream_response(); } + if should_run_ad_stack { + platform_request = platform_request.with_cache_bypass(); + } let mut response = match services.http_client().send(platform_request).await { Ok(platform_response) => platform_response.response, @@ -2913,11 +3004,37 @@ pub async fn handle_publisher_request( response.headers().len() ); + if should_run_ad_stack && response.status() == StatusCode::NOT_MODIFIED { + if let Some(dispatched) = dispatched_auction.take() { + emit_abandoned_auction( + services, + auction_observation.take(), + dispatched, + "unexpected_origin_304", + ) + .await; + } + + let response = Response::builder() + .status(StatusCode::BAD_GATEWAY) + .header(header::CACHE_CONTROL, "private, no-store") + .header(header::CONTENT_TYPE, "text/plain; charset=utf-8") + .body(EdgeBody::from( + "Publisher origin returned an invalid conditional response", + )) + .change_context(TrustedServerError::Proxy { + message: "failed to build unexpected origin 304 response".to_string(), + })?; + return Ok(PublisherResponse::Buffered(response)); + } + + crate::integrations::gpt_diagnostics::finalize_response(&gpt_diagnostics, &mut response); + let ad_slots_script = if should_run_ad_stack { settings .creative_opportunities .as_ref() - .map(|co_config| build_ad_slots_script(&matched_slots, co_config)) + .map(|co_config| build_ad_slots_script(&matched_slots, co_config, &request_path)) } else { None }; @@ -2937,10 +3054,17 @@ pub async fn handle_publisher_request( .headers() .get(header::CONTENT_TYPE) .and_then(|h| h.to_str().ok()) - .unwrap_or_default(); - if should_run_ad_stack && is_html_content_type(origin_content_type) { + .unwrap_or_default() + .to_string(); + if should_run_ad_stack && is_html_content_type(&origin_content_type) { enforce_synthesized_html_cache_privacy(&mut response); } + apply_datadome_client_tag_cache_privacy( + &mut response, + &request_method, + suppress_datadome_client_side_tag, + &origin_content_type, + ); apply_publisher_asset_cache_policy( settings, @@ -3058,6 +3182,8 @@ pub async fn handle_publisher_request( auction_request: auction_request_for_telemetry, dispatched_auction, price_granularity, + suppress_datadome_client_side_tag, + gpt_diagnostics: Some(gpt_diagnostics), }), }) } @@ -3399,8 +3525,9 @@ pub(crate) fn build_empty_bids_script() -> String { fn build_slot_json( slot: &crate::creative_opportunities::CreativeOpportunitySlot, co_config: &crate::creative_opportunities::CreativeOpportunitiesConfig, -) -> serde_json::Value { - let gam_path = slot.resolved_gam_unit_path(&co_config.gam_network_id); + section: &str, +) -> Option { + let gam_path = slot.render_gam_unit_path(&co_config.gam_network_id, section)?; let div_id = slot.resolved_div_id(); let formats: Vec = slot .formats @@ -3412,13 +3539,40 @@ fn build_slot_json( .iter() .map(|(k, v)| (k.clone(), serde_json::Value::String(v.clone()))) .collect(); - serde_json::json!({ + Some(serde_json::json!({ "id": slot.id, "gam_unit_path": gam_path, "div_id": div_id, "formats": formats, "targeting": targeting, - }) + })) +} + +/// Match creative-opportunity slots and omit dynamic GAM paths that cannot be +/// rendered for this request before they can enter an auction. +fn match_renderable_slots( + slots: &[crate::creative_opportunities::CreativeOpportunitySlot], + co_config: &crate::creative_opportunities::CreativeOpportunitiesConfig, + request_path: &str, +) -> Vec { + let section = co_config.section_for_path(request_path); + crate::creative_opportunities::match_slots(slots, request_path) + .into_iter() + .filter_map(|slot| { + if slot + .render_gam_unit_path(&co_config.gam_network_id, §ion) + .is_none() + { + log::warn!( + "Omitting slot `{}`: dynamic gam_unit_path exceeds the render limit for path `{}`", + slot.id, + request_path + ); + return None; + } + Some(slot.clone()) + }) + .collect() } /// Build the `tsjs.adSlots` `"); @@ -7794,6 +8086,8 @@ mod tests { // as the `/auction` path (sanitize → rewrite) before the creative // reaches window.tsjs.bids, so hostile executable markup never lands // in the client-facing `adm` for the Prebid Universal Creative to run. + let mut settings = test_settings(); + settings.auction.sanitize_creatives = true; let mut winning_bids = HashMap::new(); let mut bid = make_bid( "atf_sidebar_ad", @@ -7810,13 +8104,7 @@ mod tests { ); winning_bids.insert("atf_sidebar_ad".to_string(), bid); - let map = build_bid_map( - &winning_bids, - PriceGranularity::Dense, - &test_settings(), - "", - false, - ); + let map = build_bid_map(&winning_bids, PriceGranularity::Dense, &settings, "", false); let adm = map .get("atf_sidebar_ad") .and_then(|v| v.as_object()) @@ -7846,6 +8134,7 @@ mod tests { fn build_bid_map_can_skip_rewriting_but_not_sanitization() { let mut settings = test_settings(); settings.auction.rewrite_creatives = false; + settings.auction.sanitize_creatives = true; let mut winning_bids = HashMap::new(); let mut bid = make_bid( "atf_sidebar_ad", @@ -8235,7 +8524,10 @@ mod tests { height: 250, nurl: None, burl: None, + bid_id: None, ad_id: Some("bid-impression-id".to_string()), + creative_id: None, + renderer: None, cache_id: Some("f47447a0-b759-4f2f-9887-af458b79b570".to_string()), cache_host: Some("openads.adsrvr.org".to_string()), cache_path: Some("/cache".to_string()), @@ -8287,7 +8579,10 @@ mod tests { height: 250, nurl: None, burl: None, + bid_id: None, ad_id: Some("aps-bid-token".to_string()), + creative_id: None, + renderer: None, cache_id: None, cache_host: None, cache_path: None, @@ -8337,7 +8632,10 @@ mod tests { height: 250, nurl: None, burl: None, + bid_id: None, ad_id: None, + creative_id: None, + renderer: None, cache_id: None, cache_host: None, cache_path: None, @@ -8378,7 +8676,10 @@ mod tests { height: 250, nurl: None, burl: None, + bid_id: None, ad_id: None, + creative_id: None, + renderer: None, cache_id: None, cache_host: None, cache_path: None, @@ -8672,6 +8973,7 @@ mod tests { targeting: Default::default(), providers: Default::default(), compiled_patterns: Vec::new(), + compiled_unit: None, }] } @@ -9204,7 +9506,7 @@ mod tests { /// the handler emitted. mod navigation_publisher_domain_tests { use super::*; - use crate::auction::provider::AuctionProvider; + use crate::auction::provider::{AuctionProvider, ProviderRequestOutcome}; use crate::auction::telemetry::{AuctionEventBatch, AuctionTelemetrySink}; use crate::auction::types::AuctionRequest; use crate::auction::{AuctionContext, AuctionOrchestrator}; @@ -9212,7 +9514,7 @@ mod tests { use crate::platform::test_support::{ NoopConfigStore, NoopGeo, NoopSecretStore, StubBackend, }; - use crate::platform::{ClientInfo, PlatformPendingRequest, PlatformResponse}; + use crate::platform::{ClientInfo, PlatformResponse}; use crate::test_support::tests::crate_test_settings_str; use std::sync::Mutex; @@ -9241,7 +9543,7 @@ mod tests { &self, request: &AuctionRequest, _context: &AuctionContext<'_>, - ) -> Result> { + ) -> Result> { *self.captured.lock().expect("should lock captured request") = Some(request.clone()); Err(Report::new(TrustedServerError::Auction { @@ -9314,6 +9616,7 @@ mod tests { targeting: Default::default(), providers: Default::default(), compiled_patterns: Vec::new(), + compiled_unit: None, }] } diff --git a/crates/trusted-server-core/src/response_privacy.rs b/crates/trusted-server-core/src/response_privacy.rs index 8ccda97bd..0cc0eb88f 100644 --- a/crates/trusted-server-core/src/response_privacy.rs +++ b/crates/trusted-server-core/src/response_privacy.rs @@ -290,6 +290,35 @@ mod tests { ); } + #[test] + fn cookie_privacy_ignores_quoted_extension_directives() { + let settings = settings_with_response_headers(&[]); + let mut response = response_builder() + .header(header::SET_COOKIE, "id=abc") + .header( + header::CACHE_CONTROL, + "public, max-age=600, ext=\"a,no-store,b\"", + ) + .header("surrogate-control", "max-age=600") + .body(edgezero_core::body::Body::empty()) + .expect("should build response"); + + apply_response_headers_with_cache_privacy(&settings, &mut response); + + assert_eq!( + response + .headers() + .get(header::CACHE_CONTROL) + .and_then(|value| value.to_str().ok()), + Some("private, max-age=0"), + "quoted extension text must not prevent the cookie privacy downgrade" + ); + assert!( + !response.headers().contains_key("surrogate-control"), + "cookie privacy downgrade should strip edge-cache headers" + ); + } + #[test] fn preserves_private_no_store_against_operator_cache_headers_without_cookie() { let settings = settings_with_response_headers(&[ diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index da61dd84a..cd5f7f8cd 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -2056,7 +2056,24 @@ impl CacheAssetRule { } fn validate_policy_shape(&self) -> Result<(), Report> { - if self.browser_ttl_seconds.is_none() && self.edge_ttl_seconds.is_none() { + if self.visibility == CachePolicyVisibility::Private { + if self.edge_ttl_seconds.is_some() { + return Err(Report::new(TrustedServerError::Configuration { + message: format!( + "cache.asset_rules `{}` sets edge_ttl_seconds with private visibility; private rules must use browser_ttl_seconds", + self.id + ), + })); + } + if self.browser_ttl_seconds.is_none() { + return Err(Report::new(TrustedServerError::Configuration { + message: format!( + "cache.asset_rules `{}` with private visibility must configure browser_ttl_seconds", + self.id + ), + })); + } + } else if self.browser_ttl_seconds.is_none() && self.edge_ttl_seconds.is_none() { return Err(Report::new(TrustedServerError::Configuration { message: format!( "cache.asset_rules `{}` must configure browser_ttl_seconds or edge_ttl_seconds", @@ -3481,6 +3498,59 @@ mod tests { format!("{browser_ttl_err:?}").contains("positive browser_ttl_seconds"), "should explain immutable browser TTL requirement: {browser_ttl_err:?}" ); + + let private_edge_only = format!( + r#"{} + + [[cache.asset_rules]] + id = "private-edge-only" + enabled = true + path_prefix = "/assets/" + visibility = "private" + edge_ttl_seconds = 300 + "#, + crate_test_settings_str() + ); + let private_edge_only_err = Settings::from_toml(&private_edge_only) + .expect_err("should reject private rule with only an edge TTL"); + assert!( + format!("{private_edge_only_err:?}").contains("edge_ttl_seconds"), + "should explain that private rules cannot use an edge TTL: {private_edge_only_err:?}" + ); + + let private_dual_ttl = private_edge_only.replace( + "id = \"private-edge-only\"", + "id = \"private-dual-ttl\"\n browser_ttl_seconds = 300", + ); + let private_dual_ttl_err = Settings::from_toml(&private_dual_ttl) + .expect_err("should reject private rule with browser and edge TTLs"); + assert!( + format!("{private_dual_ttl_err:?}").contains("edge_ttl_seconds"), + "should reject edge TTL even when a private rule has a browser TTL: {private_dual_ttl_err:?}" + ); + + let private_browser_ttl = private_edge_only.replace( + "id = \"private-edge-only\"\n enabled = true\n path_prefix = \"/assets/\"\n visibility = \"private\"\n edge_ttl_seconds = 300", + "id = \"private-browser-ttl\"\n enabled = true\n path_prefix = \"/assets/\"\n visibility = \"private\"\n browser_ttl_seconds = 300", + ); + let private_settings = Settings::from_toml(&private_browser_ttl) + .expect("should accept a private rule with a browser TTL"); + let private_policy = private_settings + .asset_cache_policy_for_path("/assets/app.js") + .expect("should evaluate private cache rule") + .expect("should match private cache rule"); + assert_eq!( + private_policy + .cache_control_value(crate::cache_policy::EdgeCacheHeader::SurrogateControl), + "private, max-age=300", + "private rules should render their browser TTL" + ); + assert_eq!( + private_policy + .edge_header_value(crate::cache_policy::EdgeCacheHeader::SurrogateControl), + None, + "private rules should not render an edge cache TTL" + ); } #[test] diff --git a/crates/trusted-server-integration-tests/tests/common/ec.rs b/crates/trusted-server-integration-tests/tests/common/ec.rs index cde6ad1c4..0a1f149c3 100644 --- a/crates/trusted-server-integration-tests/tests/common/ec.rs +++ b/crates/trusted-server-integration-tests/tests/common/ec.rs @@ -403,3 +403,79 @@ impl Drop for MinimalOrigin { } } } + +/// A minimal HTTP origin that reflects the request's Cookie header in a +/// cacheable HTML response. +/// +/// This makes it possible to assert that an edge runtime does not reuse a +/// cookie-influenced publisher response for another visitor. +pub struct CookieVaryingOrigin { + shutdown_tx: mpsc::Sender<()>, + handle: Option>, +} + +impl CookieVaryingOrigin { + /// Starts the cookie-varying origin on `127.0.0.1:{port}`. + /// + /// # Panics + /// + /// Panics if the port is already in use. + pub fn start(port: u16) -> Self { + let listener = + TcpListener::bind(format!("127.0.0.1:{port}")).expect("should bind origin port"); + listener + .set_nonblocking(true) + .expect("should set listener nonblocking"); + let (shutdown_tx, shutdown_rx) = mpsc::channel::<()>(); + + let handle = thread::spawn(move || { + loop { + if shutdown_rx.try_recv().is_ok() { + break; + } + + match listener.accept() { + Ok((mut stream, _addr)) => { + let mut buf = [0u8; 4096]; + let Ok(bytes_read) = stream.read(&mut buf) else { + continue; + }; + let request = String::from_utf8_lossy(&buf[..bytes_read]); + let cookie = request + .lines() + .find_map(|line| { + let (name, value) = line.split_once(':')?; + name.eq_ignore_ascii_case("cookie").then(|| value.trim()) + }) + .unwrap_or("viewer=missing"); + let body = format!("{cookie}"); + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + let _ = stream.write_all(response.as_bytes()); + let _ = stream.flush(); + } + Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => { + thread::sleep(Duration::from_millis(10)); + } + Err(_) => break, + } + } + }); + + Self { + shutdown_tx, + handle: Some(handle), + } + } +} + +impl Drop for CookieVaryingOrigin { + fn drop(&mut self) { + let _ = self.shutdown_tx.send(()); + if let Some(handle) = self.handle.take() { + let _ = handle.join(); + } + } +} diff --git a/crates/trusted-server-integration-tests/tests/integration.rs b/crates/trusted-server-integration-tests/tests/integration.rs index 76a267d1f..ebd026410 100644 --- a/crates/trusted-server-integration-tests/tests/integration.rs +++ b/crates/trusted-server-integration-tests/tests/integration.rs @@ -2,6 +2,7 @@ mod common; mod environments; mod frameworks; +use common::ec::CookieVaryingOrigin; use common::runtime::{RuntimeEnvironment, TestError, origin_port, wasm_binary_path}; use environments::{RUNTIME_ENVIRONMENTS, ReadyCheckOptions, wait_for_http_ready}; use error_stack::ResultExt as _; @@ -164,6 +165,55 @@ fn test_nextjs_cloudflare() { test_combination(&runtime, &framework).expect("should pass Next.js on Cloudflare Workers"); } +#[test] +#[ignore = "requires the `wrangler` CLI in $PATH and a prebuilt Cloudflare Workers bundle (run build.sh first); the test starts wrangler dev automatically"] +fn test_cloudflare_dynamic_publisher_response_does_not_cross_cookie_boundaries() { + init_logger(); + let _origin = CookieVaryingOrigin::start(origin_port()); + let runtime = environments::cloudflare::CloudflareWorkers; + let process = runtime + .spawn(&wasm_binary_path()) + .expect("should start Cloudflare Worker"); + let client = reqwest::blocking::Client::new(); + + let first_response = client + .get(format!("{}/cache-regression", process.base_url)) + .header("cookie", "viewer=first") + .send() + .expect("should request first dynamic publisher response"); + assert_eq!( + first_response.status().as_u16(), + 200, + "first dynamic publisher response should succeed" + ); + let first_body = first_response + .text() + .expect("should read first dynamic publisher response"); + + let second_response = client + .get(format!("{}/cache-regression", process.base_url)) + .header("cookie", "viewer=second") + .send() + .expect("should request second dynamic publisher response"); + assert_eq!( + second_response.status().as_u16(), + 200, + "second dynamic publisher response should succeed" + ); + let second_body = second_response + .text() + .expect("should read second dynamic publisher response"); + + assert!( + first_body.contains("viewer=first"), + "first response must preserve its cookie-specific origin body: {first_body}" + ); + assert!( + second_body.contains("viewer=second"), + "second response must not reuse the first visitor's body: {second_body}" + ); +} + #[test] #[ignore = "requires Docker and pre-built trusted-server-axum binary"] fn test_wordpress_axum() { diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 96389be78..d691d8583 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -1046,29 +1046,30 @@ Disabled rules never match, and their matcher and policy validation is deferred until they are enabled. Rule IDs are always normalized and must remain nonempty and unique, including for disabled placeholders. -| Field | Type | Required | Description | -| -------------------------------- | ------------- | -------- | --------------------------------------------------------------- | -| `id` | String | Yes | Unique operator-facing rule identifier | -| `enabled` | Boolean | No | Whether the rule participates in matching (default `false`) | -| `preset` | String | Matcher | Built-in preset such as `nextjs-static` | -| `path_prefix` | String | Matcher | Request path prefix | -| `path_glob` | String | Matcher | Single glob matched against the request path | -| `path_globs` | Array[String] | Matcher | Multiple globs matched against the request path | -| `path_regex` | String | Matcher | Regex matched against the request path | -| `extensions` | Array[String] | Matcher | Case-insensitive file extensions | -| `fingerprint_style` | String | No | Required bundler fingerprint convention before matching | -| `visibility` | String | No | `public` or `private` (default `public`) | -| `browser_ttl_seconds` | Integer | Policy | Browser `max-age`; must be positive when `immutable = true` | -| `edge_ttl_seconds` | Integer | Policy | TTL emitted through the runtime-specific shared-cache directive | -| `stale_while_revalidate_seconds` | Integer | No | Optional `stale-while-revalidate` | -| `stale_if_error_seconds` | Integer | No | Optional `stale-if-error` | -| `immutable` | Boolean | No | Add `immutable` for a validated content-addressed rule | - -An enabled rule must configure exactly one matcher and at least one of -`browser_ttl_seconds` or `edge_ttl_seconds`. `path_glob` and `path_globs` are -mutually exclusive. `immutable = true` additionally requires a positive browser -TTL and either the content-addressed `nextjs-static` preset or an explicit -`fingerprint_style`. +| Field | Type | Required | Description | +| -------------------------------- | ------------- | -------- | ---------------------------------------------------------------------------------- | +| `id` | String | Yes | Unique operator-facing rule identifier | +| `enabled` | Boolean | No | Whether the rule participates in matching (default `false`) | +| `preset` | String | Matcher | Built-in preset such as `nextjs-static` | +| `path_prefix` | String | Matcher | Request path prefix | +| `path_glob` | String | Matcher | Single glob matched against the request path | +| `path_globs` | Array[String] | Matcher | Multiple globs matched against the request path | +| `path_regex` | String | Matcher | Regex matched against the request path | +| `extensions` | Array[String] | Matcher | Case-insensitive file extensions | +| `fingerprint_style` | String | No | Required bundler fingerprint convention before matching | +| `visibility` | String | No | `public` or `private` (default `public`) | +| `browser_ttl_seconds` | Integer | Policy | Browser `max-age`; required for private rules and positive with `immutable = true` | +| `edge_ttl_seconds` | Integer | Policy | Public rules only: TTL emitted through the runtime-specific shared-cache directive | +| `stale_while_revalidate_seconds` | Integer | No | Optional `stale-while-revalidate` | +| `stale_if_error_seconds` | Integer | No | Optional `stale-if-error` | +| `immutable` | Boolean | No | Add `immutable` for a validated content-addressed rule | + +An enabled rule must configure exactly one matcher. Public rules must configure +at least one of `browser_ttl_seconds` or `edge_ttl_seconds`; private rules must +configure `browser_ttl_seconds` and must not configure `edge_ttl_seconds`. +`path_glob` and `path_globs` are mutually exclusive. `immutable = true` +additionally requires a positive browser TTL and either the content-addressed +`nextjs-static` preset or an explicit `fingerprint_style`. The filename fingerprint check is intentionally conservative and style-specific. It examines the suffix immediately before the final extension and requires a @@ -1144,11 +1145,15 @@ built-in cache policy and do not require an asset rule. Shared-cache keys for `/static/tsjs=` must preserve `v`; otherwise a matching immutable response can collide with the missing or mismatched version's short-TTL response. -`edge_ttl_seconds` only emits the selected runtime's shared-cache directive. The -runtime or service must also enable and consume that directive. The checked-in -Cloudflare manifests enable Workers Cache. Fastly synthetic and final egress -responses still require explicit runtime cache integration, tracked in -[#908](https://github.com/IABTechLab/trusted-server/issues/908). +`edge_ttl_seconds` only emits the selected runtime's shared-cache directive for +public rules. The runtime or service must also enable and consume that +directive. The checked-in Cloudflare manifests intentionally do not enable +Workers Cache: the Worker serves the full publisher gateway, not an isolated +static-only entrypoint. Emitting `Cloudflare-CDN-Cache-Control` alone must not +be treated as permission to cache every response. Any future Workers Cache +opt-in must isolate or explicitly allowlist cacheable traffic. Fastly synthetic +and final egress responses still require explicit runtime cache integration, +tracked in [#908](https://github.com/IABTechLab/trusted-server/issues/908). ## Integration Configurations