From 486bf169aa45ebeb8d2b695ec234acf543ab5201 Mon Sep 17 00:00:00 2001 From: Christian Date: Wed, 8 Jul 2026 11:25:17 -0500 Subject: [PATCH 1/5] 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 | 4 +- .../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 | 30 +- .../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 | 357 ++++++++++- .../src/response_privacy.rs | 132 +++- crates/trusted-server-core/src/settings.rs | 473 +++++++++++++++ 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, 2654 insertions(+), 166 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 2f43295744..170654e2a0 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}; @@ -176,7 +177,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) { @@ -215,6 +216,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 c4bf7d9908..629c2b0b88 100644 --- a/crates/trusted-server-adapter-axum/tests/routes.rs +++ b/crates/trusted-server-adapter-axum/tests/routes.rs @@ -197,6 +197,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 c931360f68..015f1e7f7f 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; @@ -367,7 +368,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 @@ -401,6 +406,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 df2781945d..6aee76b8bc 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 e56498b100..9abbe7d63b 100644 --- a/crates/trusted-server-adapter-fastly/src/app.rs +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -96,6 +96,7 @@ use error_stack::Report; use trusted_server_core::auction::endpoints::handle_auction; use trusted_server_core::auction::AuctionTelemetrySink; 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::batch_sync::handle_batch_sync; use trusted_server_core::ec::consent::ec_consent_withdrawn; @@ -719,7 +720,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. // Only the handle_publisher_request branch below routes through @@ -794,6 +795,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 d20de533d7..8b4f80a77d 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 2291fce74c..63cbe6bc2e 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; @@ -637,7 +638,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 @@ -671,6 +672,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 9b96dbd70e..f2e7c7ac02 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 0000000000..5b2b09a3e0 --- /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 74830ff9bc..96d7a6cbde 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::{header, Request, Response, StatusCode}; 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; @@ -279,44 +281,42 @@ 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 if_none_match == etag { - return Response::builder() + let mut response = 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"); + short_policy.apply_to_headers(response.headers_mut(), edge_header); + return response; } } - Response::builder() + 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 32a1c2a852..fc0e6847ad 100644 --- a/crates/trusted-server-core/src/integrations/prebid.rs +++ b/crates/trusted-server-core/src/integrations/prebid.rs @@ -22,6 +22,7 @@ use crate::auction::provider::AuctionProvider; 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::{strip_cookies, CONSENT_COOKIE_NAMES}; use crate::error::TrustedServerError; @@ -568,14 +569,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 { @@ -2925,7 +2928,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 5e32cc89c2..3feda5b626 100644 --- a/crates/trusted-server-core/src/integrations/testlight.rs +++ b/crates/trusted-server-core/src/integrations/testlight.rs @@ -265,8 +265,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 70a4d6cfdd..48e92faed8 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 19c10a80dd..8737e88f1e 100644 --- a/crates/trusted-server-core/src/proxy.rs +++ b/crates/trusted-server-core/src/proxy.rs @@ -11,6 +11,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, @@ -94,7 +97,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. /// @@ -107,13 +110,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) + } } } } @@ -167,6 +180,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 { @@ -969,10 +987,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( @@ -1155,6 +1170,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) } @@ -2027,6 +2049,7 @@ mod tests { use std::collections::{HashMap, VecDeque}; use std::io; use std::sync::{Arc, Mutex}; + use std::time::Duration; use super::{ asset_origin_host_header, asset_path_skips_image_optimizer, build_asset_proxy_target_url, @@ -2037,6 +2060,7 @@ mod tests { AssetProxyCachePolicy, ProxyRequestConfig, IMAGE_FALLBACK_CONTENT_TYPE, SUPPORTED_ENCODINGS, }; + use crate::cache_policy::{CachePolicy, EdgeCacheHeader}; use crate::constants::{HEADER_ACCEPT, HEADER_X_FORWARDED_FOR}; use crate::creative; use crate::error::{IntoHttpResponse, TrustedServerError}; @@ -2051,9 +2075,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; @@ -3728,6 +3752,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 6b0ea3a5dc..acf92959c6 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -40,6 +40,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; @@ -165,6 +168,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"]; @@ -179,10 +183,8 @@ 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_deferred_module_filename(filename) { @@ -191,19 +193,46 @@ pub fn handle_tsjs_dynamic( 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 @@ -427,6 +456,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 { @@ -1306,6 +1362,7 @@ 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"); @@ -1389,6 +1446,7 @@ 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); @@ -1640,7 +1698,7 @@ pub async fn handle_publisher_request( // §4.7: HTML carrying inline per-user bid data must never be shared-cached. // `private, max-age=0` is deliberate (not `no-store`): it keeps the page // BFCache-eligible while restricting reuse to the same user's browser with - // revalidation; `Surrogate-Control` removal handles the Fastly shared cache. + // revalidation; edge-cache header removal handles shared CDN caches. // // 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, @@ -1655,14 +1713,17 @@ pub async fn handle_publisher_request( .and_then(|h| h.to_str().ok()) .unwrap_or_default(); if should_run_ad_stack && is_html_content_type(origin_content_type) { - response.headers_mut().insert( - header::CACHE_CONTROL, - HeaderValue::from_static("private, max-age=0"), - ); - response.headers_mut().remove("surrogate-control"); - response.headers_mut().remove("fastly-surrogate-control"); + CachePolicy::private_revalidate().apply_to_headers(response.headers_mut(), edge_header); } + apply_publisher_asset_cache_policy( + settings, + &request_path, + cache_rule_method, + edge_header, + &mut response, + )?; + let content_type = response .headers() .get(header::CONTENT_TYPE) @@ -2442,7 +2503,7 @@ mod tests { use crate::platform::test_support::{ build_services_with_http_client, noop_services, StubHttpClient, }; - use crate::test_support::tests::create_test_settings; + use crate::test_support::tests::{crate_test_settings_str, create_test_settings}; use edgezero_core::body::Body as EdgeBody; use http::{header, Method, Request as HttpRequest, StatusCode}; use std::sync::Arc; @@ -2695,6 +2756,7 @@ mod tests { registry: None, }, req, + EdgeCacheHeader::SurrogateControl, ) .await .expect("should proxy publisher request") @@ -2733,6 +2795,130 @@ mod tests { ); } + #[tokio::test] + async fn publisher_request_applies_configured_asset_cache_policy() { + let settings = Settings::from_toml(&format!( + r#"{} + + [[cache.asset_rules]] + id = "publisher-fingerprinted-assets" + enabled = true + path_globs = ["/assets/**/*.png"] + 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 rule"); + let stub = Arc::new(StubHttpClient::new()); + stub.push_response_with_headers( + 200, + b"png".to_vec(), + vec![ + (header::CONTENT_TYPE.as_str(), "image/png"), + (header::CACHE_CONTROL.as_str(), "public, max-age=60"), + ], + ); + let services = build_services_with_http_client( + Arc::clone(&stub) as Arc + ); + let req = HttpRequest::builder() + .method(Method::GET) + .uri("https://publisher.example/assets/logo.0123abcd.png") + .header(header::HOST, "publisher.example") + .body(EdgeBody::empty()) + .expect("should build request"); + + let response = match run_publisher_proxy(&settings, &services, req).await { + PublisherResponse::PassThrough { response, .. } => response, + PublisherResponse::Buffered(response) | PublisherResponse::Stream { response, .. } => { + response + } + }; + + assert_eq!( + response + .headers() + .get(header::CACHE_CONTROL) + .and_then(|value| value.to_str().ok()), + Some("public, max-age=31536000, immutable"), + "matched publisher-origin asset should receive normalized immutable policy" + ); + assert_eq!( + response + .headers() + .get("surrogate-control") + .and_then(|value| value.to_str().ok()), + Some("max-age=31536000"), + "publisher-origin asset should receive selected runtime edge header" + ); + } + + #[tokio::test] + async fn publisher_asset_cache_policy_respects_split_no_store_origin_header() { + let settings = Settings::from_toml(&format!( + r#"{} + + [[cache.asset_rules]] + id = "publisher-fingerprinted-assets" + enabled = true + path_globs = ["/assets/**/*.png"] + 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 rule"); + let stub = Arc::new(StubHttpClient::new()); + stub.push_response_with_headers( + 200, + b"png".to_vec(), + vec![ + (header::CONTENT_TYPE.as_str(), "image/png"), + (header::CACHE_CONTROL.as_str(), "public, max-age=60"), + (header::CACHE_CONTROL.as_str(), "no-store"), + ], + ); + let services = build_services_with_http_client( + Arc::clone(&stub) as Arc + ); + let req = HttpRequest::builder() + .method(Method::GET) + .uri("https://publisher.example/assets/logo.0123abcd.png") + .header(header::HOST, "publisher.example") + .body(EdgeBody::empty()) + .expect("should build request"); + + let response = match run_publisher_proxy(&settings, &services, req).await { + PublisherResponse::PassThrough { response, .. } => response, + PublisherResponse::Buffered(response) | PublisherResponse::Stream { response, .. } => { + response + } + }; + + let cache_control_values = response + .headers() + .get_all(header::CACHE_CONTROL) + .iter() + .filter_map(|value| value.to_str().ok()) + .collect::>(); + assert_eq!( + cache_control_values, + vec!["public, max-age=60", "no-store"], + "origin no-store in a later Cache-Control field should prevent normalized upgrade" + ); + assert!( + response.headers().get("surrogate-control").is_none(), + "origin no-store response must not receive edge-cache headers" + ); + } + #[tokio::test] async fn handle_publisher_request_does_not_self_generate_ec() { // EC generation is the adapter's real-browser-gated responsibility. This @@ -2780,6 +2966,7 @@ mod tests { registry: None, }, req, + EdgeCacheHeader::SurrogateControl, ) .await .expect("should proxy publisher request"); @@ -3406,7 +3593,8 @@ mod tests { "https://publisher.example/static/tsjs=unknown.js", ); - let response = handle_tsjs_dynamic(&req, ®istry).expect("should handle tsjs request"); + let response = handle_tsjs_dynamic(&req, ®istry, EdgeCacheHeader::SurrogateControl) + .expect("should handle tsjs request"); assert_eq!(response.status(), StatusCode::NOT_FOUND); } @@ -3420,8 +3608,126 @@ mod tests { "https://publisher.example/static/tsjs=tsjs-unified.min.js", ); - let response = handle_tsjs_dynamic(&req, ®istry).expect("should handle tsjs request"); + let response = handle_tsjs_dynamic(&req, ®istry, EdgeCacheHeader::SurrogateControl) + .expect("should handle tsjs request"); + assert_eq!(response.status(), StatusCode::OK); + } + + #[test] + fn tsjs_dynamic_uses_immutable_cache_for_matching_hash() { + let settings = create_test_settings(); + let registry = + IntegrationRegistry::new(&settings).expect("should create integration registry"); + let module_ids = registry.js_module_ids_immediate(); + let hash = trusted_server_js::concatenated_hash(&module_ids); + let req = build_request( + Method::GET, + &format!("https://publisher.example/static/tsjs=tsjs-unified.min.js?v={hash}"), + ); + + let response = handle_tsjs_dynamic(&req, ®istry, EdgeCacheHeader::SurrogateControl) + .expect("should handle tsjs request"); + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response + .headers() + .get(header::CACHE_CONTROL) + .and_then(|value| value.to_str().ok()), + Some("public, max-age=31536000, immutable"), + "should make matching content-versioned bundle immutable" + ); + assert_eq!( + response + .headers() + .get("surrogate-control") + .and_then(|value| value.to_str().ok()), + Some("max-age=31536000"), + "should give Fastly edge cache the same immutable TTL" + ); + assert_eq!( + response + .headers() + .get(header::VARY) + .and_then(|value| value.to_str().ok()), + Some("Accept-Encoding"), + "should keep encoding in the cache key" + ); + assert_eq!( + response + .headers() + .get(HEADER_X_COMPRESS_HINT) + .and_then(|value| value.to_str().ok()), + Some("on"), + "should keep Fastly delivery compression hint" + ); + } + + #[test] + fn tsjs_dynamic_uses_cloudflare_edge_header_when_selected() { + let settings = create_test_settings(); + let registry = + IntegrationRegistry::new(&settings).expect("should create integration registry"); + let module_ids = registry.js_module_ids_immediate(); + let hash = trusted_server_js::concatenated_hash(&module_ids); + let req = build_request( + Method::GET, + &format!("https://publisher.example/static/tsjs=tsjs-unified.min.js?v={hash}"), + ); + + let response = + handle_tsjs_dynamic(&req, ®istry, EdgeCacheHeader::CloudflareCdnCacheControl) + .expect("should handle tsjs request"); + + assert_eq!( + response + .headers() + .get("cloudflare-cdn-cache-control") + .and_then(|value| value.to_str().ok()), + Some("max-age=31536000"), + "should render Cloudflare-specific edge cache header" + ); + assert!( + response.headers().get("surrogate-control").is_none(), + "Cloudflare responses should not emit Fastly Surrogate-Control" + ); + } + + #[test] + fn tsjs_dynamic_keeps_short_cache_for_mismatched_hash() { + let settings = create_test_settings(); + let registry = + IntegrationRegistry::new(&settings).expect("should create integration registry"); + let req = build_request( + Method::GET, + "https://publisher.example/static/tsjs=tsjs-unified.min.js?v=not-the-hash", + ); + + let response = handle_tsjs_dynamic(&req, ®istry, EdgeCacheHeader::SurrogateControl) + .expect("should handle tsjs request"); + let cache_control = response + .headers() + .get(header::CACHE_CONTROL) + .and_then(|value| value.to_str().ok()) + .expect("should set cache-control"); + assert_eq!(response.status(), StatusCode::OK); + assert!( + cache_control.contains("max-age=300"), + "should keep short browser TTL for mismatched hash" + ); + assert!( + !cache_control.contains("immutable"), + "should not make mismatched hash requests immutable" + ); + assert_eq!( + response + .headers() + .get("surrogate-control") + .and_then(|value| value.to_str().ok()), + Some("max-age=300, stale-while-revalidate=60, stale-if-error=86400"), + "should keep short edge TTL for mismatched hash" + ); } #[test] @@ -3472,7 +3778,8 @@ mod tests { "https://publisher.example/static/tsjs=tsjs-prebid.min.js", ); - let response = handle_tsjs_dynamic(&req, ®istry).expect("should handle tsjs request"); + let response = handle_tsjs_dynamic(&req, ®istry, EdgeCacheHeader::SurrogateControl) + .expect("should handle tsjs request"); assert_eq!( response.status(), StatusCode::NOT_FOUND, @@ -3501,7 +3808,8 @@ mod tests { "https://publisher.example/static/tsjs=tsjs-prebid.min.js", ); - let response = handle_tsjs_dynamic(&req, ®istry).expect("should handle tsjs request"); + let response = handle_tsjs_dynamic(&req, ®istry, EdgeCacheHeader::SurrogateControl) + .expect("should handle tsjs request"); assert_eq!( response.status(), StatusCode::NOT_FOUND, @@ -3519,7 +3827,8 @@ mod tests { "https://publisher.example/static/tsjs=tsjs-evil.min.js", ); - let response = handle_tsjs_dynamic(&req, ®istry).expect("should handle tsjs request"); + let response = handle_tsjs_dynamic(&req, ®istry, EdgeCacheHeader::SurrogateControl) + .expect("should handle tsjs request"); assert_eq!( response.status(), StatusCode::NOT_FOUND, diff --git a/crates/trusted-server-core/src/response_privacy.rs b/crates/trusted-server-core/src/response_privacy.rs index 141ef31642..f9d564b6ed 100644 --- a/crates/trusted-server-core/src/response_privacy.rs +++ b/crates/trusted-server-core/src/response_privacy.rs @@ -11,13 +11,18 @@ use edgezero_core::http::{header, HeaderName, HeaderValue, Response}; +use crate::cache_policy::{ + cache_control_headers_are_private_or_no_store, is_edge_cache_header_name, + remove_edge_cache_headers, +}; use crate::settings::Settings; -/// Surrogate 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 SURROGATE_CACHE_HEADERS: &[&str] = &["surrogate-control", "fastly-surrogate-control"]; +/// Runtime edge-cache headers stripped from private or cookie-bearing responses. +pub use crate::cache_policy::EDGE_CACHE_HEADER_NAMES as SURROGATE_CACHE_HEADERS; + +fn cache_control_is_private_or_no_store(response: &Response) -> bool { + cache_control_headers_are_private_or_no_store(response.headers()) +} /// Forces cookie-bearing responses to stay private to shared caches. /// @@ -32,21 +37,14 @@ pub fn enforce_set_cookie_cache_privacy(response: &mut Response) { if !response.headers().contains_key(header::SET_COOKIE) { return; } - // Surrogate cache 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. - for name in SURROGATE_CACHE_HEADERS { - response.headers_mut().remove(*name); - } + 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, @@ -61,10 +59,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 @@ -72,18 +70,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()) - || key.eq_ignore_ascii_case("surrogate-control") - || key.eq_ignore_ascii_case("fastly-surrogate-control")) + || is_edge_cache_header_name(key)) { continue; } @@ -104,10 +99,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); } @@ -149,6 +148,8 @@ mod tests { let mut response = response_builder() .header(header::SET_COOKIE, "id=abc") .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"); @@ -163,8 +164,12 @@ mod tests { "operator public Cache-Control must not override cookie privacy downgrade" ); assert!( - !response.headers().contains_key("surrogate-control"), - "surrogate cache headers must be stripped on cookie responses" + !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" ); } @@ -176,6 +181,8 @@ mod tests { ("set-cookie", "operator=abc"), ("cache-control", "public, max-age=600"), ("surrogate-control", "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()) @@ -192,8 +199,12 @@ mod tests { "operator Set-Cookie plus public Cache-Control must be re-downgraded to private" ); assert!( - !response.headers().contains_key("surrogate-control"), - "surrogate cache headers must be stripped when operator headers add Set-Cookie" + !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), @@ -201,6 +212,61 @@ mod tests { ); } + #[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 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!( + !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 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 9cbb2a5462..473482254a 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::{de::DeserializeOwned, Deserialize, Deserializer, Serialize}; 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; @@ -1889,6 +1892,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)] @@ -1951,6 +2270,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, @@ -2034,6 +2356,7 @@ impl Settings { validation_label: &str, ) -> Result> { settings.integrations.normalize(); + settings.cache.normalize(); settings.proxy.normalize(); settings.image_optimizer.normalize(); settings.consent.validate(); @@ -2061,6 +2384,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()?; @@ -2169,6 +2493,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> { @@ -2735,6 +3071,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 45aee02ebb..e8d678df28 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 a single deferred module with its own cache-busting hash. @@ -165,18 +168,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" ); } @@ -239,14 +241,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 c1e832a587..b1c5ebf717 100644 --- a/crates/trusted-server-js/Cargo.toml +++ b/crates/trusted-server-js/Cargo.toml @@ -13,10 +13,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 5055e98407..faa1e5791b 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/ @@ -127,7 +128,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 @@ -141,9 +142,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"); } @@ -151,6 +153,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"); @@ -162,30 +165,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 7ddc060ab3..83815654a2 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 b975590c62..9387669297 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -69,6 +69,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 | @@ -984,6 +985,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 0000000000..f156aa768c --- /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 0951b781ee..fa967ef9ff 100644 --- a/trusted-server.example.toml +++ b/trusted-server.example.toml @@ -109,6 +109,27 @@ rewrite_script = true # 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 providers = [] From 518fce765b965b1a32aaf0a42757fad723b0fb49 Mon Sep 17 00:00:00 2001 From: Christian Date: Wed, 8 Jul 2026 17:01:17 -0500 Subject: [PATCH 2/5] Implement parser-safe SSAT late binding --- .../benches/html_processor_bench.rs | 8 +- .../trusted-server-core/src/html_processor.rs | 303 +++- crates/trusted-server-core/src/lib.rs | 1 + crates/trusted-server-core/src/publisher.rs | 1354 ++++++++++++----- .../src/publisher_late_binding.rs | 317 ++++ ...reaming-parser-safe-implementation-plan.md | 740 +++++++++ ...-publisher-streaming-parser-safe-design.md | 823 ++++++++++ 7 files changed, 3083 insertions(+), 463 deletions(-) create mode 100644 crates/trusted-server-core/src/publisher_late_binding.rs create mode 100644 docs/superpowers/plans/2026-07-08-ssat-publisher-streaming-parser-safe-implementation-plan.md create mode 100644 docs/superpowers/specs/2026-07-08-ssat-publisher-streaming-parser-safe-design.md diff --git a/crates/trusted-server-core/benches/html_processor_bench.rs b/crates/trusted-server-core/benches/html_processor_bench.rs index 9e81d67d91..c5488c4437 100644 --- a/crates/trusted-server-core/benches/html_processor_bench.rs +++ b/crates/trusted-server-core/benches/html_processor_bench.rs @@ -1,5 +1,8 @@ use criterion::{black_box, criterion_group, criterion_main, BenchmarkId, Criterion}; -use trusted_server_core::html_processor::{create_html_processor, HtmlProcessorConfig}; +use trusted_server_core::html_processor::{ + create_html_processor, BidInjectionMode, HtmlPostProcessingMode, HtmlProcessorConfig, +}; +use trusted_server_core::integrations::IntegrationDocumentState; use trusted_server_core::integrations::IntegrationRegistry; use trusted_server_core::streaming_processor::StreamProcessor as _; @@ -12,6 +15,9 @@ fn make_config() -> HtmlProcessorConfig { ad_slots_script: None, ad_bids_state: std::sync::Arc::new(std::sync::Mutex::new(None)), max_buffered_body_bytes: 16 * 1024 * 1024, + bid_injection_mode: BidInjectionMode::DirectState, + post_processing_mode: HtmlPostProcessingMode::Enabled, + document_state: IntegrationDocumentState::default(), } } diff --git a/crates/trusted-server-core/src/html_processor.rs b/crates/trusted-server-core/src/html_processor.rs index a3170084a3..884214aa6b 100644 --- a/crates/trusted-server-core/src/html_processor.rs +++ b/crates/trusted-server-core/src/html_processor.rs @@ -19,6 +19,7 @@ use crate::integrations::{ IntegrationScriptContext, ScriptRewriteAction, }; use crate::publisher::build_empty_bids_script; +use crate::publisher_late_binding::HtmlInjectionTracker; use crate::settings::Settings; use crate::streaming_processor::{HtmlRewriterAdapter, StreamProcessor}; use crate::tsjs; @@ -92,66 +93,103 @@ impl StreamProcessor for HtmlWithPostProcessing { return Ok(Vec::new()); } - // Final chunk: run post-processors on the full accumulated output. - let full_output = std::mem::take(&mut self.accumulated_output); - if full_output.is_empty() { - return Ok(full_output); - } + run_html_post_processors( + std::mem::take(&mut self.accumulated_output), + &self.post_processors, + &self.origin_host, + &self.request_host, + &self.request_scheme, + &self.document_state, + self.max_buffered_body_bytes, + ) + } - let Ok(output_str) = std::str::from_utf8(&full_output) else { - return Ok(full_output); - }; + /// No-op. `HtmlWithPostProcessing` wraps a single-use + /// [`HtmlRewriterAdapter`] that cannot be reset. Clearing auxiliary + /// state without resetting the rewriter would leave the processor + /// in an inconsistent state, so this method intentionally does nothing. + fn reset(&mut self) {} +} - let ctx = IntegrationHtmlContext { - request_host: &self.request_host, - request_scheme: &self.request_scheme, - origin_host: &self.origin_host, - document_state: &self.document_state, - }; +/// Run registered full-document post-processors on already rewritten HTML. +pub(crate) fn run_html_post_processors( + full_output: Vec, + post_processors: &[Arc], + origin_host: &str, + request_host: &str, + request_scheme: &str, + document_state: &IntegrationDocumentState, + max_buffered_body_bytes: usize, +) -> Result, io::Error> { + if full_output.is_empty() || post_processors.is_empty() { + return Ok(full_output); + } - // Preflight to avoid allocating a `String` unless at least one post-processor wants to run. - if !self - .post_processors - .iter() - .any(|p| p.should_process(output_str, &ctx)) - { - return Ok(full_output); - } + let Ok(output_str) = std::str::from_utf8(&full_output) else { + return Ok(full_output); + }; - let mut html = String::from_utf8(full_output).map_err(|e| { - io::Error::other(format!( - "HTML post-processing expected valid UTF-8 output: {e}" - )) - })?; + let ctx = IntegrationHtmlContext { + request_host, + request_scheme, + origin_host, + document_state, + }; - let mut changed = false; - for processor in &self.post_processors { - if processor.should_process(&html, &ctx) { - changed |= processor.post_process(&mut html, &ctx); - } - } + if !post_processors + .iter() + .any(|processor| processor.should_process(output_str, &ctx)) + { + return Ok(full_output); + } - if changed { - log::debug!("HTML post-processing complete: output_len={}", html.len()); - } + let mut html = String::from_utf8(full_output).map_err(|e| { + io::Error::other(format!( + "HTML post-processing expected valid UTF-8 output: {e}" + )) + })?; - // Post-processors may append content (e.g. injected scripts); enforce the - // same cap on the final document so growth during post-processing cannot - // push the buffer past the limit either. - if html.len() > self.max_buffered_body_bytes { - return Err(io::Error::other( - "publisher body exceeded maximum buffered size", - )); + let mut changed = false; + for processor in post_processors { + if processor.should_process(&html, &ctx) { + changed |= processor.post_process(&mut html, &ctx); } + } - Ok(html.into_bytes()) + if changed { + log::debug!("HTML post-processing complete: output_len={}", html.len()); } - /// No-op. `HtmlWithPostProcessing` wraps a single-use - /// [`HtmlRewriterAdapter`] that cannot be reset. Clearing auxiliary - /// state without resetting the rewriter would leave the processor - /// in an inconsistent state, so this method intentionally does nothing. - fn reset(&mut self) {} + if html.len() > max_buffered_body_bytes { + return Err(io::Error::other( + "publisher body exceeded maximum buffered size", + )); + } + + Ok(html.into_bytes()) +} + +/// How SSAT bids are inserted into parser-confirmed body end tags. +#[derive(Clone)] +pub enum BidInjectionMode { + /// Read the current bids script from shared state at the body end tag. + DirectState, + /// Insert an opaque placeholder for a later parser-safe replacement pass. + Placeholder { + /// Placeholder HTML inserted before the body end tag. + html: String, + /// Shared tracker used for EOF fallback decisions. + tracker: Arc, + }, +} + +/// Whether full-document HTML post-processors run inside this processor. +#[derive(Clone, Copy)] +pub enum HtmlPostProcessingMode { + /// Run registered full-document post-processors at EOF. + Enabled, + /// Skip post-processors so callers can run them after bid late binding. + Disabled, } /// Configuration for HTML processing @@ -172,6 +210,13 @@ pub struct HtmlProcessorConfig { /// processor aborts. Mirrors `publisher.max_buffered_body_bytes` so the /// full-document buffering done for post-processors is bounded. pub max_buffered_body_bytes: usize, + /// Bid insertion strategy for parser-confirmed body end tags. + pub bid_injection_mode: BidInjectionMode, + /// Controls whether full-document post-processors run inside this processor. + pub post_processing_mode: HtmlPostProcessingMode, + /// Per-document integration state shared by script rewriters and optional + /// post-processors. + pub document_state: IntegrationDocumentState, } impl HtmlProcessorConfig { @@ -192,6 +237,9 @@ impl HtmlProcessorConfig { ad_slots_script: None, ad_bids_state: std::sync::Arc::new(std::sync::Mutex::new(None)), max_buffered_body_bytes: settings.publisher.max_buffered_body_bytes, + bid_injection_mode: BidInjectionMode::DirectState, + post_processing_mode: HtmlPostProcessingMode::Enabled, + document_state: IntegrationDocumentState::default(), } } @@ -212,6 +260,65 @@ impl HtmlProcessorConfig { self.ad_bids_state = ad_bids_state; self } + + /// Insert `placeholder_html` at the parser-confirmed body end tag instead + /// of reading bids directly from shared state. + #[must_use] + pub fn with_bid_placeholder( + mut self, + placeholder_html: String, + tracker: Arc, + ) -> Self { + self.bid_injection_mode = BidInjectionMode::Placeholder { + html: placeholder_html, + tracker, + }; + self + } + + /// Disable full-document post-processors for a caller-managed buffered pass. + #[must_use] + pub fn without_post_processing(mut self) -> Self { + self.post_processing_mode = HtmlPostProcessingMode::Disabled; + self + } + + /// Use caller-provided document state for multi-phase processing. + #[must_use] + pub fn with_document_state(mut self, document_state: IntegrationDocumentState) -> Self { + self.document_state = document_state; + self + } +} + +/// Build the executable snippet normally inserted at the start of ``. +#[must_use] +pub(crate) fn build_head_bootstrap_snippet( + integrations: &IntegrationRegistry, + origin_host: &str, + request_host: &str, + request_scheme: &str, + document_state: &IntegrationDocumentState, + ad_slots_script: Option<&str>, +) -> String { + let mut snippet = String::new(); + if let Some(slots_script) = ad_slots_script { + snippet.push_str(slots_script); + } + let ctx = IntegrationHtmlContext { + request_host, + request_scheme, + origin_host, + document_state, + }; + for insert in integrations.head_inserts(&ctx) { + snippet.push_str(&insert); + } + let immediate_ids = integrations.js_module_ids_immediate(); + snippet.push_str(&tsjs::tsjs_script_tag(&immediate_ids)); + let deferred_ids = integrations.js_module_ids_deferred(); + snippet.push_str(&tsjs::tsjs_deferred_script_tags(&deferred_ids)); + snippet } /// Create an HTML processor with URL replacement and integration hooks. @@ -222,8 +329,11 @@ impl HtmlProcessorConfig { /// normal operation since no code holds the lock across a panic boundary. #[must_use] pub fn create_html_processor(config: HtmlProcessorConfig) -> impl StreamProcessor { - let post_processors = config.integrations.html_post_processors(); - let document_state = IntegrationDocumentState::default(); + let post_processors = match config.post_processing_mode { + HtmlPostProcessingMode::Enabled => config.integrations.html_post_processors(), + HtmlPostProcessingMode::Disabled => Vec::new(), + }; + let document_state = config.document_state.clone(); // Simplified URL patterns structure - stores only core data and generates variants on-demand struct UrlPatterns { @@ -294,6 +404,8 @@ pub fn create_html_processor(config: HtmlProcessorConfig) -> impl StreamProcesso let script_rewriters = integration_registry.script_rewriters(); let ad_slots_script = config.ad_slots_script.clone(); let ad_bids_state = config.ad_bids_state.clone(); + let bid_injection_mode = config.bid_injection_mode.clone(); + let head_bid_injection_mode = bid_injection_mode.clone(); let mut element_content_handlers = vec![ // Inject unified tsjs bundle once at the start of @@ -304,33 +416,29 @@ pub fn create_html_processor(config: HtmlProcessorConfig) -> impl StreamProcesso let document_state = document_state.clone(); let ad_slots_script = ad_slots_script.clone(); move |el| { - if !injected_tsjs.get() { - let mut snippet = String::new(); - // Inject ad slots script first so it appears before tsjs bundle. - if let Some(ref slots_script) = ad_slots_script { - snippet.push_str(slots_script); - } - let ctx = IntegrationHtmlContext { - request_host: &patterns.request_host, - request_scheme: &patterns.request_scheme, - origin_host: &patterns.origin_host, - document_state: &document_state, - }; - // First inject integration-specific config (e.g., window.__tsjs_prebid) - // so it's available when the bundle's auto-init code reads it. - for insert in integrations.head_inserts(&ctx) { - snippet.push_str(&insert); + if injected_tsjs.get() { + return Ok(()); + } + if let BidInjectionMode::Placeholder { tracker, .. } = &head_bid_injection_mode { + if tracker.head_injected() || tracker.bid_placeholder_inserted() { + injected_tsjs.set(true); + return Ok(()); } - // Main bundle: core + non-deferred integrations (synchronous). - let immediate_ids = integrations.js_module_ids_immediate(); - snippet.push_str(&tsjs::tsjs_script_tag(&immediate_ids)); - // Deferred bundles: large modules like prebid loaded after - // HTML parsing completes. Empty when none are enabled. - let deferred_ids = integrations.js_module_ids_deferred(); - snippet.push_str(&tsjs::tsjs_deferred_script_tags(&deferred_ids)); - el.prepend(&snippet, ContentType::Html); - injected_tsjs.set(true); } + + let snippet = build_head_bootstrap_snippet( + &integrations, + &patterns.origin_host, + &patterns.request_host, + &patterns.request_scheme, + &document_state, + ad_slots_script.as_deref(), + ); + if let BidInjectionMode::Placeholder { tracker, .. } = &head_bid_injection_mode { + tracker.mark_head_injected(); + } + el.prepend(&snippet, ContentType::Html); + injected_tsjs.set(true); Ok(()) } }), @@ -345,24 +453,34 @@ pub fn create_html_processor(config: HtmlProcessorConfig) -> impl StreamProcesso let state = ad_bids_state.clone(); let injected_bids = injected_bids.clone(); let has_slots = ad_slots_script.is_some(); + let bid_injection_mode = bid_injection_mode.clone(); move |el| { if !has_slots { return Ok(()); } let state = state.clone(); let injected_bids = injected_bids.clone(); + let bid_injection_mode = bid_injection_mode.clone(); if let Some(handlers) = el.end_tag_handlers() { let handler: EndTagHandler<'static> = Box::new(move |end_tag: &mut EndTag<'_>| { if injected_bids.swap(true, Ordering::SeqCst) { return Ok(()); } - let script_guard = state.lock().expect("should lock bid state"); - let bids_script = match &*script_guard { - Some(s) => s.clone(), - None => build_empty_bids_script(), - }; - end_tag.before(&bids_script, ContentType::Html); + match &bid_injection_mode { + BidInjectionMode::DirectState => { + let script_guard = state.lock().expect("should lock bid state"); + let bids_script = match &*script_guard { + Some(s) => s.clone(), + None => build_empty_bids_script(), + }; + end_tag.before(&bids_script, ContentType::Html); + } + BidInjectionMode::Placeholder { html, tracker } => { + tracker.mark_bid_placeholder_inserted(); + end_tag.before(html, ContentType::Html); + } + } Ok(()) }); handlers.push(handler); @@ -664,6 +782,9 @@ mod tests { ad_slots_script: None, ad_bids_state: std::sync::Arc::new(std::sync::Mutex::new(None)), max_buffered_body_bytes: 16 * 1024 * 1024, + bid_injection_mode: BidInjectionMode::DirectState, + post_processing_mode: HtmlPostProcessingMode::Enabled, + document_state: IntegrationDocumentState::default(), } } @@ -1436,6 +1557,9 @@ mod tests { ), ad_bids_state: std::sync::Arc::new(std::sync::Mutex::new(None)), max_buffered_body_bytes: 16 * 1024 * 1024, + bid_injection_mode: BidInjectionMode::DirectState, + post_processing_mode: HtmlPostProcessingMode::Enabled, + document_state: IntegrationDocumentState::default(), }; let mut processor = create_html_processor(config); let output = processor @@ -1509,6 +1633,9 @@ mod tests { ), ad_bids_state: state, max_buffered_body_bytes: 16 * 1024 * 1024, + bid_injection_mode: BidInjectionMode::DirectState, + post_processing_mode: HtmlPostProcessingMode::Enabled, + document_state: IntegrationDocumentState::default(), }; let mut processor = create_html_processor(config); let output = processor @@ -1544,6 +1671,9 @@ mod tests { ), ad_bids_state: state, max_buffered_body_bytes: 16 * 1024 * 1024, + bid_injection_mode: BidInjectionMode::DirectState, + post_processing_mode: HtmlPostProcessingMode::Enabled, + document_state: IntegrationDocumentState::default(), }; let mut processor = create_html_processor(config); // Malformed HTML with two elements (common in CMS template pages) @@ -1578,6 +1708,9 @@ mod tests { ad_slots_script: None, ad_bids_state: std::sync::Arc::new(std::sync::Mutex::new(None)), max_buffered_body_bytes: 16 * 1024 * 1024, + bid_injection_mode: BidInjectionMode::DirectState, + post_processing_mode: HtmlPostProcessingMode::Enabled, + document_state: IntegrationDocumentState::default(), }; let mut processor = create_html_processor(config); let output = processor @@ -1630,6 +1763,9 @@ mod tests { ), ad_bids_state: state, max_buffered_body_bytes: 16 * 1024 * 1024, + bid_injection_mode: BidInjectionMode::DirectState, + post_processing_mode: HtmlPostProcessingMode::Enabled, + document_state: IntegrationDocumentState::default(), }; let mut processor = create_html_processor(config); let output = processor @@ -1656,6 +1792,9 @@ mod tests { ad_slots_script: None, ad_bids_state: state, max_buffered_body_bytes: 16 * 1024 * 1024, + bid_injection_mode: BidInjectionMode::DirectState, + post_processing_mode: HtmlPostProcessingMode::Enabled, + document_state: IntegrationDocumentState::default(), }; let mut processor = create_html_processor(config); let output = processor diff --git a/crates/trusted-server-core/src/lib.rs b/crates/trusted-server-core/src/lib.rs index 48e92faed8..a47a63bd7a 100644 --- a/crates/trusted-server-core/src/lib.rs +++ b/crates/trusted-server-core/src/lib.rs @@ -59,6 +59,7 @@ pub mod platform; pub mod price_bucket; pub mod proxy; pub mod publisher; +pub mod publisher_late_binding; pub mod redacted; pub mod request_signing; pub mod response_privacy; diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index acf92959c6..1262676da6 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -18,7 +18,7 @@ //! into any [`Write`] (a `Vec` for buffered routes, a streaming writer for //! the streaming route). It is not a content-rewriting concern. -use std::io::Write; +use std::io::{Read, Write}; use std::sync::{Arc, Mutex}; use std::time::Duration; @@ -54,6 +54,10 @@ use crate::http_util::{is_navigation_request, serve_static_with_etag, RequestInf use crate::integrations::IntegrationRegistry; use crate::platform::{GeoInfo, PlatformBackendSpec, PlatformHttpRequest, RuntimeServices}; use crate::price_bucket::{price_bucket, PriceGranularity}; +use crate::publisher_late_binding::{ + BidPlaceholder, HtmlInjectionTracker, PlaceholderLateBinder, PlaceholderScan, + SSAT_HELD_TAIL_CAP_BYTES, +}; use crate::rsc_flight::RscFlightUrlRewriter; use crate::settings::Settings; use crate::streaming_processor::{Compression, PipelineConfig, StreamProcessor, StreamingPipeline}; @@ -345,16 +349,56 @@ fn create_html_stream_processor( ad_slots_script: Option, ad_bids_state: Arc>>, ) -> Result> { - use crate::html_processor::{create_html_processor, HtmlProcessorConfig}; - - let config = HtmlProcessorConfig::from_settings( - settings, - integration_registry, + create_html_stream_processor_with_options(HtmlStreamProcessorOptions { origin_host, request_host, request_scheme, + settings, + integration_registry, + ad_slots_script, + ad_bids_state, + late_binding: None, + }) +} + +struct HtmlLateBindingOptions<'a> { + placeholder: &'a BidPlaceholder, + tracker: Arc, +} + +struct HtmlStreamProcessorOptions<'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>>, + late_binding: Option>, +} + +fn create_html_stream_processor_with_options( + options: HtmlStreamProcessorOptions<'_>, +) -> Result> { + use crate::html_processor::{create_html_processor, HtmlProcessorConfig}; + + let mut config = HtmlProcessorConfig::from_settings( + options.settings, + options.integration_registry, + options.origin_host, + options.request_host, + options.request_scheme, ) - .with_ad_state(ad_slots_script, ad_bids_state); + .with_ad_state(options.ad_slots_script, options.ad_bids_state); + + if let Some(late_binding) = options.late_binding { + config = config + .with_bid_placeholder( + late_binding.placeholder.as_html().to_owned(), + late_binding.tracker, + ) + .without_post_processing(); + } Ok(create_html_processor(config)) } @@ -677,27 +721,24 @@ pub fn stream_publisher_body( process_response_streaming(body, output, &borrowed) } -/// Stream publisher body with a `` syntax. /// -/// 1. [`collect_dispatched_auction`](AuctionOrchestrator::collect_dispatched_auction) -/// is awaited with the remaining deadline. -/// 2. Winning bids are written to `ad_bids_state`. -/// 3. The held tail is fed through the pipeline so `lol_html` fires its -/// `` handler with bids now in state. -/// -/// For non-HTML content types the auction is collected before any body bytes -/// are written (no `` to inject). If `params.dispatched_auction` is -/// `None` the function falls back to the synchronous -/// [`stream_publisher_body`] path. +/// For non-HTML content types, any dispatched auction is collected before body +/// bytes are written because there is no parser-confirmed body close for bid +/// insertion. If no SSAT slots matched this HTML response, the function falls +/// back to the synchronous pipeline unchanged. /// /// # Errors /// -/// Returns an error if processing fails mid-stream. Headers are already -/// committed at that point; the caller logs and drops the `StreamingBody`. +/// Returns an error if processing fails mid-stream. Headers may already be +/// committed at that point; streaming callers should log the error and drop the +/// client body. pub async fn stream_publisher_body_async( body: EdgeBody, output: &mut W, @@ -707,76 +748,100 @@ pub async fn stream_publisher_body_async( orchestrator: &AuctionOrchestrator, services: &RuntimeServices, ) -> Result<(), Report> { - let Some(dispatched) = params.dispatched_auction.take() else { - // No auction — use the existing sync pipeline unchanged. - return stream_publisher_body(body, output, params, settings, integration_registry); - }; + let dispatched = params.dispatched_auction.take(); let telemetry = AuctionTelemetryCarry { observation: params.auction_observation.take(), auction_request: params.auction_request.take(), }; let is_html = is_html_content_type(¶ms.content_type); - - if !is_html { - // Non-HTML: collect auction first, then stream. There is no - // to hold, so delaying the entire body until collection is acceptable. - let placeholder = mediator_placeholder_request(); - let result = orchestrator - .collect_dispatched_auction( - dispatched, - services, - &make_collect_context(settings, services, &placeholder), - ) - .await; - if let (Some(observation), Some(auction_request)) = - (telemetry.observation, telemetry.auction_request.as_ref()) - { - emit_auction_events_best_effort_lazy(services, || { - build_auction_events( - observation, - AuctionTerminalOutcome::Completed { - request: auction_request, - result: &result, - }, + let needs_ssat_late_binding = is_html && params.ad_slots_script.is_some(); + + if !needs_ssat_late_binding { + if let Some(dispatched) = dispatched { + // Non-HTML or no-slot responses have no SSAT body-close injection + // point. Collect before writing bytes so telemetry completes and the + // existing direct-state pipeline can preserve legacy behavior. + let placeholder = mediator_placeholder_request(); + let result = orchestrator + .collect_dispatched_auction( + dispatched, + services, + &make_collect_context(settings, services, &placeholder), ) - }) - .await; - } + .await; + if let (Some(observation), Some(auction_request)) = + (telemetry.observation, telemetry.auction_request.as_ref()) + { + emit_auction_events_best_effort_lazy(services, || { + build_auction_events( + observation, + AuctionTerminalOutcome::Completed { + request: auction_request, + result: &result, + }, + ) + }) + .await; + } - write_bids_to_state( - &result.winning_bids, - params.price_granularity, - ¶ms.ad_bids_state, - settings.debug.inject_adm_for_testing, - ); + write_bids_to_state( + &result.winning_bids, + params.price_granularity, + ¶ms.ad_bids_state, + settings.debug.inject_adm_for_testing, + ); + } return stream_publisher_body(body, output, params, settings, integration_registry); } - // 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, - settings, - integration_registry, - params.ad_slots_script.as_deref().map(str::to_string), - params.ad_bids_state.clone(), - ) { - Ok(processor) => processor, - Err(err) => { - emit_abandoned_auction( + if integration_registry.has_html_post_processors() { + return buffer_html_late_binding_with_postprocessors( + body, + output, + BufferedLateBindingContext { + params, + settings, + integration_registry, + orchestrator, services, - telemetry.observation, dispatched, - "processor_init_error", - ) - .await; - return Err(err); - } - }; + telemetry, + }, + ) + .await; + } + + let placeholder = BidPlaceholder::new(); + let tracker = Arc::new(HtmlInjectionTracker::default()); + let mut processor = + match create_html_stream_processor_with_options(HtmlStreamProcessorOptions { + origin_host: ¶ms.origin_host, + request_host: ¶ms.request_host, + request_scheme: ¶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(), + late_binding: Some(HtmlLateBindingOptions { + placeholder: &placeholder, + tracker: Arc::clone(&tracker), + }), + }) { + Ok(processor) => processor, + Err(err) => { + if let Some(dispatched) = dispatched { + emit_abandoned_auction( + services, + telemetry.observation, + dispatched, + "processor_init_error", + ) + .await; + } + return Err(err); + } + }; let compression = Compression::from_content_encoding(¶ms.content_encoding); stream_html_with_auction_hold( @@ -793,10 +858,289 @@ pub async fn stream_publisher_body_async( services, settings, }, + LateBindingStreamConfig { + placeholder: &placeholder, + tracker: Arc::clone(&tracker), + fallback: LateBindingFallbackContext { + origin_host: ¶ms.origin_host, + request_host: ¶ms.request_host, + request_scheme: ¶ms.request_scheme, + integration_registry, + ad_slots_script: params.ad_slots_script.as_deref(), + }, + }, ) .await } +struct BufferedLateBindingContext<'a> { + params: &'a OwnedProcessResponseParams, + settings: &'a Settings, + integration_registry: &'a IntegrationRegistry, + orchestrator: &'a AuctionOrchestrator, + services: &'a RuntimeServices, + dispatched: Option, + telemetry: AuctionTelemetryCarry, +} + +async fn buffer_html_late_binding_with_postprocessors( + body: EdgeBody, + output: &mut W, + mut ctx: BufferedLateBindingContext<'_>, +) -> Result<(), Report> { + use crate::html_processor::{ + create_html_processor, run_html_post_processors, HtmlProcessorConfig, + }; + use crate::integrations::IntegrationDocumentState; + + let compression = Compression::from_content_encoding(&ctx.params.content_encoding); + let decoded = match decode_body_to_vec( + body, + compression, + ctx.settings.publisher.max_buffered_body_bytes, + ) { + Ok(decoded) => decoded, + Err(err) => { + if let Some(dispatched) = ctx.dispatched.take() { + emit_abandoned_auction( + ctx.services, + ctx.telemetry.observation.take(), + dispatched, + "buffered_decode_error", + ) + .await; + } + return Err(err); + } + }; + + let placeholder = BidPlaceholder::new(); + let tracker = Arc::new(HtmlInjectionTracker::default()); + let document_state = IntegrationDocumentState::default(); + let config = HtmlProcessorConfig::from_settings( + ctx.settings, + ctx.integration_registry, + &ctx.params.origin_host, + &ctx.params.request_host, + &ctx.params.request_scheme, + ) + .with_ad_state( + ctx.params.ad_slots_script.clone(), + Arc::clone(&ctx.params.ad_bids_state), + ) + .with_bid_placeholder(placeholder.as_html().to_owned(), Arc::clone(&tracker)) + .without_post_processing() + .with_document_state(document_state.clone()); + let mut processor = create_html_processor(config); + let held_tail_cap = + SSAT_HELD_TAIL_CAP_BYTES.min(ctx.settings.publisher.max_buffered_body_bytes); + let mut binder = PlaceholderLateBinder::new(&placeholder, held_tail_cap); + let mut late_bound = Vec::new(); + let fallback = LateBindingFallbackContext { + origin_host: &ctx.params.origin_host, + request_host: &ctx.params.request_host, + request_scheme: &ctx.params.request_scheme, + integration_registry: ctx.integration_registry, + ad_slots_script: ctx.params.ad_slots_script.as_deref(), + }; + let mut state = LateBindingState { + dispatched: ctx.dispatched, + telemetry: ctx.telemetry, + price_granularity: ctx.params.price_granularity, + ad_bids_state: &ctx.params.ad_bids_state, + orchestrator: ctx.orchestrator, + services: ctx.services, + settings: ctx.settings, + tracker: Arc::clone(&tracker), + fallback, + decoded_input_bytes: decoded.len(), + processed_output_bytes: 0, + }; + + for chunk in decoded.chunks(STREAM_CHUNK_SIZE) { + let processed = + processor + .process_chunk(chunk, false) + .change_context(TrustedServerError::Proxy { + message: "Failed to process buffered publisher HTML".to_string(), + }); + let processed = match processed { + Ok(processed) => processed, + Err(err) => { + abandon_pending_late_binding_auction(&mut state, "buffered_process_error").await; + return Err(err); + } + }; + write_late_bound_processed_output(&mut late_bound, &mut binder, &processed, &mut state) + .await?; + } + + let final_out = processor + .process_chunk(&[], true) + .change_context(TrustedServerError::Proxy { + message: "Failed to process buffered publisher HTML".to_string(), + }); + let final_out = match final_out { + Ok(final_out) => final_out, + Err(err) => { + abandon_pending_late_binding_auction(&mut state, "buffered_process_error").await; + return Err(err); + } + }; + write_late_bound_processed_output(&mut late_bound, &mut binder, &final_out, &mut state).await?; + let found_placeholder = binder.replaced(); + let remainder = binder.finish(); + write_checked_processed_output_or_abandon( + &mut late_bound, + &remainder, + &mut state, + "processed_output_error", + ) + .await?; + if !found_placeholder { + if state.tracker.bid_placeholder_inserted() { + log::warn!( + "SSAT bid placeholder was inserted but not observed in buffered output; using EOF fallback" + ); + } + resolve_late_bound_bids(&mut state).await; + let tail = build_eof_fallback_tail( + state.tracker.head_injected(), + &state.fallback, + state.ad_bids_state, + ); + write_checked_processed_output_or_abandon( + &mut late_bound, + tail.as_bytes(), + &mut state, + "processed_output_error", + ) + .await?; + } + + let post_processors = ctx.integration_registry.html_post_processors(); + let post_processed = run_html_post_processors( + late_bound, + &post_processors, + &ctx.params.origin_host, + &ctx.params.request_host, + &ctx.params.request_scheme, + &document_state, + ctx.settings.publisher.max_buffered_body_bytes, + ) + .change_context(TrustedServerError::Proxy { + message: "Failed to post-process buffered publisher HTML".to_string(), + })?; + + encode_processed_html_to_writer(&post_processed, compression, output) +} + +fn decode_body_to_vec( + body: EdgeBody, + compression: Compression, + limit: usize, +) -> Result, Report> { + use brotli::Decompressor; + use flate2::read::{GzDecoder, ZlibDecoder}; + + let body = body_as_reader(body); + match compression { + Compression::None => read_to_vec_bounded(body, limit), + Compression::Gzip => read_to_vec_bounded(GzDecoder::new(body), limit), + Compression::Deflate => read_to_vec_bounded(ZlibDecoder::new(body), limit), + Compression::Brotli => { + read_to_vec_bounded(Decompressor::new(body, STREAM_CHUNK_SIZE), limit) + } + } +} + +fn read_to_vec_bounded( + mut reader: R, + limit: usize, +) -> Result, Report> { + let mut output = Vec::new(); + let mut buffer = vec![0u8; STREAM_CHUNK_SIZE]; + loop { + match reader.read(&mut buffer) { + Ok(0) => return Ok(output), + Ok(n) => { + if output.len() + n > limit { + return Err(Report::new(TrustedServerError::Proxy { + message: "publisher decoded input exceeded maximum buffered size" + .to_string(), + })); + } + output.extend_from_slice(&buffer[..n]); + } + Err(err) => { + return Err(Report::new(TrustedServerError::Proxy { + message: format!("Failed to decode publisher body: {err}"), + })); + } + } + } +} + +fn encode_processed_html_to_writer( + bytes: &[u8], + compression: Compression, + output: &mut W, +) -> Result<(), Report> { + use brotli::enc::writer::CompressorWriter; + use brotli::enc::BrotliEncoderParams; + use flate2::write::{GzEncoder, ZlibEncoder}; + + match compression { + Compression::None => output + .write_all(bytes) + .change_context(TrustedServerError::Proxy { + message: "Failed to write buffered publisher HTML".to_string(), + }), + Compression::Gzip => { + let mut encoder = GzEncoder::new(output, flate2::Compression::default()); + encoder + .write_all(bytes) + .change_context(TrustedServerError::Proxy { + message: "Failed to write gzip publisher HTML".to_string(), + })?; + encoder.finish().change_context(TrustedServerError::Proxy { + message: "Failed to finalize gzip encoder".to_string(), + })?; + Ok(()) + } + Compression::Deflate => { + let mut encoder = ZlibEncoder::new(output, flate2::Compression::default()); + encoder + .write_all(bytes) + .change_context(TrustedServerError::Proxy { + message: "Failed to write deflate publisher HTML".to_string(), + })?; + encoder.finish().change_context(TrustedServerError::Proxy { + message: "Failed to finalize deflate encoder".to_string(), + })?; + Ok(()) + } + Compression::Brotli => { + let params = BrotliEncoderParams { + quality: 4, + lgwin: 22, + ..Default::default() + }; + let mut encoder = CompressorWriter::with_params(output, STREAM_CHUNK_SIZE, ¶ms); + encoder + .write_all(bytes) + .change_context(TrustedServerError::Proxy { + message: "Failed to write brotli publisher HTML".to_string(), + })?; + encoder.flush().change_context(TrustedServerError::Proxy { + message: "Failed to finalize brotli encoder".to_string(), + })?; + let _ = encoder.into_inner(); + Ok(()) + } + } +} + /// Builds the canonical mediator placeholder [`Request`] passed to the collect /// phase via [`make_collect_context`]. /// @@ -964,7 +1308,7 @@ impl AuctionTelemetryCarry { /// Bundles the auction-collection dependencies passed through the streaming helpers. struct AuctionCollectCtx<'a> { - dispatched: DispatchedAuction, + dispatched: Option, telemetry: AuctionTelemetryCarry, price_granularity: PriceGranularity, ad_bids_state: &'a Arc>>, @@ -973,14 +1317,29 @@ struct AuctionCollectCtx<'a> { settings: &'a Settings, } -/// Run the close-body hold loop for HTML bodies, collecting the auction before -/// the raw ` { + origin_host: &'a str, + request_host: &'a str, + request_scheme: &'a str, + integration_registry: &'a IntegrationRegistry, + ad_slots_script: Option<&'a str>, +} + +struct LateBindingStreamConfig<'a> { + placeholder: &'a BidPlaceholder, + tracker: Arc, + fallback: LateBindingFallbackContext<'a>, +} + +/// Run the parser-safe placeholder late-binding loop for HTML bodies. async fn stream_html_with_auction_hold( body: EdgeBody, output: &mut W, processor: &mut P, compression: Compression, ctx: AuctionCollectCtx<'_>, + late_binding: LateBindingStreamConfig<'_>, ) -> Result<(), Report> { use brotli::enc::writer::CompressorWriter; use brotli::enc::BrotliEncoderParams; @@ -988,13 +1347,36 @@ async fn stream_html_with_auction_hold( use flate2::read::{GzDecoder, ZlibDecoder}; use flate2::write::{GzEncoder, ZlibEncoder}; + let placeholder = late_binding.placeholder; + let tracker = late_binding.tracker; + let fallback_ctx = late_binding.fallback; let body = body_as_reader(body); match compression { - Compression::None => body_close_hold_loop(body, output, processor, ctx).await, + Compression::None => { + body_close_hold_loop( + body, + output, + processor, + placeholder, + Arc::clone(&tracker), + ctx, + fallback_ctx, + ) + .await + } Compression::Gzip => { let decoder = GzDecoder::new(body); let mut encoder = GzEncoder::new(&mut *output, flate2::Compression::default()); - body_close_hold_loop(decoder, &mut encoder, processor, ctx).await?; + body_close_hold_loop( + decoder, + &mut encoder, + processor, + placeholder, + Arc::clone(&tracker), + ctx, + fallback_ctx, + ) + .await?; encoder.finish().change_context(TrustedServerError::Proxy { message: "Failed to finalize gzip encoder".to_string(), })?; @@ -1003,7 +1385,16 @@ async fn stream_html_with_auction_hold( Compression::Deflate => { let decoder = ZlibDecoder::new(body); let mut encoder = ZlibEncoder::new(&mut *output, flate2::Compression::default()); - body_close_hold_loop(decoder, &mut encoder, processor, ctx).await?; + body_close_hold_loop( + decoder, + &mut encoder, + processor, + placeholder, + Arc::clone(&tracker), + ctx, + fallback_ctx, + ) + .await?; encoder.finish().change_context(TrustedServerError::Proxy { message: "Failed to finalize deflate encoder".to_string(), })?; @@ -1018,150 +1409,154 @@ async fn stream_html_with_auction_hold( }; let mut encoder = CompressorWriter::with_params(&mut *output, STREAM_CHUNK_SIZE, ¶ms); - body_close_hold_loop(decoder, &mut encoder, processor, ctx).await?; + body_close_hold_loop( + decoder, + &mut encoder, + processor, + placeholder, + Arc::clone(&tracker), + ctx, + fallback_ctx, + ) + .await?; let _ = encoder.into_inner(); Ok(()) } } } -const BODY_CLOSE_PREFIX: &[u8] = b", - found_close: bool, -} - -impl BodyCloseHoldBuffer { - fn new() -> Self { - Self { - buffered: Vec::new(), - found_close: false, - } - } - - fn push(&mut self, chunk: &[u8]) -> Vec { - self.buffered.extend_from_slice(chunk); - - if self.found_close { - return Vec::new(); - } - - if let Some(pos) = find_ascii_case_insensitive(&self.buffered, BODY_CLOSE_PREFIX) { - self.found_close = true; - return self.buffered.drain(..pos).collect(); - } - - let keep_len = BODY_CLOSE_PREFIX.len().saturating_sub(1); - if self.buffered.len() <= keep_len { - return Vec::new(); - } - - let split_at = self.buffered.len() - keep_len; - self.buffered.drain(..split_at).collect() - } - - fn found_close(&self) -> bool { - self.found_close - } - - fn finish(self) -> Vec { - self.buffered - } -} - -fn find_ascii_case_insensitive(haystack: &[u8], needle: &[u8]) -> Option { - haystack.windows(needle.len()).position(|window| { - window - .iter() - .zip(needle) - .all(|(left, right)| left.eq_ignore_ascii_case(right)) - }) +struct LateBindingState<'a> { + dispatched: Option, + telemetry: AuctionTelemetryCarry, + price_granularity: PriceGranularity, + ad_bids_state: &'a Arc>>, + orchestrator: &'a AuctionOrchestrator, + services: &'a RuntimeServices, + settings: &'a Settings, + tracker: Arc, + fallback: LateBindingFallbackContext<'a>, + decoded_input_bytes: usize, + processed_output_bytes: usize, } -/// Core close-body hold loop. -/// -/// Streams processed output until the first case-insensitive `( +/// Core parser-safe placeholder late-binding loop. +async fn body_close_hold_loop( mut reader: R, writer: &mut W, processor: &mut P, + placeholder: &BidPlaceholder, + tracker: Arc, ctx: AuctionCollectCtx<'_>, + fallback_ctx: LateBindingFallbackContext<'_>, ) -> Result<(), Report> { let AuctionCollectCtx { dispatched, - mut telemetry, + telemetry, price_granularity, ad_bids_state, orchestrator, services, settings, } = ctx; + let held_tail_cap = SSAT_HELD_TAIL_CAP_BYTES.min(settings.publisher.max_buffered_body_bytes); + let mut binder = PlaceholderLateBinder::new(placeholder, held_tail_cap); + let mut state = LateBindingState { + dispatched, + telemetry, + price_granularity, + ad_bids_state, + orchestrator, + services, + settings, + tracker, + fallback: fallback_ctx, + decoded_input_bytes: 0, + processed_output_bytes: 0, + }; let mut buffer = vec![0u8; STREAM_CHUNK_SIZE]; - let mut hold = Some(BodyCloseHoldBuffer::new()); - let mut dispatched = Some(dispatched); loop { - match reader.read(&mut buffer) { - Ok(0) => { - if let Some(hold) = hold.take() { - let dispatched = dispatched - .take() - .expect("should have dispatched auction to collect"); - collect_stream_auction( - dispatched, - telemetry.take(), - price_granularity, - ad_bids_state, - orchestrator, - services, - settings, - ) - .await; + match reader.read(&mut buffer) { + Ok(0) => { + let final_out = + processor + .process_chunk(&[], true) + .change_context(TrustedServerError::Proxy { + message: "Failed to finalize processor".to_string(), + }); + let final_out = match final_out { + Ok(final_out) => final_out, + Err(err) => { + abandon_pending_late_binding_auction(&mut state, "stream_finalize_error") + .await; + return Err(err); + } + }; + write_late_bound_processed_output(writer, &mut binder, &final_out, &mut state) + .await?; + + let found_placeholder = binder.replaced(); + let remainder = binder.finish(); + write_checked_processed_output_or_abandon( + writer, + &remainder, + &mut state, + "processed_output_error", + ) + .await?; - let held = hold.finish(); - write_processed_chunk( + if !found_placeholder { + if state.tracker.bid_placeholder_inserted() { + log::warn!( + "SSAT bid placeholder was inserted but not observed in processed output; using EOF fallback" + ); + } + resolve_late_bound_bids(&mut state).await; + let tail = build_eof_fallback_tail( + state.tracker.head_injected(), + &state.fallback, + state.ad_bids_state, + ); + write_checked_processed_output_or_abandon( writer, - processor, - &held, - false, - "Failed to process held body close", - "Failed to write held body close", - )?; - } - // Signal EOF to lol_html (fires end() which flushes remaining state). - let final_out = processor.process_chunk(&[], true).change_context( - TrustedServerError::Proxy { - message: "Failed to finalize processor".to_string(), - }, - )?; - if !final_out.is_empty() { - writer - .write_all(&final_out) - .change_context(TrustedServerError::Proxy { - message: "Failed to write finalized output".to_string(), - })?; + tail.as_bytes(), + &mut state, + "processed_output_error", + ) + .await?; } break; } Ok(n) => { - if let Some(hold_buffer) = hold.as_mut() { - let ready = hold_buffer.push(&buffer[..n]); - if let Err(err) = write_processed_chunk( - writer, - processor, - &ready, - false, - "Failed to process chunk", - "Failed to write chunk", - ) { - if let Some(dispatched) = dispatched.take() { + state.decoded_input_bytes = state.decoded_input_bytes.saturating_add(n); + if state.decoded_input_bytes > state.settings.publisher.max_buffered_body_bytes { + if let Some(dispatched) = state.dispatched.take() { + emit_abandoned_auction( + state.services, + state.telemetry.observation.take(), + dispatched, + "decoded_input_cap_exceeded", + ) + .await; + } + return Err(Report::new(TrustedServerError::Proxy { + message: "publisher decoded input exceeded maximum streaming size" + .to_string(), + })); + } + + let processed = processor.process_chunk(&buffer[..n], false).change_context( + TrustedServerError::Proxy { + message: "Failed to process chunk".to_string(), + }, + ); + let processed = match processed { + Ok(processed) => processed, + Err(err) => { + if let Some(dispatched) = state.dispatched.take() { emit_abandoned_auction( - services, - telemetry.observation.take(), + state.services, + state.telemetry.observation.take(), dispatched, "stream_process_error", ) @@ -1169,51 +1564,15 @@ async fn body_close_hold_loop( } return Err(err); } - - if hold_buffer.found_close() { - let dispatched = dispatched - .take() - .expect("should have dispatched auction to collect"); - collect_stream_auction( - dispatched, - telemetry.take(), - price_granularity, - ad_bids_state, - orchestrator, - services, - settings, - ) - .await; - - let held = hold - .take() - .expect("should have close-body hold buffer") - .finish(); - write_processed_chunk( - writer, - processor, - &held, - false, - "Failed to process held body close", - "Failed to write held body close", - )?; - } - } else { - write_processed_chunk( - writer, - processor, - &buffer[..n], - false, - "Failed to process chunk", - "Failed to write chunk", - )?; - } + }; + write_late_bound_processed_output(writer, &mut binder, &processed, &mut state) + .await?; } Err(e) => { - if let Some(dispatched) = dispatched.take() { + if let Some(dispatched) = state.dispatched.take() { emit_abandoned_auction( - services, - telemetry.observation.take(), + state.services, + state.telemetry.observation.take(), dispatched, "stream_read_error", ) @@ -1232,6 +1591,166 @@ async fn body_close_hold_loop( Ok(()) } +async fn write_late_bound_processed_output( + writer: &mut W, + binder: &mut PlaceholderLateBinder, + processed: &[u8], + state: &mut LateBindingState<'_>, +) -> Result<(), Report> { + let scan = match binder.push(processed) { + Ok(scan) => scan, + Err(err) => { + abandon_pending_late_binding_auction(state, "placeholder_scan_error").await; + return Err(err); + } + }; + + match scan { + PlaceholderScan::Emit(bytes) => { + write_checked_processed_output_or_abandon( + writer, + &bytes, + state, + "processed_output_error", + ) + .await + } + PlaceholderScan::Found { before, after } => { + write_checked_processed_output_or_abandon( + writer, + &before, + state, + "processed_output_error", + ) + .await?; + resolve_late_bound_bids(state).await; + let replacement = build_body_close_replacement_tail(state); + write_checked_processed_output(writer, replacement.as_bytes(), state)?; + write_checked_processed_output(writer, &after, state) + } + } +} + +async fn resolve_late_bound_bids(state: &mut LateBindingState<'_>) { + let Some(dispatched) = state.dispatched.take() else { + return; + }; + collect_stream_auction( + dispatched, + state.telemetry.take(), + state.price_granularity, + state.ad_bids_state, + state.orchestrator, + state.services, + state.settings, + ) + .await; +} + +async fn abandon_pending_late_binding_auction( + state: &mut LateBindingState<'_>, + reason: &'static str, +) { + let Some(dispatched) = state.dispatched.take() else { + return; + }; + emit_abandoned_auction( + state.services, + state.telemetry.observation.take(), + dispatched, + reason, + ) + .await; +} + +async fn write_checked_processed_output_or_abandon( + writer: &mut W, + bytes: &[u8], + state: &mut LateBindingState<'_>, + reason: &'static str, +) -> Result<(), Report> { + match write_checked_processed_output(writer, bytes, state) { + Ok(()) => Ok(()), + Err(err) => { + abandon_pending_late_binding_auction(state, reason).await; + Err(err) + } + } +} + +fn current_bids_script(ad_bids_state: &Arc>>) -> String { + ad_bids_state + .lock() + .expect("should lock bid state") + .clone() + .unwrap_or_else(build_empty_bids_script) +} + +fn build_body_close_replacement_tail(state: &LateBindingState<'_>) -> String { + if state.tracker.head_injected() { + return current_bids_script(state.ad_bids_state); + } + + log::info!("SSAT bid injection used missing-head body-close fallback"); + state.tracker.mark_head_injected(); + build_bootstrap_and_bids_tail(&state.fallback, state.ad_bids_state) +} + +fn build_eof_fallback_tail( + head_bootstrap_observed: bool, + fallback_ctx: &LateBindingFallbackContext<'_>, + ad_bids_state: &Arc>>, +) -> String { + if head_bootstrap_observed { + log::info!("SSAT bid injection used EOF fallback after head bootstrap"); + return current_bids_script(ad_bids_state); + } + + log::info!("SSAT bid injection used missing-head EOF fallback"); + build_bootstrap_and_bids_tail(fallback_ctx, ad_bids_state) +} + +fn build_bootstrap_and_bids_tail( + fallback_ctx: &LateBindingFallbackContext<'_>, + ad_bids_state: &Arc>>, +) -> String { + let document_state = crate::integrations::IntegrationDocumentState::default(); + let mut tail = crate::html_processor::build_head_bootstrap_snippet( + fallback_ctx.integration_registry, + fallback_ctx.origin_host, + fallback_ctx.request_host, + fallback_ctx.request_scheme, + &document_state, + fallback_ctx.ad_slots_script, + ); + tail.push_str(¤t_bids_script(ad_bids_state)); + tail +} + +fn write_checked_processed_output( + writer: &mut W, + bytes: &[u8], + state: &mut LateBindingState<'_>, +) -> Result<(), Report> { + if bytes.is_empty() { + return Ok(()); + } + + state.processed_output_bytes = state.processed_output_bytes.saturating_add(bytes.len()); + if state.processed_output_bytes > state.settings.publisher.max_buffered_body_bytes { + return Err(Report::new(TrustedServerError::Proxy { + message: "publisher processed output exceeded maximum streaming size".to_string(), + })); + } + + writer + .write_all(bytes) + .change_context(TrustedServerError::Proxy { + message: "Failed to write processed publisher output".to_string(), + })?; + Ok(()) +} + async fn emit_abandoned_auction( services: &RuntimeServices, observation: Option, @@ -1302,35 +1821,6 @@ async fn collect_stream_auction( } } -fn write_processed_chunk( - writer: &mut W, - processor: &mut P, - chunk: &[u8], - is_last: bool, - process_error: &str, - write_error: &str, -) -> Result<(), Report> { - if chunk.is_empty() && !is_last { - return Ok(()); - } - - let out = - processor - .process_chunk(chunk, is_last) - .change_context(TrustedServerError::Proxy { - message: process_error.to_string(), - })?; - if !out.is_empty() { - writer - .write_all(&out) - .change_context(TrustedServerError::Proxy { - message: write_error.to_string(), - })?; - } - - Ok(()) -} - /// Auction dispatch context passed to [`handle_publisher_request`]. pub struct AuctionDispatch<'a> { /// Orchestrator that dispatches and collects SSP bid requests. @@ -2489,8 +2979,7 @@ pub async fn handle_page_bids( #[cfg(test)] mod tests { - use std::io::{self, Read as _, Write as _}; - use std::sync::atomic::{AtomicUsize, Ordering}; + use std::io::{Read as _, Write as _}; use brotli::enc::writer::CompressorWriter; use brotli::Decompressor; @@ -2508,47 +2997,6 @@ mod tests { use http::{header, Method, Request as HttpRequest, StatusCode}; use std::sync::Arc; - struct ChunkedReader { - chunks: std::collections::VecDeque>, - read_count: Arc, - } - - impl ChunkedReader { - fn new(chunks: &[&[u8]], read_count: Arc) -> Self { - Self { - chunks: chunks.iter().map(|chunk| chunk.to_vec()).collect(), - read_count, - } - } - } - - impl io::Read for ChunkedReader { - fn read(&mut self, buf: &mut [u8]) -> io::Result { - let Some(chunk) = self.chunks.pop_front() else { - return Ok(0); - }; - self.read_count.fetch_add(1, Ordering::SeqCst); - let len = chunk.len().min(buf.len()); - buf[..len].copy_from_slice(&chunk[..len]); - Ok(len) - } - } - - struct RecordingProcessor { - read_count: Arc, - body_close_processed_at: Arc, - } - - impl StreamProcessor for RecordingProcessor { - fn process_chunk(&mut self, chunk: &[u8], _is_last: bool) -> Result, io::Error> { - if find_ascii_case_insensitive(chunk, BODY_CLOSE_PREFIX).is_some() { - self.body_close_processed_at - .store(self.read_count.load(Ordering::SeqCst), Ordering::SeqCst); - } - Ok(chunk.to_vec()) - } - } - fn gzip_encode(input: &[u8]) -> Vec { let mut encoder = GzEncoder::new(Vec::new(), flate2::Compression::default()); encoder @@ -2603,6 +3051,28 @@ mod tests { } } + fn make_html_ssat_params( + settings: &Settings, + dispatched_auction: Option, + ) -> OwnedProcessResponseParams { + OwnedProcessResponseParams { + content_encoding: String::new(), + origin_host: settings.publisher.origin_host(), + origin_url: settings.publisher.origin_url.clone(), + request_host: settings.publisher.domain.clone(), + request_scheme: "https".to_owned(), + content_type: "text/html; charset=utf-8".to_owned(), + ad_slots_script: Some( + r#""#.to_owned(), + ), + ad_bids_state: std::sync::Arc::new(std::sync::Mutex::new(None)), + auction_observation: None, + auction_request: None, + dispatched_auction, + price_granularity: Default::default(), + } + } + fn test_auction_request() -> AuctionRequest { AuctionRequest { id: "test-auction".to_string(), @@ -3109,93 +3579,217 @@ mod tests { } #[tokio::test] - async fn body_close_hold_loop_processes_close_tail_before_reading_post_body_chunks() { + async fn ssat_late_binding_ignores_body_text_inside_script() { let settings = create_test_settings(); let services = noop_services(); let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); + let registry = IntegrationRegistry::empty_for_tests(); let dispatched = DispatchedAuction::empty_for_test(test_auction_request(), 500); - let read_count = Arc::new(AtomicUsize::new(0)); - let body_close_processed_at = Arc::new(AtomicUsize::new(0)); - let reader = ChunkedReader::new( - &[ - b"painted", - b"", - b"", - ], - Arc::clone(&read_count), - ); - let mut processor = RecordingProcessor { - read_count: Arc::clone(&read_count), - body_close_processed_at: Arc::clone(&body_close_processed_at), - }; - let ad_bids_state = Arc::new(Mutex::new(None)); - let ctx = AuctionCollectCtx { - dispatched, - telemetry: AuctionTelemetryCarry { - observation: None, - auction_request: None, - }, - price_granularity: PriceGranularity::default(), - ad_bids_state: &ad_bids_state, - orchestrator: &orchestrator, - services: &services, - settings: &settings, - }; + let mut params = make_html_ssat_params(&settings, Some(dispatched)); let mut output = Vec::new(); + let html = r#"

painted

"#; - body_close_hold_loop(reader, &mut output, &mut processor, ctx) - .await - .expect("should stream body with auction hold"); + stream_publisher_body_async( + EdgeBody::from(html.as_bytes().to_vec()), + &mut output, + &mut params, + &settings, + ®istry, + &orchestrator, + &services, + ) + .await + .expect("should stream HTML with parser-safe late binding"); - assert_eq!( - body_close_processed_at.load(Ordering::SeqCst), - 1, - "close-body tail should be processed as soon as it is found, before later chunks are read" + let rewritten = String::from_utf8(output).expect("should be utf8"); + assert!( + rewritten.contains("const marker = \"\";"), + "script literal should remain script text" + ); + let script_literal_pos = rewritten + .find("const marker") + .expect("should preserve script literal"); + let bids_pos = rewritten + .find(".bids=JSON.parse") + .expect("should inject bids script"); + let real_body_close_pos = rewritten.rfind("").expect("should have body close"); + assert!( + bids_pos > script_literal_pos, + "script literal must not trigger early bid insertion" ); - assert_eq!( - std::str::from_utf8(&output).expect("should be utf8"), - "painted", - "post-body chunks should still stream in order" + assert!( + bids_pos < real_body_close_pos, + "bids must be injected before the real parser-confirmed body close" + ); + assert!( + !rewritten.contains("__TSJS_BIDS_PLACEHOLDER"), + "placeholder must not leak to the client" ); } - #[test] - fn body_close_hold_buffer_holds_close_body_tail_in_single_chunk() { - let mut hold = BodyCloseHoldBuffer::new(); + #[tokio::test] + async fn ssat_late_binding_appends_bids_at_eof_when_body_close_missing() { + let settings = create_test_settings(); + let services = noop_services(); + let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); + let registry = IntegrationRegistry::empty_for_tests(); + let mut params = make_html_ssat_params(&settings, None); + let mut output = Vec::new(); + let html = b"

painted

"; - let ready = hold.push(b"painted"); - let held = hold.finish(); + stream_publisher_body_async( + EdgeBody::from(html.to_vec()), + &mut output, + &mut params, + &settings, + ®istry, + &orchestrator, + &services, + ) + .await + .expect("should append EOF fallback bids"); - assert_eq!( - std::str::from_utf8(&ready).expect("should be utf8"), - "painted", - "content before should stream before auction collection" + let rewritten = String::from_utf8(output).expect("should be utf8"); + assert!( + rewritten.contains(".bids=JSON.parse"), + "missing body close should append bids at EOF" ); - assert_eq!( - std::str::from_utf8(&held).expect("should be utf8"), - "", - "the close-body tag and trailing bytes should be held" + assert!( + !rewritten.contains("__TSJS_BIDS_PLACEHOLDER"), + "placeholder must not leak on EOF fallback" ); } - #[test] - fn body_close_hold_buffer_holds_close_body_tail_across_chunks() { - let mut hold = BodyCloseHoldBuffer::new(); + #[tokio::test] + async fn ssat_late_binding_prepends_bootstrap_when_body_closes_without_head() { + let settings = create_test_settings(); + let services = noop_services(); + let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); + let registry = IntegrationRegistry::empty_for_tests(); + let mut params = make_html_ssat_params(&settings, None); + let mut output = Vec::new(); + let html = b"

painted

"; - let first = hold.push(b"painted"); - let held = hold.finish(); + stream_publisher_body_async( + EdgeBody::from(html.to_vec()), + &mut output, + &mut params, + &settings, + ®istry, + &orchestrator, + &services, + ) + .await + .expect("should prepend missing-head bootstrap before body-close bids"); + + let rewritten = String::from_utf8(output).expect("should be utf8"); + let slots_pos = rewritten + .find(".adSlots=") + .expect("should inject ad slot state"); + let bundle_pos = rewritten + .find("/static/tsjs=") + .expect("should inject tsjs script tag"); + let bids_pos = rewritten + .find(".bids=JSON.parse") + .expect("should inject bids script"); + let body_close_pos = rewritten.rfind("").expect("should have body close"); + assert!( + slots_pos < bundle_pos && bundle_pos < bids_pos && bids_pos < body_close_pos, + "missing-head body-close replacement should preserve executable order before " + ); + assert!( + !rewritten.contains("__TSJS_BIDS_PLACEHOLDER"), + "placeholder must not leak on missing-head body-close replacement" + ); + } - let streamed = [first, second].concat(); - assert_eq!( - std::str::from_utf8(&streamed).expect("should be utf8"), - "painted", - "split bytes must not leak before auction collection" + #[tokio::test] + async fn ssat_late_binding_appends_minimal_tail_when_head_and_body_close_missing() { + let settings = create_test_settings(); + let services = noop_services(); + let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); + let registry = IntegrationRegistry::empty_for_tests(); + let mut params = make_html_ssat_params(&settings, None); + let mut output = Vec::new(); + let html = b"
fragment without document scaffolding
"; + + stream_publisher_body_async( + EdgeBody::from(html.to_vec()), + &mut output, + &mut params, + &settings, + ®istry, + &orchestrator, + &services, + ) + .await + .expect("should append missing-head EOF fallback tail"); + + let rewritten = String::from_utf8(output).expect("should be utf8"); + let slots_pos = rewritten + .find(".adSlots=") + .expect("should append ad slot state"); + let bundle_pos = rewritten + .find("/static/tsjs=") + .expect("should append tsjs script tag"); + let bids_pos = rewritten + .find(".bids=JSON.parse") + .expect("should append bids script"); + assert!( + slots_pos < bundle_pos && bundle_pos < bids_pos, + "fallback tail should preserve executable order" ); - assert_eq!( - std::str::from_utf8(&held).expect("should be utf8"), - "", - "split close-body tag should be held intact" + assert!( + !rewritten.contains("__TSJS_BIDS_PLACEHOLDER"), + "placeholder must not leak on missing-head fallback" + ); + } + + #[tokio::test] + async fn buffered_ssat_postprocessor_path_rejects_processed_output_over_cap() { + let mut settings = create_test_settings(); + settings.publisher.max_buffered_body_bytes = 96; + settings + .integrations + .insert_config( + "nextjs", + &serde_json::json!({ + "enabled": true, + "rewrite_attributes": ["href"], + }), + ) + .expect("should update nextjs config"); + let services = noop_services(); + let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); + let registry = IntegrationRegistry::new(&settings).expect("should create registry"); + assert!( + registry.has_html_post_processors(), + "nextjs should force the buffered post-processor path" + ); + let mut params = make_html_ssat_params(&settings, None); + let mut output = Vec::new(); + let html = b"x"; + assert!( + html.len() < settings.publisher.max_buffered_body_bytes, + "decoded input should fit so the processed-output cap is exercised" + ); + + let err = stream_publisher_body_async( + EdgeBody::from(html.to_vec()), + &mut output, + &mut params, + &settings, + ®istry, + &orchestrator, + &services, + ) + .await + .expect_err("should reject processed output over cap"); + + let err = format!("{err:?}"); + assert!( + err.contains("processed output exceeded"), + "error should come from the processed-output cap: {err}" ); } diff --git a/crates/trusted-server-core/src/publisher_late_binding.rs b/crates/trusted-server-core/src/publisher_late_binding.rs new file mode 100644 index 0000000000..506279ab0a --- /dev/null +++ b/crates/trusted-server-core/src/publisher_late_binding.rs @@ -0,0 +1,317 @@ +//! Parser-safe SSAT bid placeholder late binding. +//! +//! The publisher HTML rewriter inserts an opaque placeholder only from a +//! parser-confirmed `` handler. This module scans the rewritten, +//! uncompressed HTML stream for that placeholder and lets the caller replace it +//! with the final bids script without scanning raw origin bytes for HTML syntax. + +use std::sync::atomic::{AtomicBool, Ordering}; + +use error_stack::Report; +use uuid::Uuid; + +use crate::error::TrustedServerError; + +/// Maximum processed-output suffix retained after the bid placeholder is found. +pub(crate) const SSAT_HELD_TAIL_CAP_BYTES: usize = 64 * 1024; + +/// Per-request opaque marker inserted before a parser-confirmed ``. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct BidPlaceholder { + html: String, +} + +impl BidPlaceholder { + /// Generate a new high-entropy HTML comment placeholder for one response. + #[must_use] + pub(crate) fn new() -> Self { + let id = Uuid::new_v4(); + Self { + html: format!(""), + } + } + + /// Return the placeholder as HTML. + #[must_use] + pub(crate) fn as_html(&self) -> &str { + &self.html + } + + /// Return the placeholder bytes used by the streaming scanner. + #[must_use] + pub(crate) fn as_bytes(&self) -> &[u8] { + self.html.as_bytes() + } +} + +/// Tracks which parser insertions actually happened for EOF fallback decisions. +#[derive(Debug, Default)] +pub struct HtmlInjectionTracker { + head_injected: AtomicBool, + bid_placeholder_inserted: AtomicBool, +} + +impl HtmlInjectionTracker { + /// Mark that the normal `` bootstrap insertion ran. + pub(crate) fn mark_head_injected(&self) { + self.head_injected.store(true, Ordering::SeqCst); + } + + /// Mark that the body end-tag placeholder insertion ran. + pub(crate) fn mark_bid_placeholder_inserted(&self) { + self.bid_placeholder_inserted.store(true, Ordering::SeqCst); + } + + /// Return whether the normal `` bootstrap insertion ran. + #[must_use] + pub(crate) fn head_injected(&self) -> bool { + self.head_injected.load(Ordering::SeqCst) + } + + /// Return whether the body end-tag placeholder insertion ran. + #[must_use] + pub(crate) fn bid_placeholder_inserted(&self) -> bool { + self.bid_placeholder_inserted.load(Ordering::SeqCst) + } +} + +/// Result of pushing one processed-output chunk through the placeholder scanner. +#[derive(Debug)] +pub(crate) enum PlaceholderScan { + /// No placeholder was found; emit these bytes immediately. + Emit(Vec), + /// The first placeholder was found. Emit `before`, collect/resolve bids, + /// then emit the replacement followed by `after`. + Found { before: Vec, after: Vec }, +} + +/// Streaming scanner for the bid placeholder. +pub(crate) struct PlaceholderLateBinder { + placeholder: Vec, + buffered: Vec, + replaced: bool, + held_tail_cap: usize, +} + +impl PlaceholderLateBinder { + /// Create a scanner for `placeholder` with a bounded post-placeholder hold. + #[must_use] + pub(crate) fn new(placeholder: &BidPlaceholder, held_tail_cap: usize) -> Self { + Self { + placeholder: placeholder.as_bytes().to_vec(), + buffered: Vec::new(), + replaced: false, + held_tail_cap, + } + } + + /// Return true after the first placeholder has been found. + #[must_use] + pub(crate) fn replaced(&self) -> bool { + self.replaced + } + + /// Push processed uncompressed output through the placeholder scanner. + /// + /// # Errors + /// + /// Returns a proxy error if the suffix held after the first placeholder + /// exceeds the configured held-tail cap. + pub(crate) fn push( + &mut self, + chunk: &[u8], + ) -> Result> { + if self.replaced { + let emit = self.push_after_replacement(chunk); + return Ok(PlaceholderScan::Emit(emit)); + } + + self.buffered.extend_from_slice(chunk); + if let Some(position) = find_bytes(&self.buffered, &self.placeholder) { + self.replaced = true; + let before = self.buffered.drain(..position).collect::>(); + self.buffered.drain(..self.placeholder.len()); + let suffix = std::mem::take(&mut self.buffered); + if suffix.len() > self.held_tail_cap { + return Err(Report::new(TrustedServerError::Proxy { + message: format!( + "SSAT placeholder held tail {} bytes exceeds {}-byte limit", + suffix.len(), + self.held_tail_cap + ), + })); + } + let after = self.push_after_replacement(&suffix); + return Ok(PlaceholderScan::Found { before, after }); + } + + Ok(PlaceholderScan::Emit(self.drain_safe_prefix())) + } + + /// Finish scanning and return all remaining safe bytes. + #[must_use] + pub(crate) fn finish(mut self) -> Vec { + if self.replaced { + remove_all_placeholders(&mut self.buffered, &self.placeholder); + } + self.buffered + } + + fn push_after_replacement(&mut self, chunk: &[u8]) -> Vec { + self.buffered.extend_from_slice(chunk); + remove_all_placeholders(&mut self.buffered, &self.placeholder); + self.drain_safe_prefix() + } + + fn drain_safe_prefix(&mut self) -> Vec { + let keep_len = partial_placeholder_prefix_len(&self.buffered, &self.placeholder); + if self.buffered.len() <= keep_len { + return Vec::new(); + } + + let split_at = self.buffered.len() - keep_len; + self.buffered.drain(..split_at).collect() + } +} + +fn partial_placeholder_prefix_len(buffered: &[u8], placeholder: &[u8]) -> usize { + let max_len = buffered.len().min(placeholder.len().saturating_sub(1)); + (1..=max_len) + .rev() + .find(|&len| buffered.ends_with(&placeholder[..len])) + .unwrap_or(0) +} + +fn remove_all_placeholders(buffered: &mut Vec, placeholder: &[u8]) { + while let Some(position) = find_bytes(buffered, placeholder) { + buffered.drain(position..position + placeholder.len()); + } +} + +fn find_bytes(haystack: &[u8], needle: &[u8]) -> Option { + if needle.is_empty() { + return Some(0); + } + haystack + .windows(needle.len()) + .position(|window| window == needle) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bid_placeholder_is_unique_html_comment() { + let first = BidPlaceholder::new(); + let second = BidPlaceholder::new(); + + assert_ne!(first, second, "per-request placeholders should be unique"); + assert!( + first.as_html().starts_with(""), + "placeholder should close the HTML comment" + ); + } + + #[test] + fn late_binder_detects_placeholder_split_across_chunks() { + let placeholder = BidPlaceholder { + html: "".to_string(), + }; + let marker = placeholder.as_html(); + let split = marker.len() / 2; + let mut binder = PlaceholderLateBinder::new(&placeholder, SSAT_HELD_TAIL_CAP_BYTES); + + let first = binder + .push(format!("before{}", &marker[..split]).as_bytes()) + .expect("should scan first chunk"); + let second = binder + .push(format!("{}after", &marker[split..]).as_bytes()) + .expect("should scan second chunk"); + + match first { + PlaceholderScan::Emit(bytes) => assert_eq!( + String::from_utf8(bytes).expect("should be utf8"), + "before", + "safe prefix before the split placeholder should stream" + ), + PlaceholderScan::Found { .. } => panic!("should not find split placeholder yet"), + } + let mut output = Vec::new(); + match second { + PlaceholderScan::Found { before, after } => { + assert!(before.is_empty(), "prefix was already emitted"); + output.extend(after); + } + PlaceholderScan::Emit(_) => panic!("should find placeholder in second chunk"), + } + output.extend(binder.finish()); + assert_eq!( + String::from_utf8(output).expect("should be utf8"), + "after", + "suffix after placeholder should be returned by the scanner" + ); + } + + #[test] + fn late_binder_strips_later_placeholder_occurrences() { + let placeholder = BidPlaceholder { + html: "".to_string(), + }; + let mut binder = PlaceholderLateBinder::new(&placeholder, SSAT_HELD_TAIL_CAP_BYTES); + let first = format!("a{}b", placeholder.as_html()); + let second = format!("c{}d", placeholder.as_html()); + + let first = binder + .push(first.as_bytes()) + .expect("should scan first placeholder"); + let second = binder + .push(second.as_bytes()) + .expect("should scan duplicate placeholder"); + let final_bytes = binder.finish(); + + let mut output = Vec::new(); + match first { + PlaceholderScan::Found { before, after } => { + output.extend(before); + output.extend(b"REPLACED"); + output.extend(after); + } + PlaceholderScan::Emit(_) => panic!("should find first placeholder"), + } + match second { + PlaceholderScan::Emit(bytes) => output.extend(bytes), + PlaceholderScan::Found { .. } => panic!("should not find second placeholder"), + } + output.extend(final_bytes); + + let output = String::from_utf8(output).expect("should be utf8"); + assert_eq!(output, "aREPLACEDbcd"); + assert!( + !output.contains(placeholder.as_html()), + "placeholder should not leak after replacement" + ); + } + + #[test] + fn late_binder_rejects_large_held_tail() { + let placeholder = BidPlaceholder { + html: "".to_string(), + }; + let mut binder = PlaceholderLateBinder::new(&placeholder, 4); + let input = format!("{}12345", placeholder.as_html()); + + let err = binder + .push(input.as_bytes()) + .expect_err("held tail should exceed cap"); + + assert!( + format!("{err:?}").contains("held tail"), + "error should mention held tail cap" + ); + } +} diff --git a/docs/superpowers/plans/2026-07-08-ssat-publisher-streaming-parser-safe-implementation-plan.md b/docs/superpowers/plans/2026-07-08-ssat-publisher-streaming-parser-safe-implementation-plan.md new file mode 100644 index 0000000000..2e0fda7fcd --- /dev/null +++ b/docs/superpowers/plans/2026-07-08-ssat-publisher-streaming-parser-safe-implementation-plan.md @@ -0,0 +1,740 @@ +# SSAT publisher streaming parser-safe implementation plan + +**Source spec:** `docs/superpowers/specs/2026-07-08-ssat-publisher-streaming-parser-safe-design.md` +**Issue:** #857 +**Status:** Draft implementation plan +**Area:** Trusted Server runtime / publisher fallback / server-side ad stack + +## Objective + +Implement the spec in small, reviewable slices so Fastly publisher SSAT HTML can +stream origin bytes through decode/rewrite/bid late-binding/re-encode to the +client without full origin-body or assembled-body materialization, while keeping +all non-Fastly adapters and full-document post-processors explicitly buffered for +this slice. + +The plan preserves the project constraints in `CLAUDE.md`: minimal unrelated +refactoring, no bare workspace `cargo test`, `error-stack` errors, `log` macros, +no Tokio or OS-only dependencies in core runtime code, and target-aware validation. + +## Current code findings + +- `crates/trusted-server-core/src/publisher.rs` + - `handle_publisher_request` always sends publisher origin requests with + `PlatformHttpRequest::new(req, backend_name)`, so Fastly uses the default + buffered platform response path. + - `body_as_reader()` calls `EdgeBody::into_bytes()`, which is incompatible with + true streaming and would materialize `EdgeBody::Stream(_)`. + - `stream_publisher_body_async` uses `BodyCloseHoldBuffer`, which raw-scans + decoded origin bytes for `` containing `''` does not trigger bid collection; + - script/JSON data containing literal or escaped `` split across origin chunks still injects before the close tag; + - missing `` appends the fallback tail at EOF; + - missing `` plus missing `` appends the minimal executable tail; + - multiple body close tags do not inject multiple bid scripts. +- Routing seams: + - Fastly request-level SSAT streaming candidates set `stream_response = true`; + - registries with HTML post-processors do not set `stream_response = true`; + - non-Fastly adapters keep publisher `stream_response = false`. +- Header seams: + - processed publisher HTML strips stale payload headers; + - pass-through/unmodified streams preserve origin validators and range metadata. + +## Phase 1: Core parser-safe late binding + +### 1.1 Add a late-binding module + +Create `crates/trusted-server-core/src/publisher_late_binding.rs` and export it +from `crates/trusted-server-core/src/lib.rs` or keep it `pub(crate)` from +`publisher.rs`. + +Suggested types: + +```rust +pub(crate) const SSAT_HELD_TAIL_CAP_BYTES: usize = 64 * 1024; + +pub(crate) struct BidPlaceholder { + token: String, + html: String, +} + +pub(crate) struct HtmlInjectionTracker { + head_injected: AtomicBool, + placeholder_inserted: AtomicBool, +} + +pub(crate) enum LateBindingOutcome { + BodyClose, + EofFallback, + MissingHeadEofFallback, +} +``` + +Responsibilities: + +- Generate a high-entropy per-request placeholder with `uuid::Uuid::new_v4()`. +- Render the placeholder as a valid HTML comment. +- Expose byte accessors for streaming scans. +- Track whether `` bootstrap and body-close placeholder insertion occurred. +- Define small helper errors as `TrustedServerError::Proxy` contexts rather than + adding broad new error enums unless needed. + +Acceptance tests: + +- placeholders are unique across requests; +- placeholder bytes are valid UTF-8 and valid comment-shaped HTML; +- `LateBindingScanner` can find a placeholder split across arbitrary chunks; +- later occurrences are stripped after first replacement. + +### 1.2 Extend HTML processor configuration + +Modify `crates/trusted-server-core/src/html_processor.rs`. + +Suggested API: + +```rust +pub enum BidInjectionMode { + DirectState, + Placeholder { html: String, tracker: Arc }, +} + +pub enum HtmlPostProcessingMode { + Enabled, + Disabled, +} +``` + +Add fields to `HtmlProcessorConfig`: + +- `bid_injection_mode: BidInjectionMode` defaulting to `DirectState`; +- `post_processing_mode: HtmlPostProcessingMode` defaulting to `Enabled`; +- optional shared `HtmlInjectionTracker`. + +Add builder methods: + +- `with_ad_state(...)` remains for existing call sites; +- `with_bid_placeholder(placeholder, tracker)` for SSAT late binding; +- `without_post_processing()` for the buffered late-binding-before-postprocess + flow and Fastly true streaming guard. + +Refactor the existing head injection code into a reusable helper, for example: + +```rust +pub(crate) fn build_head_bootstrap_snippet( + integrations: &IntegrationRegistry, + ctx: &IntegrationHtmlContext<'_>, + ad_slots_script: Option<&str>, +) -> String +``` + +The helper must preserve current executable ordering: + +1. ad slot state; +2. integration head config inserts; +3. main TSJS bundle; +4. deferred TSJS bundle tags. + +Body end-tag behavior: + +- If no slots matched, keep skipping bid injection entirely. +- In `DirectState`, preserve current behavior for compatibility. +- In `Placeholder`, insert the placeholder in the first parser-confirmed body + end tag only, using the existing once-only guard pattern. +- Set `tracker.placeholder_inserted = true` only when the placeholder is inserted. +- Set `tracker.head_injected = true` when the `` injection actually runs. + +Acceptance tests: + +- `` inside scripts/comments/attributes does not insert the placeholder; +- a real body close inserts one placeholder before the close tag; +- multiple body tags or end tags still produce at most one placeholder; +- direct-state mode remains compatible with existing tests. + +### 1.3 Build the placeholder late binder + +Implement a streaming scanner over processed uncompressed output. + +Suggested shape: + +```rust +pub(crate) struct PlaceholderLateBinder { + placeholder: BidPlaceholder, + overlap: Vec, + replaced: bool, + held_tail_cap: usize, +} + +pub(crate) enum BinderEvent { + Emit(Vec), + PlaceholderFound { prefix: Vec, suffix: Vec }, +} +``` + +The scanner must: + +- keep at most `placeholder.len() - 1` overlap before the placeholder is found; +- emit all safe prefix bytes immediately; +- on first placeholder, return the prefix and suffix around the placeholder; +- hold only the suffix from the current processed chunk while the auction is + collected; +- enforce `min(SSAT_HELD_TAIL_CAP_BYTES, settings.publisher.max_buffered_body_bytes)`; +- after first replacement, strip any subsequent placeholder bytes defensively; +- never forward the placeholder to the client or to post-processors. + +Acceptance tests: + +- placeholder split across chunks is detected; +- bytes before the placeholder are emitted as soon as safe; +- replacement happens exactly once; +- duplicate placeholder occurrences are stripped; +- held-tail cap violation maps to a proxy error. + +### 1.4 Replace `BodyCloseHoldBuffer` + +Modify `crates/trusted-server-core/src/publisher.rs`. + +Replace raw `BodyCloseHoldBuffer` / `find_ascii_case_insensitive` usage with the +placeholder late-binding flow: + +```text +decoded origin bytes + -> HTML processor configured with placeholder mode and no post-processors + -> processed uncompressed output + -> PlaceholderLateBinder + -> auction collect / empty bid script / EOF fallback + -> output cap counter + -> optional recompression + -> writer +``` + +Important behavior: + +- If `params.dispatched_auction` exists, collect it when the first placeholder is + found; otherwise collect at EOF fallback. +- If no auction was dispatched but slots exist, replace the placeholder + immediately with the current or empty bid script. +- Existing `collect_stream_auction`, `write_bids_to_state`, and + `build_bids_script` paths should be reused so output shape and debug comments + remain stable. +- Auction telemetry must complete or abandon on every exit path. +- Delete or deprecate `BodyCloseHoldBuffer` tests once equivalent placeholder + tests cover the behavior. + +### 1.5 Implement EOF fallback tail + +On EOF, after finalizing `lol_html`: + +1. Feed final processor output through the binder. +2. If a placeholder appears in final output, replace it normally. +3. If no placeholder was ever found: + - collect any dispatched auction; + - if `tracker.head_injected` is true, append only the bids script; + - if `tracker.head_injected` is false, append the minimal bootstrap tail in + executable order using the extracted head-snippet helper plus the bids + script. + +The fallback tail is best-effort malformed-document handling; it must still: + +- count against the processed-output cap; +- never expose placeholders; +- preserve current privacy behavior for SSAT HTML. + +## Phase 2: Streaming pipeline, compression, and caps + +### 2.1 Add a non-materializing publisher body pump + +The current synchronous `StreamingPipeline::process` is safe for buffered +`EdgeBody::Once` but not enough for Fastly true streaming because `body_as_reader` +uses `into_bytes()`. + +Add a publisher-specific async body pump in `publisher.rs` or a new core module +such as `publisher_body_stream.rs`. + +Requirements: + +- Consume `EdgeBody::Once` by chunking the bytes without copying the full payload + again. +- Consume `EdgeBody::Stream(_)` with `StreamExt::next().await`. +- Never call `EdgeBody::into_bytes()`, `into_bytes_bounded()`, or equivalent on + the true streaming SSAT path. +- Convert origin stream errors into `TrustedServerError::Proxy` with context. +- Preserve upstream backpressure by pausing reads while auction collection runs. + +Compression implementation options: + +- Prefer explicit incremental decoder/encoder state machines driven by origin + chunks: + - `flate2::Decompress` / `flate2::Compress` for gzip and deflate if practical; + - brotli streaming reader/writer APIs with small bounded buffers. +- If reusing `std::io::Read`-based decoders with a custom stream reader, document + why it does not materialize and test that it consumes one upstream chunk at a + time. Avoid introducing Tokio/OS-only async dependencies into core. + +### 2.2 Preserve compression finalization + +For processed streaming HTML with supported encodings: + +- identity: write processed bytes directly; +- gzip: finalize with `finish()` and propagate errors; +- deflate: finalize with `finish()` and propagate errors; +- brotli: explicitly flush/finalize through the available brotli writer API and + propagate write errors where available. + +The placeholder scanner must run after HTML rewriting and before recompression. + +Acceptance tests: + +- gzip, deflate, and brotli origin HTML decode/rewrite/re-encode successfully; +- decoded client output has bids before the real parser-confirmed ``; +- final compressed response can be decoded by a client; +- finalization errors are propagated or logged/dropped after commit, depending + on where they occur. + +### 2.3 Enforce streaming caps + +Use `settings.publisher.max_buffered_body_bytes` as the effective cumulative +limit for this slice. + +Counters: + +- `decoded_input_bytes`: increment after decompression and before `lol_html`; +- `processed_output_bytes`: increment after late binding and before + recompression; +- `held_tail_bytes`: enforce via `PlaceholderLateBinder`. + +Pre-commit rejection: + +- For identity responses, if `Content-Length` exceeds the limit, reject before + `stream_to_client()`. +- Do not use compressed `Content-Length` as proof of decoded size. + +Mid-stream violation: + +- Return a proxy error from the streaming loop. +- Fastly send path logs and drops the `StreamingBody` without `finish()` because + headers are already committed. +- Buffered paths return a normal error response as they do today. + +Acceptance tests: + +- large identity HTML below cap succeeds; +- decoded input over cap fails/aborts; +- processed output over cap fails/aborts; +- gzip/deflate/br expansion over cap is caught after decode; +- held-tail cap is enforced. + +### 2.4 Normalize transformed response headers + +Add a helper in `publisher.rs`, for example: + +```rust +pub(crate) fn strip_transformed_payload_headers(headers: &mut HeaderMap) +``` + +Remove at minimum: + +- `Content-Length` +- `Content-MD5` +- `Digest` +- `Repr-Digest` +- `Content-Range` +- `Accept-Ranges` +- `ETag` + +Apply this helper to every processed publisher HTML response, buffered or +streaming. Do not apply it to unmodified pass-through streams. + +Keep `Content-Encoding` when the body is re-encoded with the same encoding. +`Transfer-Encoding` remains adapter-owned. + +## Phase 3: Fastly request/response streaming path + +### 3.1 Add request-level streaming candidate options + +Modify `handle_publisher_request` without breaking existing adapters. + +Suggested API: + +```rust +pub struct PublisherRequestOptions { + pub allow_origin_streaming: bool, +} + +pub async fn handle_publisher_request_with_options(..., options: PublisherRequestOptions) +``` + +Keep the current `handle_publisher_request(...)` as a wrapper with +`allow_origin_streaming = false` so Axum/Cloudflare/Spin remain buffered by +default. + +Request-level Fastly candidate gate: + +- method is `GET` and not `HEAD`; +- request is a navigation; +- server-side ad stack may run for the request; +- no full-document HTML post-processors are registered; +- Fastly caller can preserve publisher streaming state out-of-band; +- origin fetch uses `send`, not `send_async`. + +When all gates pass, build the origin request with: + +```rust +PlatformHttpRequest::new(req, backend_name).with_stream_response() +``` + +Tests should assert `StubHttpClient::recorded_stream_response_flags()` for +eligible and ineligible requests. If needed, extend `StubHttpClient` so a queued +response can return `EdgeBody::Stream(_)` when `stream_response` is true. + +### 3.2 Refine response-level route decisions + +The existing `classify_response_route` treats processable non-HTML content as +`Stream`. For this issue, true Fastly SSAT streaming is only for SSAT HTML. + +Add either a new classifier or extend route context: + +```rust +pub(crate) enum PublisherSendRoute { + ProcessedHtmlStream, + StreamUnmodified, + BufferedProcessed, + BufferedUnmodified, + PassThrough, + Bodiless, +} +``` + +Response-level stream eligibility for Fastly: + +- request/response can carry a body (`GET`, not `HEAD`; not `204`, `205`, `304`); +- response is HTML and SSAT assembly is needed; +- content encoding is identity/gzip/deflate/br; +- no post-processors; +- identity `Content-Length` preflight passes; +- processor construction succeeds before client commit. + +If a streaming candidate resolves to non-HTML or unsupported encoding: + +- stream the origin body unmodified when safe; +- abandon any dispatched auction with telemetry; +- preserve bodyless semantics for `HEAD`, `204`, `205`, and `304`. + +If the response needs buffered mode and that requirement was known before fetch +(post-processors), it should never have requested `with_stream_response()`. + +### 3.3 Preserve publisher-streaming state across the Fastly boundary + +Do not put the SSAT streaming state into `http::Extensions`: `DispatchedAuction` +can carry adapter-specific pending request handles and should be treated as +`!Send` / not extension-safe. + +Add a Fastly-specific out-of-band outcome, likely in a new file +`crates/trusted-server-adapter-fastly/src/publisher_streaming.rs`: + +```rust +pub(crate) enum FastlyPublisherFallbackOutcome { + Response(HttpResponse), + Stream(FastlyPublisherStream), +} + +pub(crate) struct FastlyPublisherStream { + response: HttpResponse, + body: EdgeBody, + params: Box, + method: Method, + ec_state: Option, + request_filter_effects: Option, + services: RuntimeServices, + settings: Arc, + registry: IntegrationRegistry, + orchestrator: Arc, +} +``` + +The exact ownership can vary, but the stream outcome must carry everything the +send path needs after headers are finalized: + +- response skeleton; +- origin stream; +- `OwnedProcessResponseParams`; +- method/status for no-body checks; +- settings and registry; +- orchestrator and runtime services; +- EC/request-filter/finalization state. + +### 3.4 Split Fastly publisher fallback dispatch from generic EdgeZero response dispatch + +The existing router returns only `HttpResponse`, which forces buffering. Add a +Fastly-specific publisher fallback dispatcher that can return +`FastlyPublisherFallbackOutcome` before generic `send_edgezero_response` loses +state. + +Recommended approach: + +1. Extract shared publisher fallback setup from `dispatch_fallback` into helper + functions where practical, without refactoring unrelated named routes. +2. In `edgezero_main`, after request conversion and app state construction, + detect the fallback publisher route that is eligible for the Fastly SSAT path. +3. For that path, call the new Fastly publisher dispatcher directly. +4. For all other paths, continue using `app.router().oneshot(core_req)` and + `send_edgezero_response` unchanged. + +The Fastly publisher dispatcher must preserve middleware ordering: + +1. forwarded-header sanitization already happened on the original Fastly request; +2. EC request-state setup; +3. pre-route request filters; +4. asset/named/integration routes must still bypass this path; +5. `handle_publisher_request_with_options(... allow_origin_streaming = true ...)`; +6. buffered fallback for non-stream outcomes; +7. attach or carry EC finalize state and request-filter effects; +8. entry-point finalize headers; +9. EC finalization; +10. request-filter response effects; +11. final set-cookie cache privacy guard. + +If this direct path would duplicate too much router logic, an acceptable +alternative is to make `execute_fallback` return an adapter-private enum and keep +the router for all ordinary responses. The key invariant is that the publisher +stream state reaches Fastly `main.rs` without becoming a plain `EdgeBody::Stream`. + +### 3.5 Add Fastly publisher stream send path + +In `crates/trusted-server-adapter-fastly/src/main.rs`: + +- Add `send_fastly_publisher_stream(FastlyPublisherStream)`. +- Apply request-filter effects to headers before commit. +- Reapply final set-cookie privacy guard before commit. +- Strip transformed payload headers before commit. +- Convert response headers to a Fastly skeleton. +- Call `stream_to_client()`. +- Drive `stream_publisher_body_async(...)` into the Fastly `StreamingBody`. +- Call `finish()` only on success. +- On mid-stream error, log and drop the `StreamingBody` without buffered fallback. +- Run pull-sync-after-send behavior equivalent to the existing EC path once the + response has been sent or attempted. + +Acceptance tests: + +- Fastly SSAT HTML takes the publisher stream path, not asset `stream_asset_body`; +- headers are finalized before streaming starts; +- `Content-Length` and stale validators are absent for processed streaming HTML; +- non-HTML/unsupported-encoding candidate responses stream unmodified and + abandon auction; +- `HEAD`, `204`, `205`, and `304` do not attach processed streaming bodies. + +## Phase 4: Buffered-mode guards, documentation, and adapter parity + +### 4.1 Route post-processors to buffered mode + +Use `IntegrationRegistry::has_html_post_processors()` in the request-level gate +before origin fetch. If true: + +- do not call `with_stream_response()`; +- keep using `buffer_publisher_response_async`; +- run parser-safe late binding before post-processors; +- ensure no placeholder is visible to post-processors or clients. + +Implementation detail: + +- Configure `lol_html` with placeholder mode and post-processing disabled. +- Run late binding into the bounded buffer. +- Then run registered post-processors over the full final HTML. + +This may require extracting post-processing from `HtmlWithPostProcessing` into a +reusable helper, while keeping existing public behavior unchanged for other +callers. + +### 4.2 Keep non-Fastly adapters explicitly buffered + +Update comments and tests in: + +- `crates/trusted-server-adapter-axum/src/app.rs` +- `crates/trusted-server-adapter-cloudflare/src/app.rs` +- `crates/trusted-server-adapter-spin/src/app.rs` + +Expected behavior: + +- they call the default non-streaming publisher handler; +- they call `buffer_publisher_response_async`; +- they benefit from parser-safe late binding and EOF fallback; +- they enforce the existing buffered cap; +- they are not described as true streaming. + +### 4.3 Preserve privacy/cache behavior + +Regression tests should prove SSAT HTML still gets: + +```http +Cache-Control: private, max-age=0 +``` + +and strips shared/runtime edge-cache headers: + +- `Surrogate-Control` +- `Fastly-Surrogate-Control` +- `CDN-Cache-Control` +- `Cloudflare-CDN-Cache-Control` + +The final set-cookie cache privacy guard in Fastly must still run after EC +finalization and request-filter effects. + +### 4.4 Update docs + +Candidate docs to update after implementation behavior exists: + +- Fastly runtime / architecture docs: Fastly SSAT publisher HTML is true + streaming only on the guarded path. +- Adapter docs: Axum, Cloudflare, and Spin publisher SSAT are buffered for this + slice. +- Next.js integration docs: full-document post-processing remains buffered. + +Run docs formatting if docs are edited: + +```bash +cd docs && npm run format +``` + +## Phase 5: Verification plan + +Run focused validation after each phase, then the full target-matched gate before +handoff. + +Minimum Rust verification: + +```bash +cargo fmt --all -- --check +cargo test-fastly +cargo test-axum +cargo test-cloudflare +cargo test-spin +cargo clippy-fastly +cargo clippy-axum +cargo clippy-cloudflare +cargo clippy-cloudflare-wasm +cargo clippy-spin-native +cargo clippy-spin-wasm +``` + +If JS/TS is touched unexpectedly: + +```bash +cd crates/trusted-server-js/lib && npx vitest run +cd crates/trusted-server-js/lib && npm run format +``` + +If docs are touched: + +```bash +cd docs && npm run format +``` + +Do **not** use bare `cargo test --workspace`; it compiles Fastly for the wrong +target in this workspace. + +## Acceptance criteria checklist + +- [ ] Fastly request-level SSAT candidates request origin streaming with + `with_stream_response()`. +- [ ] Fastly processed SSAT HTML writes to `fastly::StreamingBody` without first + materializing the origin body or final assembled body. +- [ ] Publisher SSAT streaming state is not mistaken for asset pass-through + `EdgeBody::Stream(_)`. +- [ ] ``. +- [ ] Missing `` appends bids or minimal SSAT fallback tail at EOF. +- [ ] gzip, deflate, and brotli stream through decode/rewrite/re-encode. +- [ ] Decoded-input, processed-output, and held-tail caps are enforced without a + full-document allocation. +- [ ] Full-document post-processors route to buffered mode and never see a raw + placeholder. +- [ ] Axum, Cloudflare, and Spin remain documented/tested buffered mode. +- [ ] SSAT HTML retains `Cache-Control: private, max-age=0` and no shared edge + cache headers. +- [ ] Processed HTML strips stale payload validators/range metadata; unmodified + pass-through preserves them. +- [ ] Every dispatched auction is collected or abandoned on all exit paths. + +## Risks and open questions + +- **Fastly fallback parity risk:** a direct Fastly publisher streaming dispatcher + can accidentally bypass auth, request filters, EC finalization, pull sync, or + cache privacy guards. Keep the direct path narrow and share helpers with the + existing fallback path where possible. +- **Streaming decoder complexity:** current compression helpers are `Read`/`Write` + based. The true stream path needs chunk-driven processing without + `into_bytes()`. Prefer a small publisher-specific pump and extensive tests over + broad refactors to `StreamingPipeline`. +- **Post-processor ordering:** Next.js/full-document post-processing must see + final HTML with bids, not placeholders. This likely requires extracting + post-processing into an explicit buffered step. +- **Mid-stream failures:** once Fastly commits headers, cap/decode/processor/write + errors can only truncate the response. Tests and logs should reflect that; do + not attempt buffered fallback after commit. +- **Type ownership:** avoid storing `DispatchedAuction` or other adapter-specific + pending handles in `http::Extensions`; use an adapter-private outcome that can + carry non-`Send` state directly. +- **Scope control:** do not make Next.js post-processing streaming-safe and do not + add true streaming for Axum/Cloudflare/Spin in this issue. diff --git a/docs/superpowers/specs/2026-07-08-ssat-publisher-streaming-parser-safe-design.md b/docs/superpowers/specs/2026-07-08-ssat-publisher-streaming-parser-safe-design.md new file mode 100644 index 0000000000..0744909f5a --- /dev/null +++ b/docs/superpowers/specs/2026-07-08-ssat-publisher-streaming-parser-safe-design.md @@ -0,0 +1,823 @@ +# Make SSAT publisher HTML assembly truly streaming and parser-safe + +**Issue:** #857 +**Status:** Draft +**Area:** Trusted Server runtime / publisher fallback / server-side ad stack + +## Summary + +Trusted Server's server-side ad stack (SSAT) publisher HTML path should be a +true streaming assembly path on Fastly: origin HTML bytes should flow through +Trusted Server and reach the browser before the origin response is fully read, +while bid injection remains parser-safe and privacy-preserving. + +Today the code has a streaming-shaped publisher API, but the Fastly origin +response can still be materialized into a single WASM-heap allocation before +client bytes flow, and the EdgeZero publisher fallback buffers the assembled +publisher response before sending it. The current SSAT bid hold also scans raw +origin bytes for `` when one is + present. +- If no parser-confirmed body close exists, append the SSAT fallback tail at EOF + as a best-effort fallback. +- Streaming mode supports gzip, deflate, and brotli origin HTML by decoding, + rewriting, and re-encoding incrementally. +- Streaming mode enforces cumulative decoded-input and processed-output caps + without full-document allocation. +- Full-document HTML post-processors, including the current Next.js + post-processor, are explicitly routed through buffered mode for this slice. +- Axum, Cloudflare, and Spin publisher SSAT behavior is documented and tested as + buffered mode for this slice. +- Existing SSAT privacy behavior remains unchanged: + `Cache-Control: private, max-age=0` and no shared/runtime edge-cache headers + on assembled per-user HTML. +- Processed HTML responses do not retain stale payload validators or range + metadata from the origin representation. + +## Non-goals + +- Origin-template caching. +- Transformed-template caching. +- Dynamic HTML/RSC/API cache-key design. +- SSAT compression offload via `Accept-Encoding: identity` / `X-Compress-Hint`. +- Akamai-specific cache behavior. +- Auction backend timeout/name fixes. +- Making Next.js RSC post-processing streaming-safe in this slice. +- Making Axum, Cloudflare, or Spin publisher SSAT truly streaming in this slice. + +## Current behavior + +### Publisher response shape + +`crates/trusted-server-core/src/publisher.rs` exposes: + +- `PublisherResponse::Buffered` +- `PublisherResponse::Stream` +- `PublisherResponse::PassThrough` + +`PublisherResponse::Stream` currently means "processable body with headers +separated from the body", not necessarily client-visible streaming. Its docs +already note that, on the interim path, the body may have been materialized in +WASM heap upstream. + +Also, the current Fastly send boundary treats a plain `EdgeBody::Stream(_)` as +an asset pass-through stream. It does not carry the publisher-specific state +needed to run the SSAT HTML assembly loop, collect/abandon an auction, or apply +parser-safe bid late binding. True publisher streaming therefore needs an +explicit publisher-streaming envelope or equivalent dispatch path, not just a +processed response whose body happens to be `EdgeBody::Stream(_)`. + +### Fastly origin body materialization + +`handle_publisher_request` sends the origin request with: + +```rust +services + .http_client() + .send(PlatformHttpRequest::new(req, backend_name)) + .await +``` + +On Fastly, `PlatformHttpRequest::stream_response` defaults to `false`, so +`crates/trusted-server-adapter-fastly/src/platform.rs` converts the +`fastly::Response` body through `take_body_bytes()` before returning the +platform-neutral response. That can allocate the full origin response body before +any client bytes are sent. + +### EdgeZero publisher fallback buffering + +`crates/trusted-server-adapter-fastly/src/app.rs` currently resolves publisher +fallback responses with `buffer_publisher_response_async`, producing a single +`Body::Once` response. `crates/trusted-server-adapter-fastly/src/main.rs` only +streams `EdgeBody::Stream(_)` bodies directly to Fastly's `StreamingBody`; SSAT +publisher HTML is therefore sent as a completed buffered response. + +### Raw close-body hold + +`stream_publisher_body_async` uses `BodyCloseHoldBuffer`, which searches decoded +origin bytes for the case-insensitive raw prefix ` + const marker = '' + +``` + +The actual injection is parser-aware because it happens in a `lol_html` body +end-tag handler, but the decision about when to stop streaming and collect the +auction is not parser-aware. + +### Full-document HTML post-processors + +`crates/trusted-server-core/src/html_processor.rs` wraps the HTML rewriter in +`HtmlWithPostProcessing`. When any `IntegrationHtmlPostProcessor` is registered, +it accumulates the rewritten document and runs post-processors only at EOF. The +current Next.js integration registers such a post-processor. + +This is intentionally not stream-safe today and must be treated as buffered mode +for this issue. + +## Locked decisions + +1. **Missing `` fallback:** append the SSAT fallback tail at EOF when no + parser-confirmed body close is available. +2. **Post-processors:** full-document HTML post-processors are an acceptable + buffered-mode tradeoff for this slice. Next.js streaming-safe post-processing + is deferred. +3. **Compression:** true streaming should support gzip, deflate, and brotli by + incrementally decoding, rewriting, and re-encoding. +4. **Adapter scope:** Fastly is the first true-streaming target. Axum, + Cloudflare, and Spin are documented/tested as buffered mode. +5. **Caps:** streaming mode enforces cumulative decoded-input and + processed-output caps, plus a small held-tail cap. + +## Architecture + +### High-level Fastly SSAT streaming flow + +```text +Client request + -> Fastly entry point + -> publisher fallback route + -> dispatch SSAT auction requests + -> fetch publisher origin with streaming response body for request-level SSAT candidates + -> inspect origin response metadata and choose the response-level route + -> if stream-eligible, finalize response headers before client commit + -> Fastly response.stream_to_client() + -> incremental origin body assembly: + encoded origin bytes + -> decoder, if Content-Encoding is gzip/deflate/br + -> cumulative decoded-input cap + -> lol_html processor + -> parser-inserted bid placeholder detection + -> auction collect at placeholder, or EOF fallback + -> cumulative processed-output cap + -> encoder, if response remains compressed + -> Fastly StreamingBody + -> finish StreamingBody +``` + +### Buffered-mode flow + +Buffered mode remains the compatibility path for: + +- non-Fastly adapters in this slice; +- Fastly SSAT HTML when full-document HTML post-processors are registered; +- request shapes that are known before fetch not to be safe streaming candidates; +- unsupported response shapes where the stream cannot safely commit headers; +- existing unmodified buffered routes. + +Buffered mode may still use the same parser-safe placeholder late-binding logic, +but its output sink is a bounded in-memory writer rather than Fastly's +`StreamingBody`. + +## Parser-safe bid late binding + +### Problem with raw-byte scanning + +Raw ``; +- JSON data inside a script block; +- text in a comment; +- attribute content; +- malformed markup that `lol_html` does not interpret as a body end tag. + +Therefore raw scanning is not a safe trigger for auction collection or bid-tail +holding. + +### Proposed mechanism + +Use `lol_html` as the only authority for detecting the real body end tag. + +1. For SSAT requests, configure the HTML processor with a per-request opaque bid + placeholder token instead of directly injecting bids from shared state in the + `body` end-tag handler. +2. The body end-tag handler inserts that placeholder immediately before the real + parser-confirmed ``. +3. The publisher streaming loop scans **processed uncompressed output**, not raw + origin input, for the opaque placeholder. +4. Until the placeholder is found, processed output streams through immediately, + subject only to the placeholder scanner's overlap buffer. +5. When the placeholder is found: + - emit bytes before the placeholder; + - stop reading additional origin bytes after the current decoded/processed + chunk has been handled; + - hold the placeholder plus any suffix from the current processed chunk; + - collect the dispatched auction; + - build the bids script; + - replace the placeholder with the bids script; + - emit the held suffix; + - resume reading and streaming origin bytes. +6. At EOF, if no placeholder was found: + - collect the dispatched auction if it has not already been collected; + - finalize the HTML processor; + - if final processor output contains the placeholder, replace it normally; + - otherwise append the SSAT fallback tail at EOF. + +Pausing origin reads while the auction is collected is intentional. The +implementation should rely on the runtime's normal upstream backpressure rather +than draining the rest of the origin response into memory. The wait remains +bounded by the existing auction collection timeout/deadline; if collection fails +or times out, replace the placeholder with the empty/current bids script and +continue streaming. + +The EOF fallback covers both documents that have a `` without a parsed end +tag and malformed documents that never expose a body end tag. If the normal +`` injection has already run, the fallback tail may be just the bids +script. If no head injection ran, the fallback tail must include the minimal SSAT +bootstrap in executable order — ad slot state, integration head config required +by the TSJS bundle, the TSJS script tag(s), and then the bids script. This is a +best-effort malformed-document path; it must still be bounded by the processed +output cap and must not leak placeholders. + +The placeholder should be a per-request high-entropy token, such as an HTML +comment containing a UUID, for example: + +```html + +``` + +The exact format is an implementation detail, but it must be: + +- generated per request; +- impossible for normal origin content to predict; +- valid HTML when inserted by `lol_html`; +- scanned only in processed uncompressed output; +- fully removed from the client response on success. + +### Placeholder scanner requirements + +The placeholder scanner must be streaming-safe: + +- handle the placeholder split across processor output chunks; +- emit all bytes before the placeholder as soon as safely possible; +- keep at most `placeholder.len() - 1` bytes of overlap while searching; +- once the placeholder is found, hold only the placeholder and suffix bytes until + auction collection completes; +- enforce a held-tail cap so the hold cannot become accidental full-document + buffering; +- after the first successful replacement, strip any later occurrence of the same + placeholder token from processed output rather than forwarding it to the + client. + +### Bid source behavior + +If an auction was dispatched, the first parser-confirmed placeholder triggers +auction collection. The resulting winning bids are written through the existing +bid-script builder path. + +If no auction was dispatched but the SSAT ad stack still needs to inject slot +state and an empty bids script, the late-binding layer should replace the +placeholder immediately with the empty/current bids script without awaiting an +auction. + +If multiple body end tags exist, only the first placeholder is replaced with +bids. The preferred implementation is for the `lol_html` body end-tag handler to +insert at most one placeholder, using the existing once-only guard pattern. The +late-binding scanner must still defensively remove any later placeholders, if +any, so no raw placeholder leaks to the client and bids are not injected multiple +times. + +## Compression design + +Streaming SSAT HTML must support the currently supported origin content +encodings: + +- identity / no `Content-Encoding`; +- `gzip`; +- `deflate`; +- `br`. + +The streaming path should preserve the existing response encoding unless a +separate route explicitly strips it. Compression offload and forcing identity +origin fetches belong to #858 and are out of scope here. + +### Pipeline placement + +Parser-safe placeholder detection must occur after HTML rewriting and before +recompression: + +```text +encoded origin chunk + -> decoder + -> decoded HTML bytes + -> lol_html processor + -> processed uncompressed bytes containing parser placeholder + -> placeholder late binder / bid insertion + -> encoder + -> client writer +``` + +Scanning compressed output would be incorrect because the placeholder would not +be visible. Scanning raw decoded input would reintroduce the original parser +context bug. + +The true-streaming implementation must drive this pipeline from origin stream +chunks. It must not convert the publisher body through `EdgeBody::into_bytes()`, +`take_body_bytes()`, or any equivalent full-body materialization before decoding +and rewriting. Buffered adapters may continue to use the bounded in-memory path. + +### Decoder/encoder finalization + +The existing synchronous pipeline already explicitly finalizes gzip and deflate +encoders with `finish()` and uses explicit brotli flush/finalization behavior. +The new SSAT streaming loop must preserve equivalent finalization semantics: + +- gzip: call `finish()` and propagate errors; +- deflate: call `finish()` and propagate errors; +- brotli: explicitly flush/finalize through the available brotli writer API and + propagate write errors where the API exposes them. + +If compression finalization fails after headers have been committed, log the +error and abort the streaming body. + +## Streaming caps + +True streaming removes the full-document allocation, but it must still protect +WASM memory and CPU from unbounded input/output growth. + +### Cap types + +Use the existing publisher body limit initially: + +```text +settings.publisher.max_buffered_body_bytes +``` + +Despite the current name, it should be applied to streaming SSAT as the +cumulative safety limit until/unless a separate streaming-specific setting is +introduced. + +Enforce: + +1. **Decoded input cap** + - Count bytes after decompression and before HTML parsing. + - Protects against compressed expansion bombs and excessive parser workload. +2. **Processed uncompressed output cap** + - Count bytes emitted by the HTML processor and late binder before + recompression. + - Protects against output amplification from rewrites and injected scripts. +3. **Held-tail cap** + - Count bytes retained after the parser placeholder is found and before the + auction is collected. + - Use a concrete default so tests and operators can reason about behavior: + `SSAT_HELD_TAIL_CAP_BYTES = 64 * 1024` (8× the current 8 KiB stream chunk), + with the effective cap clamped to `settings.publisher.max_buffered_body_bytes` + when that setting is smaller. + - If exceeded, treat it as a streaming safety violation. + +### Pre-commit rejection vs mid-stream abort + +When headers have not yet been committed, known oversized responses should fail +cleanly with an error response. For example, an identity response with +`Content-Length` greater than the configured cap can be rejected before +`stream_to_client()`. Compressed `Content-Length` is not a decoded-size signal and +must not be used as proof that the decoded input is under the cap; compressed +responses are checked by the cumulative decoded-input counter while streaming. + +For compressed, chunked, or unknown-size responses, the true size may only be +known after streaming begins. If a cumulative cap is exceeded after headers are +committed: + +- log the violation with enough context for diagnosis; +- abort/drop the streaming body; +- do not attempt to recover with a buffered fallback because the client-visible + response has already started. + +This matches standard reverse-proxy behavior for mid-stream processing failures. + +## Fastly adapter design + +### Origin fetch + +Fastly has to make the platform request before it knows the origin response's +`Content-Type`, status, or `Content-Encoding`, so the route decision is +intentionally two-phase: + +1. **Request-level candidate decision, before origin fetch.** Use a streaming + platform response only when request metadata and configuration make true SSAT + streaming possible: the method can carry a response body (`GET`, not `HEAD`), + the server-side ad stack may run for this navigation, no full-document HTML + post-processors are registered, and the Fastly send boundary can preserve the + publisher-streaming state through response finalization. +2. **Response-level route decision, after origin headers arrive and before + client commit.** Inspect status, `Content-Type`, `Content-Encoding`, and + relevant headers to choose processed streaming, streamed unmodified + pass-through, bodiless response handling, or a pre-commit error. + +Request-level Fastly candidates should use the existing platform flag shape: + +```rust +PlatformHttpRequest::new(req, backend_name).with_stream_response() +``` + +On Fastly, this preserves the origin response body as `EdgeBody::Stream(_)` +instead of using `take_body_bytes()`. + +The platform-level body materialization cap still applies to non-streaming +requests. Streaming SSAT applies its own cumulative decoded/processed caps in +core. Because a candidate request may later prove not to be processable SSAT +HTML, the Fastly path must be able to forward a preserved origin stream +unmodified without first buffering it. If a request is known before fetch to +require buffered mode, such as post-processor-enabled HTML, do not request a +streaming origin body. + +### Client commit + +For true-streaming Fastly SSAT responses: + +1. Build and mutate response headers in core as today. +2. Preserve the publisher streaming state across the fallback/entry-point + boundary. Do not encode a processed publisher stream as a plain + `EdgeBody::Stream(_)` unless the send path can distinguish it from the asset + pass-through stream case. A dedicated `PublisherResponse` variant, + response extension, or Fastly-specific fallback result is acceptable as long + as it carries the response skeleton, origin stream, processing params, + orchestrator/services access, and auction telemetry token. +3. Apply all finalization that must happen before client commit, including: + - EC/privacy finalization; + - request-filter response effects; + - final cache/privacy guards; + - transformed-response header normalization; + - removal of `Content-Length` for processed streaming bodies. +4. Convert the response headers to a Fastly skeleton response. +5. Call `stream_to_client()`. +6. Run the SSAT streaming body assembly loop, writing to the Fastly + `StreamingBody`. +7. Call `finish()` on success. +8. On any mid-stream error, log and drop the streaming body. + +Once `stream_to_client()` is called, no response header mutation is possible. +The Fastly entry point therefore owns the boundary between final response header +mutation and body streaming. + +### Route decision + +A Fastly publisher response is response-level stream-eligible when all of the +following are true: + +- the request/response can carry a body (`GET`, not `HEAD`, not `204`, `205`, or + `304`); +- the response is HTML that needs SSAT assembly, not merely a text/JS/CSS/JSON + response that the generic publisher processor could rewrite; +- the content encoding is supported (`identity`, `gzip`, `deflate`, `br`); +- no full-document HTML post-processors are registered for the active + integration registry; +- pre-commit header checks, including identity `Content-Length` preflight, have + not rejected the response; +- response headers can be finalized before commit; +- processor construction succeeds before commit. + +If the request was fetched as a streaming candidate but the response is not +processable SSAT HTML, Fastly should stream the origin body unmodified when that +is safe, abandon any dispatched auction with telemetry, and preserve bodyless +status semantics by not attaching a body for `HEAD`, `204`, `205`, or `304`. +If the response requires buffered mode and that requirement was known before the +origin fetch, the request should not have used `with_stream_response()`. + +## Non-Fastly adapters + +For this issue, Axum, Cloudflare, and Spin remain buffered for publisher SSAT +HTML. Their behavior must be explicit in docs and tests: + +- they may still call `buffer_publisher_response_async`; +- they must preserve parser-safe bid injection semantics; +- they must enforce the existing buffered body cap; +- they should remove or normalize headers such as `Transfer-Encoding` where the + adapter already does so after buffering; +- they do not satisfy the Fastly true-streaming acceptance criterion and should + not be described as true streaming in docs or comments. + +Future work can add adapter-specific streaming support once each runtime has a +safe response-commit and body-streaming boundary. + +## HTML post-processors + +Any registered `IntegrationHtmlPostProcessor` means the HTML path requires the +full rewritten document. For this slice: + +- Fastly SSAT HTML with post-processors must route to buffered mode. +- Next.js is explicitly accepted as buffered mode. +- The request-level Fastly route decision should use + `IntegrationRegistry::has_html_post_processors()` or an equivalent presence + check before requesting a streaming origin body. +- Tests should verify that a registry with a post-processor does not enter the + true Fastly streaming path and does not call `with_stream_response()` for the + publisher origin fetch. + +Buffered post-processor mode must preserve the current observable ordering: +post-processors should see the rewritten HTML with the final bids script, not a +raw placeholder. The bounded buffered pipeline should therefore be: + +```text +decode origin body + -> lol_html rewrite with parser placeholder + -> parser-safe late binding / bids replacement or EOF fallback + -> full-document post-processors + -> encode or buffer final body +``` + +No placeholder token may be visible to post-processors unless the post-processor +API explicitly opts into that in future work, and no placeholder may leak to the +client. + +This does not prevent script rewriters or attribute rewriters from streaming; +those run inside `lol_html` and are distinct from full-document post-processors. + +## Privacy and cache behavior + +SSAT-assembled HTML can contain per-user slot state and bid data. This issue +must preserve the existing privacy contract: + +```http +Cache-Control: private, max-age=0 +``` + +and strip runtime/shared edge-cache headers, including: + +```http +Surrogate-Control +Fastly-Surrogate-Control +CDN-Cache-Control +Cloudflare-CDN-Cache-Control +``` + +This applies before Fastly commits the streaming response. Streaming must not +weaken the final cache/privacy guard that protects responses with `Set-Cookie` +or per-user SSAT data. + +### Transformed-response header normalization + +Any processed HTML response has a different byte representation from the origin +response, even when `Content-Encoding` is preserved through decode/re-encode. +Before client commit, the streaming and buffered processed paths should remove +payload-derived headers unless they are recomputed for the transformed payload. +At minimum, processed publisher HTML should remove: + +```http +Content-Length +Content-MD5 +Digest +Repr-Digest +Content-Range +Accept-Ranges +ETag +``` + +`Content-Encoding` should be preserved when the response is re-encoded with the +same encoding. `Transfer-Encoding` remains adapter-owned and should continue to +be removed or normalized where the adapter already does so. Unmodified +pass-through streams keep origin payload validators and range metadata. + +## Error handling + +### Before client commit + +Errors before `stream_to_client()` should return a normal error response where +possible: + +- invalid origin configuration; +- backend registration failure; +- origin fetch failure before headers; +- unsupported route discovered before commit that cannot be safely streamed + unmodified; +- processor construction failure; +- known oversized identity body from `Content-Length` preflight. + +A streaming candidate that resolves to non-HTML, unsupported-encoding HTML, or a +bodiless status is not automatically an error: it may be streamed or returned +unmodified when headers can still be finalized safely. If an SSAT auction was +dispatched and the response cannot be processed, consume or abandon the dispatch +token explicitly so telemetry remains accurate. + +### After client commit + +Errors after headers are committed cannot be converted into a clean HTTP error +response. The streaming path should: + +- log the error; +- emit abandonment/completion telemetry where applicable; +- drop/abort the streaming body without calling `finish()`; +- let the client observe a truncated response. + +Mid-stream errors include: + +- origin stream read failure; +- decompression failure; +- HTML processor failure; +- placeholder hold cap exceeded; +- decoded-input or processed-output cap exceeded; +- write failure to the client streaming body; +- compression finalization failure. + +## Observability + +Add low-cardinality logs or telemetry fields sufficient to understand streaming +behavior without exposing user data: + +- route mode: `fastly_streaming`, `streamed_unmodified_non_html`, + `streamed_unmodified_unsupported_encoding`, `buffered_post_processor`, + `buffered_adapter`, `pass_through`, `buffered_unmodified`; +- request-level candidate decision and response-level route decision; +- whether bid insertion used `body_close`, `eof_fallback`, or + `missing_head_eof_fallback`; +- decoded input bytes; +- processed output bytes; +- held-tail bytes; +- whether the response was compressed and which encoding was used; +- auction collect wait duration at the body-close hold; +- cap violation reason, if any. + +Do not log bid payloads, EC IDs, cookies, consent strings, or full URLs with +sensitive query parameters. + +## Testing strategy + +### Core parser-safety tests + +- Inline script contains `""`; auction collection is not triggered until + the real parser-confirmed body close. +- JSON/script data contains escaped or literal `` split across origin chunks still produces a parser placeholder + and bid injection before the close tag. +- Placeholder token split across processor output chunks is detected and + replaced exactly once. +- Multiple body close tags do not inject bids multiple times and do not leak + placeholders. +- Missing `` appends bids or the SSAT fallback tail at EOF. +- Missing `` plus missing `` appends the minimal SSAT fallback tail + at EOF without leaking placeholders. +- Normal bid injection still places bids before `` when the close tag is + present. + +### Streaming cap tests + +- Large identity HTML below the cap streams successfully. +- Decoded input exceeding the cap fails/aborts without allocating the full body. +- Processed output exceeding the cap fails/aborts. +- Highly compressible gzip/deflate/br HTML that expands over the decoded cap is + rejected during streaming. +- Held-tail cap violation aborts rather than growing an unbounded hold buffer. +- A streaming-body test proves the true Fastly path consumes chunks incrementally + and does not call `EdgeBody::into_bytes()` or an equivalent full-body materializer. + +### Compression tests + +For gzip, deflate, and brotli: + +- compressed origin HTML streams through decode/rewrite/re-encode; +- decompressed output contains injected bids at the correct location; +- final compressed response can be decoded by a client; +- encoder finalization errors, where testable, are propagated as stream errors. + +### Fastly adapter tests + +- Fastly SSAT stream-eligible HTML preserves the origin body as + `EdgeBody::Stream(_)` before client send. +- Fastly SSAT stream-eligible HTML does not call the buffered publisher response + resolver. +- Fastly streaming uses an explicit publisher-streaming dispatch path and is not + mistaken for an asset pass-through `EdgeBody::Stream(_)`. +- Fastly streaming removes `Content-Length` and other payload-derived headers + for processed bodies. +- Fastly response headers are finalized before the streaming body is opened. +- Fastly post-processor-enabled HTML routes to buffered mode and does not request + `with_stream_response()` from the publisher origin. +- Fastly streaming candidates that resolve to non-HTML or unsupported-encoding + HTML stream unmodified safely and abandon any dispatched auction. +- Fastly `HEAD`, 204, 205, and 304 responses do not attach a streaming processed + body. + +### Non-Fastly adapter tests + +- Axum publisher SSAT is explicitly buffered in this slice. +- Cloudflare publisher SSAT is explicitly buffered in this slice. +- Spin publisher SSAT is explicitly buffered in this slice. +- Buffered non-Fastly paths preserve parser-safe bid injection and EOF fallback. +- Buffered post-processor paths run parser-safe late binding before + post-processors and never expose placeholders to post-processors or clients. + +### Privacy regression tests + +- SSAT HTML still emits `Cache-Control: private, max-age=0`. +- Shared/runtime cache headers are stripped from SSAT HTML. +- Streaming Fastly SSAT responses with cookies or per-user data do not regain + shared cacheability after finalization. +- Processed streaming HTML strips stale entity validators/range headers while + unmodified pass-through streams preserve them. + +## Implementation phases + +### Phase 1: Parser-safe late-binding in core + +- Add per-request bid placeholder support to the HTML processor configuration. +- Add a streaming placeholder late-binder that scans processed uncompressed + output and replaces the parser-inserted placeholder with bids. +- Replace raw `BodyCloseHoldBuffer` usage for SSAT collection with + placeholder-triggered collection. +- Add EOF fallback bid append, including the missing-head minimal SSAT fallback + tail when normal head injection never ran. +- Keep existing buffered adapters working through the new parser-safe path. +- Ensure buffered post-processor mode performs late binding before post-processing + so post-processors see final HTML rather than raw placeholders. + +### Phase 2: Streaming caps + +- Add decoded-input and processed-output cumulative counters to the SSAT + streaming loop. +- Add the concrete 64 KiB held-tail cap, clamped by the publisher body limit. +- Map cap violations to the existing proxy error type. +- Add tests for identity and compressed expansion cases. + +### Phase 3: Fastly origin streaming and client streaming + +- Add the two-phase Fastly route decision: request-level streaming candidates + before fetch and response-level stream eligibility after origin headers. +- Request streaming origin responses only for Fastly request-level SSAT + candidates. +- Preserve `EdgeBody::Stream(_)` through an explicit publisher-streaming dispatch + path rather than the asset pass-through stream path. +- Add a Fastly entry-point path that finalizes headers, opens + `stream_to_client()`, and drives the SSAT streaming loop into the + `StreamingBody`. +- Stream non-HTML or unsupported-encoding candidate responses unmodified when + safe, with auction abandonment telemetry. +- Ensure dispatched auctions are collected or abandoned on every exit path. + +### Phase 4: Buffered-mode documentation and route guards + +- Route full-document post-processor HTML to buffered mode before requesting a + streaming origin response. +- Normalize transformed-response headers on both buffered and streaming + processed HTML paths. +- Document Next.js as buffered mode for this slice. +- Document Axum, Cloudflare, and Spin publisher SSAT as buffered mode. +- Add adapter tests so future changes do not accidentally claim streaming parity. + +### Phase 5: Verification + +Minimum targeted verification for touched Rust code: + +```bash +cargo fmt --all -- --check +cargo test-fastly +cargo test-axum +cargo test-cloudflare +cargo test-spin +cargo clippy-fastly +cargo clippy-axum +cargo clippy-cloudflare +cargo clippy-cloudflare-wasm +cargo clippy-spin-native +cargo clippy-spin-wasm +``` + +If implementation touches JS/TS, also run: + +```bash +cd crates/trusted-server-js/lib && npx vitest run +cd crates/trusted-server-js/lib && npm run format +``` + +## Acceptance criteria mapping + +| Issue acceptance criterion | Design coverage | +| --------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Fastly SSAT HTML path no longer requires full origin body materialization before sending client bytes. | Two-phase Fastly route decision uses streaming origin bodies for request-level candidates, preserves publisher-streaming state explicitly, avoids `into_bytes()`/`take_body_bytes()`, and writes to `StreamingBody`. | +| Streaming path enforces a cumulative body cap without requiring a single full-body allocation. | Decoded-input and processed-output cumulative caps plus concrete held-tail cap. | +| Body-close hold is parser-context-aware and does not trigger on ` Date: Thu, 9 Jul 2026 16:00:16 -0500 Subject: [PATCH 3/5] Add opt-in SSAT compression offload --- crates/trusted-server-adapter-axum/src/app.rs | 8 +- .../src/app.rs | 10 +- .../trusted-server-adapter-fastly/src/app.rs | 6 +- crates/trusted-server-adapter-spin/src/app.rs | 10 +- crates/trusted-server-core/src/publisher.rs | 619 +++++++++++++++++- crates/trusted-server-core/src/settings.rs | 41 +- docs/guide/configuration.md | 83 ++- trusted-server.example.toml | 3 + 8 files changed, 711 insertions(+), 69 deletions(-) diff --git a/crates/trusted-server-adapter-axum/src/app.rs b/crates/trusted-server-adapter-axum/src/app.rs index 170654e2a0..25ae4716a4 100644 --- a/crates/trusted-server-adapter-axum/src/app.rs +++ b/crates/trusted-server-adapter-axum/src/app.rs @@ -20,7 +20,8 @@ use trusted_server_core::proxy::{ handle_first_party_proxy_sign, }; use trusted_server_core::publisher::{ - AuctionDispatch, buffer_publisher_response_async, handle_page_bids, handle_publisher_request, + AuctionDispatch, DeliveryCompressionCapability, PublisherAdapterOptions, + buffer_publisher_response_async, handle_page_bids, handle_publisher_request, handle_tsjs_dynamic, page_bids_preflight_denied, }; use trusted_server_core::request_signing::{ @@ -216,7 +217,10 @@ async fn dispatch_fallback( &mut ec_context, auction, req, - EdgeCacheHeader::SMaxageFallback, + PublisherAdapterOptions { + edge_cache_header: EdgeCacheHeader::SMaxageFallback, + delivery_compression: DeliveryCompressionCapability::Unavailable, + }, ) .await?; // Async finalize so the dispatched auction is collected and its bids are diff --git a/crates/trusted-server-adapter-cloudflare/src/app.rs b/crates/trusted-server-adapter-cloudflare/src/app.rs index 015f1e7f7f..9488446f93 100644 --- a/crates/trusted-server-adapter-cloudflare/src/app.rs +++ b/crates/trusted-server-adapter-cloudflare/src/app.rs @@ -22,8 +22,9 @@ use trusted_server_core::proxy::{ handle_first_party_proxy_sign, }; use trusted_server_core::publisher::{ - AuctionDispatch, PublisherResponse, buffer_publisher_response_async, handle_page_bids, - handle_publisher_request, handle_tsjs_dynamic, page_bids_preflight_denied, + AuctionDispatch, DeliveryCompressionCapability, PublisherAdapterOptions, PublisherResponse, + buffer_publisher_response_async, handle_page_bids, handle_publisher_request, + handle_tsjs_dynamic, page_bids_preflight_denied, }; use trusted_server_core::request_signing::{ handle_trusted_server_discovery, handle_verify_signature, @@ -406,7 +407,10 @@ fn build_router(state: &Arc) -> RouterService { &mut ec_context, auction, req, - EdgeCacheHeader::CloudflareCdnCacheControl, + PublisherAdapterOptions { + edge_cache_header: EdgeCacheHeader::CloudflareCdnCacheControl, + delivery_compression: DeliveryCompressionCapability::Unavailable, + }, ) .await { diff --git a/crates/trusted-server-adapter-fastly/src/app.rs b/crates/trusted-server-adapter-fastly/src/app.rs index 9abbe7d63b..32efb8d36b 100644 --- a/crates/trusted-server-adapter-fastly/src/app.rs +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -119,6 +119,7 @@ use trusted_server_core::proxy::{ use trusted_server_core::publisher::{ buffer_publisher_response_async, handle_page_bids, handle_publisher_request, handle_tsjs_dynamic, page_bids_preflight_denied, AuctionDispatch, + DeliveryCompressionCapability, PublisherAdapterOptions, }; use trusted_server_core::request_signing::{ handle_deactivate_key, handle_rotate_key, handle_trusted_server_discovery, @@ -795,7 +796,10 @@ async fn dispatch_fallback( &mut ec.ec_context, auction, req, - EdgeCacheHeader::SurrogateControl, + PublisherAdapterOptions { + edge_cache_header: EdgeCacheHeader::SurrogateControl, + delivery_compression: DeliveryCompressionCapability::FastlyDynamic, + }, ) .await { diff --git a/crates/trusted-server-adapter-spin/src/app.rs b/crates/trusted-server-adapter-spin/src/app.rs index 63cbe6bc2e..c8e8d164af 100644 --- a/crates/trusted-server-adapter-spin/src/app.rs +++ b/crates/trusted-server-adapter-spin/src/app.rs @@ -21,8 +21,9 @@ use trusted_server_core::proxy::{ handle_first_party_proxy_sign, }; use trusted_server_core::publisher::{ - AuctionDispatch, PublisherResponse, buffer_publisher_response_async, handle_page_bids, - handle_publisher_request, handle_tsjs_dynamic, page_bids_preflight_denied, + AuctionDispatch, DeliveryCompressionCapability, PublisherAdapterOptions, PublisherResponse, + buffer_publisher_response_async, handle_page_bids, handle_publisher_request, + handle_tsjs_dynamic, page_bids_preflight_denied, }; use trusted_server_core::request_signing::{ handle_trusted_server_discovery, handle_verify_signature, @@ -672,7 +673,10 @@ fn build_router(state: &Arc) -> RouterService { &mut ec_context, auction, req, - EdgeCacheHeader::SMaxageFallback, + PublisherAdapterOptions { + edge_cache_header: EdgeCacheHeader::SMaxageFallback, + delivery_compression: DeliveryCompressionCapability::Unavailable, + }, ) .await { diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 1262676da6..669d56314a 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -101,6 +101,20 @@ fn restrict_accept_encoding(req: &mut Request) { ); } +fn ensure_vary_accept_encoding(headers: &mut http::HeaderMap) { + let already_varies = headers.get_all(header::VARY).iter().any(|value| { + value.to_str().ok().is_some_and(|value| { + value.split(',').any(|token| { + let token = token.trim(); + token == "*" || token.eq_ignore_ascii_case("accept-encoding") + }) + }) + }); + if !already_varies { + headers.append(header::VARY, HeaderValue::from_static("Accept-Encoding")); + } +} + fn select_supported_accept_encoding(client_accept_encoding: &str) -> String { let supported_subset = SUPPORTED_ENCODING_VALUES .into_iter() @@ -254,7 +268,8 @@ fn parse_deferred_module_filename(filename: &str) -> Option<&'static str> { /// Parameters for processing response streaming. struct ProcessResponseParams<'a> { - content_encoding: &'a str, + input_compression: Compression, + output_compression: Compression, origin_host: &'a str, origin_url: &'a str, request_host: &'a str, @@ -285,17 +300,17 @@ fn process_response_streaming( let is_rsc_flight = content_type_contains_ascii_case_insensitive(params.content_type, "text/x-component"); log::debug!( - "process_response_streaming: content_type={}, content_encoding={}, is_html={}, is_rsc_flight={}", + "process_response_streaming: content_type={}, input_compression={:?}, output_compression={:?}, is_html={}, is_rsc_flight={}", params.content_type, - params.content_encoding, + params.input_compression, + params.output_compression, is_html, is_rsc_flight ); - let compression = Compression::from_content_encoding(params.content_encoding); let config = PipelineConfig { - input_compression: compression, - output_compression: compression, + input_compression: params.input_compression, + output_compression: params.output_compression, chunk_size: 8192, }; @@ -530,7 +545,8 @@ fn apply_publisher_asset_cache_policy( /// Owned version of [`ProcessResponseParams`] for returning from /// [`handle_publisher_request`] without lifetime issues. pub struct OwnedProcessResponseParams { - pub(crate) content_encoding: String, + pub(crate) input_compression: Compression, + pub(crate) output_compression: Compression, pub(crate) origin_host: String, pub(crate) origin_url: String, pub(crate) request_host: String, @@ -707,7 +723,8 @@ pub fn stream_publisher_body( integration_registry: &IntegrationRegistry, ) -> Result<(), Report> { let borrowed = ProcessResponseParams { - content_encoding: ¶ms.content_encoding, + input_compression: params.input_compression, + output_compression: params.output_compression, origin_host: ¶ms.origin_host, origin_url: ¶ms.origin_url, request_host: ¶ms.request_host, @@ -843,12 +860,12 @@ pub async fn stream_publisher_body_async( } }; - let compression = Compression::from_content_encoding(¶ms.content_encoding); stream_html_with_auction_hold( body, output, &mut processor, - compression, + params.input_compression, + params.output_compression, AuctionCollectCtx { dispatched, telemetry, @@ -893,10 +910,9 @@ async fn buffer_html_late_binding_with_postprocessors( }; use crate::integrations::IntegrationDocumentState; - let compression = Compression::from_content_encoding(&ctx.params.content_encoding); let decoded = match decode_body_to_vec( body, - compression, + ctx.params.input_compression, ctx.settings.publisher.max_buffered_body_bytes, ) { Ok(decoded) => decoded, @@ -1032,7 +1048,7 @@ async fn buffer_html_late_binding_with_postprocessors( message: "Failed to post-process buffered publisher HTML".to_string(), })?; - encode_processed_html_to_writer(&post_processed, compression, output) + encode_processed_html_to_writer(&post_processed, ctx.params.output_compression, output) } fn decode_body_to_vec( @@ -1337,7 +1353,8 @@ async fn stream_html_with_auction_hold( body: EdgeBody, output: &mut W, processor: &mut P, - compression: Compression, + input_compression: Compression, + output_compression: Compression, ctx: AuctionCollectCtx<'_>, late_binding: LateBindingStreamConfig<'_>, ) -> Result<(), Report> { @@ -1351,8 +1368,8 @@ async fn stream_html_with_auction_hold( let tracker = late_binding.tracker; let fallback_ctx = late_binding.fallback; let body = body_as_reader(body); - match compression { - Compression::None => { + match (input_compression, output_compression) { + (Compression::None, Compression::None) => { body_close_hold_loop( body, output, @@ -1364,7 +1381,7 @@ async fn stream_html_with_auction_hold( ) .await } - Compression::Gzip => { + (Compression::Gzip, Compression::Gzip) => { let decoder = GzDecoder::new(body); let mut encoder = GzEncoder::new(&mut *output, flate2::Compression::default()); body_close_hold_loop( @@ -1382,7 +1399,7 @@ async fn stream_html_with_auction_hold( })?; Ok(()) } - Compression::Deflate => { + (Compression::Deflate, Compression::Deflate) => { let decoder = ZlibDecoder::new(body); let mut encoder = ZlibEncoder::new(&mut *output, flate2::Compression::default()); body_close_hold_loop( @@ -1400,7 +1417,7 @@ async fn stream_html_with_auction_hold( })?; Ok(()) } - Compression::Brotli => { + (Compression::Brotli, Compression::Brotli) => { let decoder = Decompressor::new(body, STREAM_CHUNK_SIZE); let params = BrotliEncoderParams { quality: 4, @@ -1422,6 +1439,45 @@ async fn stream_html_with_auction_hold( let _ = encoder.into_inner(); Ok(()) } + (Compression::Gzip, Compression::None) => { + body_close_hold_loop( + GzDecoder::new(body), + output, + processor, + placeholder, + Arc::clone(&tracker), + ctx, + fallback_ctx, + ) + .await + } + (Compression::Deflate, Compression::None) => { + body_close_hold_loop( + ZlibDecoder::new(body), + output, + processor, + placeholder, + Arc::clone(&tracker), + ctx, + fallback_ctx, + ) + .await + } + (Compression::Brotli, Compression::None) => { + body_close_hold_loop( + Decompressor::new(body, STREAM_CHUNK_SIZE), + output, + processor, + placeholder, + Arc::clone(&tracker), + ctx, + fallback_ctx, + ) + .await + } + _ => Err(Report::new(TrustedServerError::Proxy { + message: "Unsupported compression transformation".to_string(), + })), } } @@ -1821,6 +1877,41 @@ async fn collect_stream_auction( } } +/// Delivery-compression behavior explicitly supported by an adapter. +/// +/// This capability is independent of [`EdgeCacheHeader`]. Adapters must declare +/// it directly so cache-header selection cannot accidentally enable Fastly-only +/// response delivery behavior. +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub enum DeliveryCompressionCapability { + /// Fastly dynamic compression honors `X-Compress-Hint: on` at delivery. + FastlyDynamic, + /// The adapter has no compatible delivery-compression facility. + Unavailable, +} + +/// Adapter-specific publisher response behavior. +/// +/// Bundles cache-header selection with an explicit delivery-compression +/// capability while keeping those independent platform concerns visible. +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub struct PublisherAdapterOptions { + /// Shared-cache header family emitted by publisher cache policies. + pub edge_cache_header: EdgeCacheHeader, + /// Delivery-compression facility available on the current adapter. + pub delivery_compression: DeliveryCompressionCapability, +} + +fn should_offload_ssat_compression( + settings: &Settings, + adapter_options: PublisherAdapterOptions, + should_run_ad_stack: bool, +) -> bool { + settings.publisher.ssat_compression_offload_enabled + && adapter_options.delivery_compression == DeliveryCompressionCapability::FastlyDynamic + && should_run_ad_stack +} + /// Auction dispatch context passed to [`handle_publisher_request`]. pub struct AuctionDispatch<'a> { /// Orchestrator that dispatches and collects SSP bid requests. @@ -1852,9 +1943,10 @@ pub async fn handle_publisher_request( ec_context: &mut EcContext, auction: AuctionDispatch<'_>, mut req: Request, - edge_header: EdgeCacheHeader, + adapter_options: PublisherAdapterOptions, ) -> Result> { log::debug!("Proxying request to publisher_origin"); + let edge_header = adapter_options.edge_cache_header; // 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. @@ -1965,6 +2057,8 @@ pub async fn handle_publisher_request( auction.orchestrator.is_enabled(), ); let should_run_auction = should_run_ad_stack; + let compression_offload_active = + should_offload_ssat_compression(settings, adapter_options, should_run_ad_stack); // Diagnostic: shows which gate suppresses the server-side auction. Pair with // the `EC context: ... jurisdiction=...` line from EC-context construction // when `consent_allows_auction=false`. @@ -2130,8 +2224,18 @@ pub async fn handle_publisher_request( } ); - // Only advertise encodings the rewrite pipeline can decode and re-encode. - restrict_accept_encoding(&mut req); + if compression_offload_active { + // Ask the origin for identity so Trusted Server can emit uncompressed + // HTML for Fastly's dynamic delivery compression. The response path + // still decodes supported compression if the origin ignores this. + req.headers_mut().insert( + header::ACCEPT_ENCODING, + HeaderValue::from_static("identity"), + ); + } else { + // 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 // origin. On the EdgeZero path the entry point re-injects this header from // trusted Fastly TLS metadata so in-process scheme detection works; the @@ -2303,14 +2407,29 @@ pub async fn handle_publisher_request( content_encoding ); + let offload_stream = compression_offload_active && is_html_content_type(&content_type); + let input_compression = Compression::from_content_encoding(&content_encoding); + let output_compression = if offload_stream { + Compression::None + } else { + input_compression + }; let body = std::mem::replace(response.body_mut(), EdgeBody::empty()); response.headers_mut().remove(header::CONTENT_LENGTH); + if offload_stream { + response.headers_mut().remove(header::CONTENT_ENCODING); + response + .headers_mut() + .insert(HEADER_X_COMPRESS_HINT, HeaderValue::from_static("on")); + ensure_vary_accept_encoding(response.headers_mut()); + } Ok(PublisherResponse::Stream { response, body, params: Box::new(OwnedProcessResponseParams { - content_encoding, + input_compression, + output_compression, origin_host, origin_url: settings.publisher.origin_url.clone(), request_host: request_host.to_string(), @@ -2984,7 +3103,7 @@ mod tests { use brotli::enc::writer::CompressorWriter; use brotli::Decompressor; use flate2::read::GzDecoder; - use flate2::write::GzEncoder; + use flate2::write::{GzEncoder, ZlibEncoder}; use super::*; use crate::auction::types::{AdFormat, AdSlot, MediaType}; @@ -3014,6 +3133,14 @@ mod tests { output } + fn deflate_encode(input: &[u8]) -> Vec { + let mut encoder = ZlibEncoder::new(Vec::new(), flate2::Compression::default()); + encoder + .write_all(input) + .expect("should write deflate test input"); + encoder.finish().expect("should finish deflate encoding") + } + fn brotli_encode(input: &[u8]) -> Vec { let mut encoder = CompressorWriter::new(Vec::new(), 4096, 5, 22); encoder @@ -3036,7 +3163,8 @@ mod tests { content_encoding: &str, ) -> OwnedProcessResponseParams { OwnedProcessResponseParams { - content_encoding: content_encoding.to_owned(), + input_compression: Compression::from_content_encoding(content_encoding), + output_compression: Compression::from_content_encoding(content_encoding), origin_host: settings.publisher.origin_host(), origin_url: settings.publisher.origin_url.clone(), request_host: settings.publisher.domain.clone(), @@ -3056,7 +3184,8 @@ mod tests { dispatched_auction: Option, ) -> OwnedProcessResponseParams { OwnedProcessResponseParams { - content_encoding: String::new(), + input_compression: Compression::None, + output_compression: Compression::None, origin_host: settings.publisher.origin_host(), origin_url: settings.publisher.origin_url.clone(), request_host: settings.publisher.domain.clone(), @@ -3172,6 +3301,416 @@ mod tests { ); } + fn ssat_compression_test_settings(enabled: bool) -> Settings { + let mut settings = Settings::from_toml(&format!( + r#"{} + + [auction] + enabled = true + + [creative_opportunities] + gam_network_id = "12345" + + [[creative_opportunities.slot]] + id = "article-slot" + page_patterns = ["/article"] + formats = [{{ width = 300, height = 250 }}] + "#, + crate_test_settings_str() + )) + .expect("should parse SSAT compression test settings"); + settings.publisher.ssat_compression_offload_enabled = enabled; + settings.proxy.allowed_domains = vec!["*.example".to_string(), "*.example.com".to_string()]; + settings + } + + async fn run_ssat_compression_request( + settings: &Settings, + adapter_options: PublisherAdapterOptions, + origin_body: Vec, + origin_content_encoding: Option<&str>, + ) -> ( + PublisherResponse, + Arc, + RuntimeServices, + AuctionOrchestrator, + ) { + let stub = Arc::new(StubHttpClient::new()); + let mut origin_headers = vec![ + ( + header::CONTENT_TYPE.as_str().to_string(), + "text/html; charset=utf-8".to_string(), + ), + ( + header::CONTENT_LENGTH.as_str().to_string(), + "9999".to_string(), + ), + (header::VARY.as_str().to_string(), "Origin".to_string()), + ( + header::VARY.as_str().to_string(), + "Accept-Language".to_string(), + ), + ]; + if let Some(origin_content_encoding) = origin_content_encoding { + origin_headers.push(( + header::CONTENT_ENCODING.as_str().to_string(), + origin_content_encoding.to_string(), + )); + } + stub.push_response_with_headers(200, origin_body, origin_headers); + let services = build_services_with_http_client( + Arc::clone(&stub) as Arc + ); + let consent = crate::consent::ConsentContext { + jurisdiction: crate::consent::jurisdiction::Jurisdiction::NonRegulated, + ..Default::default() + }; + let mut ec_context = EcContext::new_for_test_with_ip(None, consent, None); + let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); + let req = HttpRequest::builder() + .method(Method::GET) + .uri("https://publisher.example/article?edition=morning&view=full") + .header(header::HOST, "publisher.example") + .header(header::ACCEPT_ENCODING, "gzip, br") + .header("sec-fetch-dest", "document") + .body(EdgeBody::empty()) + .expect("should build SSAT compression request"); + + let publisher_response = handle_publisher_request( + settings, + &services, + None, + &mut ec_context, + AuctionDispatch { + orchestrator: &orchestrator, + slots: settings.creative_opportunity_slots(), + registry: None, + }, + req, + adapter_options, + ) + .await + .expect("should proxy SSAT compression request"); + + (publisher_response, stub, services, orchestrator) + } + + #[test] + fn ssat_compression_offload_requires_setting_capability_and_ad_stack() { + let mut settings = create_test_settings(); + let fastly_options = PublisherAdapterOptions { + edge_cache_header: EdgeCacheHeader::SurrogateControl, + delivery_compression: DeliveryCompressionCapability::FastlyDynamic, + }; + let unavailable_options = PublisherAdapterOptions { + edge_cache_header: EdgeCacheHeader::SMaxageFallback, + delivery_compression: DeliveryCompressionCapability::Unavailable, + }; + + assert!( + !should_offload_ssat_compression(&settings, fastly_options, true), + "disabled setting should retain in-guest compression" + ); + settings.publisher.ssat_compression_offload_enabled = true; + assert!( + !should_offload_ssat_compression(&settings, unavailable_options, true), + "adapters without Fastly dynamic compression should retain in-guest compression" + ); + assert!( + !should_offload_ssat_compression(&settings, fastly_options, false), + "requests outside the server-side ad stack should retain in-guest compression" + ); + assert!( + should_offload_ssat_compression(&settings, fastly_options, true), + "enabled Fastly server-side ad stack should offload compression" + ); + } + + #[test] + fn ensure_vary_accept_encoding_preserves_wildcard() { + let mut headers = http::HeaderMap::new(); + headers.insert(header::VARY, HeaderValue::from_static("*")); + + ensure_vary_accept_encoding(&mut headers); + + assert_eq!( + headers + .get_all(header::VARY) + .iter() + .filter_map(|value| value.to_str().ok()) + .collect::>(), + vec!["*"], + "wildcard Vary should not receive a redundant Accept-Encoding field" + ); + } + + #[tokio::test] + async fn fastly_ssat_compression_offload_requests_and_emits_identity() { + let settings = ssat_compression_test_settings(true); + let html = b"

Origin HTML

"; + let (publisher_response, stub, services, orchestrator) = run_ssat_compression_request( + &settings, + PublisherAdapterOptions { + edge_cache_header: EdgeCacheHeader::SurrogateControl, + delivery_compression: DeliveryCompressionCapability::FastlyDynamic, + }, + html.to_vec(), + None, + ) + .await; + + assert_eq!( + stub.recorded_request_uris(), + vec!["https://origin.test-publisher.com/article?edition=morning&view=full"], + "offload must preserve the publisher request path and query" + ); + let request_headers = stub.recorded_request_headers(); + assert!( + request_headers[0].iter().any(|(name, value)| { + name.eq_ignore_ascii_case(header::ACCEPT_ENCODING.as_str()) && value == "identity" + }), + "Fastly SSAT offload must request identity from the origin" + ); + + let PublisherResponse::Stream { + response, params, .. + } = &publisher_response + else { + panic!("processable SSAT HTML should use the stream route"); + }; + assert_eq!(params.input_compression, Compression::None); + assert_eq!(params.output_compression, Compression::None); + assert_eq!( + response + .headers() + .get(header::CACHE_CONTROL) + .and_then(|value| value.to_str().ok()), + Some("private, max-age=0"), + "compression offload must preserve SSAT browser privacy" + ); + assert!( + response.headers().get("surrogate-control").is_none(), + "compression offload must not restore shared cacheability" + ); + assert!(response.headers().get(header::CONTENT_ENCODING).is_none()); + assert!(response.headers().get(header::CONTENT_LENGTH).is_none()); + assert_eq!( + response + .headers() + .get(HEADER_X_COMPRESS_HINT) + .and_then(|value| value.to_str().ok()), + Some("on") + ); + let vary = response + .headers() + .get_all(header::VARY) + .iter() + .filter_map(|value| value.to_str().ok()) + .collect::>(); + assert_eq!( + vary, + vec!["Origin", "Accept-Language", "Accept-Encoding"], + "offload must preserve existing Vary fields and add Accept-Encoding once" + ); + + let registry = IntegrationRegistry::new(&settings).expect("should build registry"); + let response = buffer_publisher_response_async( + publisher_response, + &Method::GET, + &settings, + ®istry, + &orchestrator, + &services, + ) + .await + .expect("should buffer identity SSAT output"); + assert!(response.headers().get(header::CONTENT_ENCODING).is_none()); + let body = response + .into_body() + .into_bytes() + .expect("should read identity SSAT output"); + let body = String::from_utf8(body.to_vec()).expect("should decode identity SSAT output"); + assert!(body.contains("Origin HTML"), "should preserve origin HTML"); + assert!(body.contains("tsjs"), "should still process SSAT HTML"); + } + + #[tokio::test] + async fn fastly_ssat_compression_offload_decodes_supported_origin_encodings() { + let settings = ssat_compression_test_settings(true); + let registry = IntegrationRegistry::new(&settings).expect("should build registry"); + let html = b"

Compressed origin HTML

"; + let cases = [ + ("gzip", gzip_encode(html)), + ("deflate", deflate_encode(html)), + ("br", brotli_encode(html)), + ]; + + for (origin_content_encoding, origin_body) in cases { + let (publisher_response, _stub, services, orchestrator) = run_ssat_compression_request( + &settings, + PublisherAdapterOptions { + edge_cache_header: EdgeCacheHeader::SurrogateControl, + delivery_compression: DeliveryCompressionCapability::FastlyDynamic, + }, + origin_body, + Some(origin_content_encoding), + ) + .await; + + let PublisherResponse::Stream { + response, params, .. + } = &publisher_response + else { + panic!("supported compressed SSAT HTML should use the stream route"); + }; + assert_eq!( + params.input_compression, + Compression::from_content_encoding(origin_content_encoding), + "should decode the origin's actual content encoding" + ); + assert_eq!(params.output_compression, Compression::None); + assert!(response.headers().get(header::CONTENT_ENCODING).is_none()); + assert_eq!( + response + .headers() + .get(HEADER_X_COMPRESS_HINT) + .and_then(|value| value.to_str().ok()), + Some("on") + ); + + let response = buffer_publisher_response_async( + publisher_response, + &Method::GET, + &settings, + ®istry, + &orchestrator, + &services, + ) + .await + .expect("should buffer decoded SSAT output"); + let body = response + .into_body() + .into_bytes() + .expect("should read decoded SSAT output"); + let body = + String::from_utf8(body.to_vec()).expect("should decode identity SSAT output"); + assert!( + body.contains("Compressed origin HTML"), + "should decode {origin_content_encoding} origin HTML" + ); + } + } + + #[tokio::test] + async fn unavailable_delivery_compression_keeps_mirrored_gzip_behavior() { + let settings = ssat_compression_test_settings(true); + let html = b"

Portable adapter

"; + let (publisher_response, stub, services, orchestrator) = run_ssat_compression_request( + &settings, + PublisherAdapterOptions { + edge_cache_header: EdgeCacheHeader::SMaxageFallback, + delivery_compression: DeliveryCompressionCapability::Unavailable, + }, + gzip_encode(html), + Some("gzip"), + ) + .await; + + let request_headers = stub.recorded_request_headers(); + assert!( + request_headers[0].iter().any(|(name, value)| { + name.eq_ignore_ascii_case(header::ACCEPT_ENCODING.as_str()) && value == "gzip, br" + }), + "non-Fastly adapters must retain supported client encodings" + ); + let PublisherResponse::Stream { + response, params, .. + } = &publisher_response + else { + panic!("processable SSAT HTML should use the stream route"); + }; + assert_eq!(params.input_compression, Compression::Gzip); + assert_eq!(params.output_compression, Compression::Gzip); + assert_eq!( + response + .headers() + .get(header::CONTENT_ENCODING) + .and_then(|value| value.to_str().ok()), + Some("gzip") + ); + assert!(response.headers().get(HEADER_X_COMPRESS_HINT).is_none()); + assert_eq!( + response + .headers() + .get_all(header::VARY) + .iter() + .filter_map(|value| value.to_str().ok()) + .collect::>(), + vec!["Origin", "Accept-Language"], + "non-Fastly adapters must not mutate Vary" + ); + + let registry = IntegrationRegistry::new(&settings).expect("should build registry"); + let response = buffer_publisher_response_async( + publisher_response, + &Method::GET, + &settings, + ®istry, + &orchestrator, + &services, + ) + .await + .expect("should buffer mirrored gzip output"); + let body = response + .into_body() + .into_bytes() + .expect("should read mirrored gzip output"); + let decoded = gzip_decode(&body); + assert!( + String::from_utf8(decoded) + .expect("should decode mirrored gzip") + .contains("Portable adapter"), + "non-Fastly output must remain valid gzip" + ); + } + + #[tokio::test] + async fn disabled_offload_keeps_default_mirrored_compression() { + let settings = ssat_compression_test_settings(false); + let html = b"

Default behavior

"; + let (publisher_response, stub, _services, _orchestrator) = run_ssat_compression_request( + &settings, + PublisherAdapterOptions { + edge_cache_header: EdgeCacheHeader::SurrogateControl, + delivery_compression: DeliveryCompressionCapability::FastlyDynamic, + }, + gzip_encode(html), + Some("gzip"), + ) + .await; + + let request_headers = stub.recorded_request_headers(); + assert!(request_headers[0].iter().any(|(name, value)| { + name.eq_ignore_ascii_case(header::ACCEPT_ENCODING.as_str()) && value == "gzip, br" + })); + let PublisherResponse::Stream { + response, params, .. + } = publisher_response + else { + panic!("processable SSAT HTML should use the stream route"); + }; + assert_eq!(params.input_compression, Compression::Gzip); + assert_eq!(params.output_compression, Compression::Gzip); + assert!(response.headers().get(HEADER_X_COMPRESS_HINT).is_none()); + assert_eq!( + response + .headers() + .get(header::CONTENT_ENCODING) + .and_then(|value| value.to_str().ok()), + Some("gzip") + ); + } + #[test] fn request_ec_uses_cookie_not_header() { let settings = create_test_settings(); @@ -3226,7 +3765,10 @@ mod tests { registry: None, }, req, - EdgeCacheHeader::SurrogateControl, + PublisherAdapterOptions { + edge_cache_header: EdgeCacheHeader::SurrogateControl, + delivery_compression: DeliveryCompressionCapability::Unavailable, + }, ) .await .expect("should proxy publisher request") @@ -3436,7 +3978,10 @@ mod tests { registry: None, }, req, - EdgeCacheHeader::SurrogateControl, + PublisherAdapterOptions { + edge_cache_header: EdgeCacheHeader::SurrogateControl, + delivery_compression: DeliveryCompressionCapability::Unavailable, + }, ) .await .expect("should proxy publisher request"); @@ -4485,7 +5030,8 @@ mod tests { let body = EdgeBody::from(compressed); let params = OwnedProcessResponseParams { - content_encoding: "gzip".to_string(), + input_compression: Compression::Gzip, + output_compression: Compression::Gzip, origin_host: "origin.example.com".to_string(), origin_url: "https://origin.example.com".to_string(), request_host: "proxy.example.com".to_string(), @@ -4532,7 +5078,8 @@ mod tests { IntegrationRegistry::new(&settings).expect("should create integration registry"); let params = OwnedProcessResponseParams { - content_encoding: String::new(), + input_compression: Compression::None, + output_compression: Compression::None, origin_host: "origin.example.com".to_string(), origin_url: "https://origin.example.com".to_string(), request_host: "proxy.example.com".to_string(), @@ -4571,7 +5118,8 @@ mod tests { r#""#; let state = Arc::new(Mutex::new(Some(bids_script.to_string()))); let params = OwnedProcessResponseParams { - content_encoding: String::new(), + input_compression: Compression::None, + output_compression: Compression::None, origin_host: "origin.example.com".to_string(), origin_url: "https://origin.example.com".to_string(), request_host: "proxy.example.com".to_string(), @@ -4623,7 +5171,8 @@ mod tests { // Claim gzip encoding but feed non-gzip bytes. The GzDecoder will // error as soon as it tries to read the gzip header. let params = OwnedProcessResponseParams { - content_encoding: "gzip".to_string(), + input_compression: Compression::Gzip, + output_compression: Compression::Gzip, origin_host: "origin.example.com".to_string(), origin_url: "https://origin.example.com".to_string(), request_host: "proxy.example.com".to_string(), @@ -4730,7 +5279,8 @@ mod tests { let body = EdgeBody::from(html.to_vec()); let params = OwnedProcessResponseParams { - content_encoding: String::new(), + input_compression: Compression::None, + output_compression: Compression::None, origin_host: "origin.example.com".to_string(), origin_url: "https://origin.example.com".to_string(), request_host: "proxy.example.com".to_string(), @@ -4786,7 +5336,8 @@ mod tests { // Small, single-fragment RSC script — placeholder path (not fallback). let html = br#""#; let params = OwnedProcessResponseParams { - content_encoding: String::new(), + input_compression: Compression::None, + output_compression: Compression::None, origin_host: "origin.example.com".to_string(), origin_url: "https://origin.example.com".to_string(), request_host: "proxy.example.com".to_string(), diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index 473482254a..2cafc8d6e2 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -46,6 +46,11 @@ pub struct Publisher { /// Keep this secret stable to allow existing links to decode. #[validate(custom(function = validate_redacted_not_empty))] pub proxy_secret: Redacted, + /// Enables Fastly dynamic delivery compression for eligible server-side ad + /// stack HTML responses. Defaults to `false` and has no effect on runtimes + /// that do not explicitly declare support for Fastly dynamic delivery. + #[serde(default)] + pub ssat_compression_offload_enabled: bool, /// Maximum number of bytes buffered when a publisher origin response is /// post-processed in full (HTML rewriting/injection) instead of streamed. /// This caps the *decoded, post-rewrite* output buffer and applies to any @@ -88,6 +93,7 @@ impl Default for Publisher { origin_url: String::default(), origin_host_header_override: None, proxy_secret: Redacted::default(), + ssat_compression_offload_enabled: false, max_buffered_body_bytes: default_max_buffered_body_bytes(), } } @@ -130,6 +136,7 @@ impl Publisher { /// origin_url: "https://origin.example.com:8080".to_string(), /// origin_host_header_override: None, /// proxy_secret: Redacted::new("proxy-secret".to_string()), + /// ssat_compression_offload_enabled: false, /// max_buffered_body_bytes: 16 * 1024 * 1024, /// }; /// assert_eq!(publisher.origin_host(), "origin.example.com:8080"); @@ -4317,6 +4324,7 @@ origin_host_header_overide = "www.example.com""#, origin_url: "https://origin.example.com:8080".to_string(), origin_host_header_override: None, proxy_secret: Redacted::new("test-secret".to_string()), + ssat_compression_offload_enabled: false, max_buffered_body_bytes: 16 * 1024 * 1024, }; assert_eq!(publisher.origin_host(), "origin.example.com:8080"); @@ -4328,6 +4336,7 @@ origin_host_header_overide = "www.example.com""#, origin_url: "https://origin.example.com".to_string(), origin_host_header_override: None, proxy_secret: Redacted::new("test-secret".to_string()), + ssat_compression_offload_enabled: false, max_buffered_body_bytes: 16 * 1024 * 1024, }; assert_eq!(publisher.origin_host(), "origin.example.com"); @@ -4339,6 +4348,7 @@ origin_host_header_overide = "www.example.com""#, origin_url: "http://localhost:9090".to_string(), origin_host_header_override: None, proxy_secret: Redacted::new("test-secret".to_string()), + ssat_compression_offload_enabled: false, max_buffered_body_bytes: 16 * 1024 * 1024, }; assert_eq!(publisher.origin_host(), "localhost:9090"); @@ -4350,6 +4360,7 @@ origin_host_header_overide = "www.example.com""#, origin_url: "localhost:9090".to_string(), origin_host_header_override: None, proxy_secret: Redacted::new("test-secret".to_string()), + ssat_compression_offload_enabled: false, max_buffered_body_bytes: 16 * 1024 * 1024, }; assert_eq!(publisher.origin_host(), "localhost:9090"); @@ -4361,6 +4372,7 @@ origin_host_header_overide = "www.example.com""#, origin_url: "http://192.168.1.1:8080".to_string(), origin_host_header_override: None, proxy_secret: Redacted::new("test-secret".to_string()), + ssat_compression_offload_enabled: false, max_buffered_body_bytes: 16 * 1024 * 1024, }; assert_eq!(publisher.origin_host(), "192.168.1.1:8080"); @@ -4372,6 +4384,7 @@ origin_host_header_overide = "www.example.com""#, origin_url: "http://[::1]:8080".to_string(), origin_host_header_override: None, proxy_secret: Redacted::new("test-secret".to_string()), + ssat_compression_offload_enabled: false, max_buffered_body_bytes: 16 * 1024 * 1024, }; assert_eq!(publisher.origin_host(), "[::1]:8080"); @@ -4385,6 +4398,7 @@ origin_host_header_overide = "www.example.com""#, origin_url: "https://origin.example.com:8443".to_string(), origin_host_header_override: None, proxy_secret: Redacted::new("test-secret".to_string()), + ssat_compression_offload_enabled: false, max_buffered_body_bytes: 16 * 1024 * 1024, }; @@ -4399,6 +4413,7 @@ origin_host_header_overide = "www.example.com""#, origin_url: "https://origin.example.com".to_string(), origin_host_header_override: Some("www.example.com".to_string()), proxy_secret: Redacted::new("test-secret".to_string()), + ssat_compression_offload_enabled: false, max_buffered_body_bytes: 16 * 1024 * 1024, }; @@ -4406,7 +4421,7 @@ origin_host_header_overide = "www.example.com""#, } #[test] - fn publisher_default_max_buffered_body_bytes_matches_config_default() { + fn publisher_defaults_match_config_defaults() { // The manual `Default` impl must agree with the serde default applied // when the key is omitted from TOML, so programmatic `Publisher::default()` // does not silently produce a zero-byte buffer cap. @@ -4415,6 +4430,10 @@ origin_host_header_overide = "www.example.com""#, super::default_max_buffered_body_bytes(), "Publisher::default() must use the same buffer cap as the TOML default" ); + assert!( + !Publisher::default().ssat_compression_offload_enabled, + "Publisher::default() must keep SSAT compression offload disabled" + ); let from_toml = Settings::from_toml( r#" @@ -4439,6 +4458,26 @@ origin_host_header_overide = "www.example.com""#, Publisher::default().max_buffered_body_bytes, "TOML default and Publisher::default() must stay aligned" ); + assert!( + !from_toml.publisher.ssat_compression_offload_enabled, + "omitted TOML key must keep SSAT compression offload disabled" + ); + } + + #[test] + fn publisher_ssat_compression_offload_parses_from_toml() { + let toml = crate_test_settings_str().replace( + "proxy_secret = \"unit-test-proxy-secret\"", + "proxy_secret = \"unit-test-proxy-secret\"\n ssat_compression_offload_enabled = true", + ); + + let settings = Settings::from_toml(&toml) + .expect("should parse publisher SSAT compression offload setting"); + + assert!( + settings.publisher.ssat_compression_offload_enabled, + "explicit TOML setting should enable SSAT compression offload" + ); } #[test] diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 9387669297..ea912f0fb3 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -162,14 +162,15 @@ Core publisher settings for domain, origin, and proxy configuration. ### `[publisher]` -| Field | Type | Required | Description | -| ----------------------------- | ------- | -------- | --------------------------------------------------------------------------------------- | -| `domain` | String | Yes | Publisher's apex domain name | -| `cookie_domain` | String | Yes | Domain for non-EC cookies (typically with leading dot) | -| `origin_url` | String | Yes | Full URL of publisher origin server | -| `origin_host_header_override` | String | No | Outbound Host header to send while connecting to `origin_url` | -| `proxy_secret` | String | Yes | Secret key for encrypting/signing proxy URLs | -| `max_buffered_body_bytes` | Integer | No | Max bytes buffered when a publisher response is post-processed in full (default 16 MiB) | +| Field | Type | Required | Description | +| ---------------------------------- | ------- | -------- | --------------------------------------------------------------------------------------- | +| `domain` | String | Yes | Publisher's apex domain name | +| `cookie_domain` | String | Yes | Domain for non-EC cookies (typically with leading dot) | +| `origin_url` | String | Yes | Full URL of publisher origin server | +| `origin_host_header_override` | String | No | Outbound Host header to send while connecting to `origin_url` | +| `proxy_secret` | String | Yes | Secret key for encrypting/signing proxy URLs | +| `ssat_compression_offload_enabled` | Boolean | No | Fastly-only dynamic delivery compression for eligible SSAT HTML (default `false`) | +| `max_buffered_body_bytes` | Integer | No | Max bytes buffered when a publisher response is post-processed in full (default 16 MiB) | > **Note:** EC cookies (`ts-ec`) derive their domain automatically as `.{domain}` and > do not use `cookie_domain`. The `cookie_domain` field is used by other cookie helpers. @@ -184,6 +185,8 @@ origin_url = "https://origin.publisher.com" # Optional: connect to origin_url but send this outbound Host header. # origin_host_header_override = "www.publisher.com" proxy_secret = "change-me-to-secure-random-value" +# Fastly only; disabled by default. +ssat_compression_offload_enabled = false ``` **Environment Override**: @@ -194,6 +197,7 @@ TRUSTED_SERVER__PUBLISHER__COOKIE_DOMAIN=.publisher.com TRUSTED_SERVER__PUBLISHER__ORIGIN_URL=https://origin.publisher.com TRUSTED_SERVER__PUBLISHER__ORIGIN_HOST_HEADER_OVERRIDE=www.publisher.com TRUSTED_SERVER__PUBLISHER__PROXY_SECRET=your-secret-here +TRUSTED_SERVER__PUBLISHER__SSAT_COMPRESSION_OFFLOAD_ENABLED=true TRUSTED_SERVER__PUBLISHER__MAX_BUFFERED_BODY_BYTES=16777216 ``` @@ -299,6 +303,35 @@ openssl rand -base64 32 Changing `proxy_secret` invalidates all existing signed URLs. Plan rotations carefully and use graceful transition periods. ::: +#### `ssat_compression_offload_enabled` + +**Purpose**: Offload delivery compression for eligible server-side ad stack +(SSAT) HTML responses to Fastly dynamic compression. + +When enabled on the Fastly adapter, Trusted Server requests identity encoding +from the publisher origin, decodes a supported compressed response if the origin +ignores that request, emits identity HTML, and sends `X-Compress-Hint: on` for +Fastly delivery compression. `Vary: Accept-Encoding` is retained or added as +needed. The setting applies only when the existing SSAT request gates pass and +the response is processable HTML. + +The setting defaults to `false`. Axum, Cloudflare, and Spin explicitly declare +the capability unavailable, so enabling it there does not change request or +response compression behavior. + +::: warning Origin response size +Identity responses use more origin-to-edge bandwidth and count against Fastly's +current 10 MiB raw origin-response limit before Trusted Server processing. Keep +this option disabled for publishers whose representative identity HTML can +approach that limit. +::: + +**Environment Override**: + +```bash +TRUSTED_SERVER__PUBLISHER__SSAT_COMPRESSION_OFFLOAD_ENABLED=true +``` + #### `max_buffered_body_bytes` **Purpose**: Upper bound on the in-memory buffer used when a publisher origin @@ -998,23 +1031,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. diff --git a/trusted-server.example.toml b/trusted-server.example.toml index fa967ef9ff..0830a217e0 100644 --- a/trusted-server.example.toml +++ b/trusted-server.example.toml @@ -10,6 +10,9 @@ origin_url = "https://origin.example.com" # Optional: override outbound Host header while connecting to origin_url. # origin_host_header_override = "www.example.com" proxy_secret = "change-me-proxy-secret" +# Fastly only: request identity HTML from the origin and let Fastly dynamically +# compress eligible server-side ad stack responses at delivery. +ssat_compression_offload_enabled = false [ec] passphrase = "trusted-server-placeholder-secret" From 68c67c9460b1a303c4f07654fccdbb82eb75c730 Mon Sep 17 00:00:00 2001 From: Christian Date: Thu, 9 Jul 2026 16:25:44 -0500 Subject: [PATCH 4/5] Fix SSAT compression offload rollout safety --- crates/trusted-server-core/src/config.rs | 32 ++++++ crates/trusted-server-core/src/publisher.rs | 106 ++++++++++++++++---- crates/trusted-server-core/src/settings.rs | 6 +- docs/guide/configuration.md | 32 ++++-- trusted-server.example.toml | 6 +- 5 files changed, 149 insertions(+), 33 deletions(-) diff --git a/crates/trusted-server-core/src/config.rs b/crates/trusted-server-core/src/config.rs index 7bbecd747e..1a4d5dfada 100644 --- a/crates/trusted-server-core/src/config.rs +++ b/crates/trusted-server-core/src/config.rs @@ -238,6 +238,38 @@ mod tests { ); } + #[test] + fn wrapper_omits_disabled_ssat_compression_offload_for_legacy_compatibility() { + let mut settings = valid_settings(); + settings.publisher.ssat_compression_offload_enabled = false; + let app_config = + TrustedServerAppConfig::new(settings).expect("should build disabled app config"); + let value = + serde_json::to_value(&app_config).expect("should serialize disabled app config"); + let publisher = value + .get("publisher") + .and_then(serde_json::Value::as_object) + .expect("should serialize publisher settings"); + + assert!( + !publisher.contains_key("ssat_compression_offload_enabled"), + "disabled setting must be omitted so older runtimes can load the config" + ); + + let mut settings = valid_settings(); + settings.publisher.ssat_compression_offload_enabled = true; + let app_config = + TrustedServerAppConfig::new(settings).expect("should build enabled app config"); + let value = serde_json::to_value(&app_config).expect("should serialize enabled app config"); + assert_eq!( + value + .get("publisher") + .and_then(|publisher| publisher.get("ssat_compression_offload_enabled")), + Some(&serde_json::Value::Bool(true)), + "enabled setting must remain present in the published config" + ); + } + #[test] fn wrapper_deserializes_from_settings_shape() { let toml = crate_test_settings_str(); diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 669d56314a..b23f783080 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -2224,18 +2224,11 @@ pub async fn handle_publisher_request( } ); - if compression_offload_active { - // Ask the origin for identity so Trusted Server can emit uncompressed - // HTML for Fastly's dynamic delivery compression. The response path - // still decodes supported compression if the origin ignores this. - req.headers_mut().insert( - header::ACCEPT_ENCODING, - HeaderValue::from_static("identity"), - ); - } else { - // Only advertise encodings the rewrite pipeline can decode and re-encode. - restrict_accept_encoding(&mut req); - } + // Only advertise encodings the rewrite pipeline can decode. Compression + // offload changes the processed HTML representation sent to Fastly, not + // origin negotiation, so non-HTML responses retain their negotiated + // encoding. + restrict_accept_encoding(&mut req); // Strip the internal `fastly-ssl` scheme signal before forwarding to the // origin. On the EdgeZero path the entry point re-injects this header from // trusted Fastly TLS metadata so in-process scheme detection works; the @@ -3328,6 +3321,8 @@ mod tests { settings: &Settings, adapter_options: PublisherAdapterOptions, origin_body: Vec, + origin_status: u16, + origin_content_type: &str, origin_content_encoding: Option<&str>, ) -> ( PublisherResponse, @@ -3339,7 +3334,7 @@ mod tests { let mut origin_headers = vec![ ( header::CONTENT_TYPE.as_str().to_string(), - "text/html; charset=utf-8".to_string(), + origin_content_type.to_string(), ), ( header::CONTENT_LENGTH.as_str().to_string(), @@ -3357,7 +3352,7 @@ mod tests { origin_content_encoding.to_string(), )); } - stub.push_response_with_headers(200, origin_body, origin_headers); + stub.push_response_with_headers(origin_status, origin_body, origin_headers); let services = build_services_with_http_client( Arc::clone(&stub) as Arc ); @@ -3371,7 +3366,7 @@ mod tests { .method(Method::GET) .uri("https://publisher.example/article?edition=morning&view=full") .header(header::HOST, "publisher.example") - .header(header::ACCEPT_ENCODING, "gzip, br") + .header(header::ACCEPT_ENCODING, "gzip, br, identity;q=0") .header("sec-fetch-dest", "document") .body(EdgeBody::empty()) .expect("should build SSAT compression request"); @@ -3445,7 +3440,7 @@ mod tests { } #[tokio::test] - async fn fastly_ssat_compression_offload_requests_and_emits_identity() { + async fn fastly_ssat_compression_offload_preserves_origin_negotiation_and_emits_identity() { let settings = ssat_compression_test_settings(true); let html = b"

Origin HTML

"; let (publisher_response, stub, services, orchestrator) = run_ssat_compression_request( @@ -3455,6 +3450,8 @@ mod tests { delivery_compression: DeliveryCompressionCapability::FastlyDynamic, }, html.to_vec(), + 200, + "text/html; charset=utf-8", None, ) .await; @@ -3467,9 +3464,9 @@ mod tests { let request_headers = stub.recorded_request_headers(); assert!( request_headers[0].iter().any(|(name, value)| { - name.eq_ignore_ascii_case(header::ACCEPT_ENCODING.as_str()) && value == "identity" + name.eq_ignore_ascii_case(header::ACCEPT_ENCODING.as_str()) && value == "gzip, br" }), - "Fastly SSAT offload must request identity from the origin" + "Fastly SSAT offload must retain supported origin negotiation" ); let PublisherResponse::Stream { @@ -3534,6 +3531,73 @@ mod tests { assert!(body.contains("tsjs"), "should still process SSAT HTML"); } + #[tokio::test] + async fn fastly_ssat_compression_offload_preserves_non_html_error_compression() { + let settings = ssat_compression_test_settings(true); + let json = br#"{"message":"Negotiated JSON"}"#; + let (publisher_response, stub, services, orchestrator) = run_ssat_compression_request( + &settings, + PublisherAdapterOptions { + edge_cache_header: EdgeCacheHeader::SurrogateControl, + delivery_compression: DeliveryCompressionCapability::FastlyDynamic, + }, + gzip_encode(json), + 502, + "application/json", + Some("gzip"), + ) + .await; + + let request_headers = stub.recorded_request_headers(); + assert!( + request_headers[0].iter().any(|(name, value)| { + name.eq_ignore_ascii_case(header::ACCEPT_ENCODING.as_str()) && value == "gzip, br" + }), + "offload must preserve supported origin negotiation for non-HTML responses" + ); + + let PublisherResponse::Stream { + response, params, .. + } = &publisher_response + else { + panic!("processable JSON should use the stream route"); + }; + assert_eq!(response.status(), http::StatusCode::BAD_GATEWAY); + assert_eq!(params.input_compression, Compression::Gzip); + assert_eq!(params.output_compression, Compression::Gzip); + assert_eq!( + response + .headers() + .get(header::CONTENT_ENCODING) + .and_then(|value| value.to_str().ok()), + Some("gzip") + ); + assert!(response.headers().get(HEADER_X_COMPRESS_HINT).is_none()); + + let registry = IntegrationRegistry::new(&settings).expect("should build registry"); + let response = buffer_publisher_response_async( + publisher_response, + &Method::GET, + &settings, + ®istry, + &orchestrator, + &services, + ) + .await + .expect("should buffer mirrored non-HTML output"); + let body = response + .into_body() + .into_bytes() + .expect("should read mirrored non-HTML output"); + let decoded = gzip_decode(&body); + assert!( + String::from_utf8(decoded) + .expect("should decode mirrored non-HTML gzip") + .contains("Negotiated JSON"), + "non-HTML output must remain valid gzip" + ); + } + #[tokio::test] async fn fastly_ssat_compression_offload_decodes_supported_origin_encodings() { let settings = ssat_compression_test_settings(true); @@ -3553,6 +3617,8 @@ mod tests { delivery_compression: DeliveryCompressionCapability::FastlyDynamic, }, origin_body, + 200, + "text/html; charset=utf-8", Some(origin_content_encoding), ) .await; @@ -3612,6 +3678,8 @@ mod tests { delivery_compression: DeliveryCompressionCapability::Unavailable, }, gzip_encode(html), + 200, + "text/html; charset=utf-8", Some("gzip"), ) .await; @@ -3685,6 +3753,8 @@ mod tests { delivery_compression: DeliveryCompressionCapability::FastlyDynamic, }, gzip_encode(html), + 200, + "text/html; charset=utf-8", Some("gzip"), ) .await; diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index 2cafc8d6e2..e4ad1506fb 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -27,6 +27,10 @@ pub const ENVIRONMENT_VARIABLE_PREFIX: &str = "TRUSTED_SERVER"; #[cfg(test)] pub const ENVIRONMENT_VARIABLE_SEPARATOR: &str = "__"; +fn is_false(value: &bool) -> bool { + !*value +} + #[derive(Debug, Clone, Deserialize, Serialize, Validate)] #[serde(deny_unknown_fields)] pub struct Publisher { @@ -49,7 +53,7 @@ pub struct Publisher { /// Enables Fastly dynamic delivery compression for eligible server-side ad /// stack HTML responses. Defaults to `false` and has no effect on runtimes /// that do not explicitly declare support for Fastly dynamic delivery. - #[serde(default)] + #[serde(default, skip_serializing_if = "is_false")] pub ssat_compression_offload_enabled: bool, /// Maximum number of bytes buffered when a publisher origin response is /// post-processed in full (HTML rewriting/injection) instead of streamed. diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index ea912f0fb3..37ac8e0f56 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -185,8 +185,8 @@ origin_url = "https://origin.publisher.com" # Optional: connect to origin_url but send this outbound Host header. # origin_host_header_override = "www.publisher.com" proxy_secret = "change-me-to-secure-random-value" -# Fastly only; disabled by default. -ssat_compression_offload_enabled = false +# Fastly only; disabled by default. Uncomment after deploying a compatible binary. +# ssat_compression_offload_enabled = true ``` **Environment Override**: @@ -308,22 +308,32 @@ Changing `proxy_secret` invalidates all existing signed URLs. Plan rotations car **Purpose**: Offload delivery compression for eligible server-side ad stack (SSAT) HTML responses to Fastly dynamic compression. -When enabled on the Fastly adapter, Trusted Server requests identity encoding -from the publisher origin, decodes a supported compressed response if the origin -ignores that request, emits identity HTML, and sends `X-Compress-Hint: on` for +When enabled on the Fastly adapter, Trusted Server continues to negotiate only +supported encodings with the publisher origin. For processable HTML, it decodes +the origin response, emits identity HTML, and sends `X-Compress-Hint: on` for Fastly delivery compression. `Vary: Accept-Encoding` is retained or added as needed. The setting applies only when the existing SSAT request gates pass and -the response is processable HTML. +the response is processable HTML; non-HTML responses retain their negotiated +origin encoding. The setting defaults to `false`. Axum, Cloudflare, and Spin explicitly declare the capability unavailable, so enabling it there does not change request or response compression behavior. -::: warning Origin response size -Identity responses use more origin-to-edge bandwidth and count against Fastly's -current 10 MiB raw origin-response limit before Trusted Server processing. Keep -this option disabled for publishers whose representative identity HTML can -approach that limit. +::: warning Fastly delivery billing +Fastly bills dynamically compressed responses based on their uncompressed size +before compression when the service is subject to metered delivery charges. +This option can therefore increase billable delivery bytes even though browsers +receive compressed content. Include uncompressed response size and projected +delivery cost in rollout measurements. See Fastly's +[compression guide](https://www.fastly.com/documentation/guides/concepts/compression/). +::: + +::: warning Deployment ordering +Deploy a Trusted Server binary that supports this setting before enabling it. +Before rolling back to an older binary, set the option to `false` and publish +the configuration with the current CLI so the disabled field is omitted, then +roll back the binary. ::: **Environment Override**: diff --git a/trusted-server.example.toml b/trusted-server.example.toml index 0830a217e0..cb2c0d948e 100644 --- a/trusted-server.example.toml +++ b/trusted-server.example.toml @@ -10,9 +10,9 @@ origin_url = "https://origin.example.com" # Optional: override outbound Host header while connecting to origin_url. # origin_host_header_override = "www.example.com" proxy_secret = "change-me-proxy-secret" -# Fastly only: request identity HTML from the origin and let Fastly dynamically -# compress eligible server-side ad stack responses at delivery. -ssat_compression_offload_enabled = false +# Fastly only: emit processed server-side ad stack HTML as identity and let +# Fastly dynamically compress it at delivery. Defaults to false. +# ssat_compression_offload_enabled = true [ec] passphrase = "trusted-server-placeholder-secret" From 700e9db468ea6a9331d0a4fbfc96b6b5e4a289e3 Mon Sep 17 00:00:00 2001 From: Christian Date: Fri, 10 Jul 2026 17:52:50 -0500 Subject: [PATCH 5/5] Document dynamic response caching design --- ...7-10-ts-dynamic-response-caching-design.md | 1320 +++++++++++++++++ 1 file changed, 1320 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-10-ts-dynamic-response-caching-design.md diff --git a/docs/superpowers/specs/2026-07-10-ts-dynamic-response-caching-design.md b/docs/superpowers/specs/2026-07-10-ts-dynamic-response-caching-design.md new file mode 100644 index 0000000000..a3bbeae97d --- /dev/null +++ b/docs/superpowers/specs/2026-07-10-ts-dynamic-response-caching-design.md @@ -0,0 +1,1320 @@ +# Design TS-owned dynamic response caching + +**Issue:** #859 +**Status:** Draft +**Area:** Trusted Server runtime / publisher origin / Fastly cache + +## Summary + +Trusted Server should cache reusable, anonymous publisher-origin HTML templates +without ever shared-caching the final server-side ad template (SSAT) response. +The origin template is fetched through a canonical, cookie-free request, stored +under an explicit versioned key, and passed through the existing HTML rewrite and +per-request auction assembly pipeline on every request. + +The first implementation is Fastly-only and disabled by default. It uses +Fastly's HTTP readthrough cache so complete origin responses retain their normal +HTTP metadata while Fastly provides request collapsing, revalidation, +stale-while-revalidate (SWR), and surrogate-key purging. Axum, Cloudflare, and +Spin keep the current uncached publisher-origin behavior. + +This design intentionally excludes transformed-template caching and dynamic +React Server Component (RSC) or API caching. Those response families have +additional configuration and cardinality requirements and remain later phases. + +## Context and scope split + +The initial cache-header work in +`docs/superpowers/specs/2026-07-06-cache-control-header-design.md` controls how +browsers and shared caches treat TS-owned/static responses. It does not create a +TS-owned dynamic object cache. + +The adjacent work remains separate: + +- #857 covers parser-safe SSAT late binding and publisher streaming. +- #858 covers optional SSAT compression offload. +- #859 covers the dynamic origin/template cache described here. + +The order matters. The origin-template cache sits before HTML transformation and +personalization. Parser-safe late binding and response privacy continue to run +after a template hit exactly as they do after an origin fetch. + +## Production-audit evidence + +A read-only audit of a representative production publisher used a stock headed +browser and a valid bot-management session. The site was a Next.js App Router +frontend backed by a headless CMS. Findings are intentionally anonymized. + +### HTML size and existing cache behavior + +| Response family | Decoded size observed | Compressed size observed | Existing upstream result | +| --------------- | --------------------: | -----------------------: | ------------------------ | +| Homepage HTML | about 1.4 MiB | about 235 KiB | Shared-cache hit | +| Section HTML | about 720 KiB | about 145 KiB | Shared-cache hit | +| Article HTML | about 635 KiB | about 100 KiB | Shared-cache hit | + +The publisher already cached HTML upstream. A TS origin-template cache would +still remove the upstream network round trip, but its origin-load reduction may +be smaller for publishers that already have an effective origin CDN. For those +publishers, transformed-template caching may eventually provide the larger CPU +benefit. + +### Cookie behavior + +An anonymous browser accumulated many first-party advertising, analytics, +identity, geography, and experiment cookies. Bypassing every request that has a +`Cookie` header would therefore eliminate most useful cache hits. + +Visible article content remained stable across two anonymous cookie states, but +embedded App Router data and related-content modules were not byte-identical. +The audit also observed per-request bot, geography, and experiment cookies on +responses that otherwise came from a shared cache. This demonstrates that a +cacheable body and per-request response side effects can exist at different edge +layers. + +Consequences: + +- a cacheable template request must deliberately request an anonymous + representation instead of forwarding arbitrary cookies; +- TS must not cache or replay origin `Set-Cookie` fields in phase one; +- bot-management and other per-request edge behavior must run outside the + template-cache boundary or the origin connection must be service-authenticated. + +### Query behavior + +Adding a non-content tracking query created a separate upstream cache object and +the query appeared in the returned App Router representation. Removing such a +query from only the cache key would therefore collapse byte-distinct responses. +Phase one bypasses every query-bearing request. + +### `Vary` and RSC behavior + +HTML responses varied on content encoding, an experiment dimension, and several +router-state headers. Initial document responses embedded substantial flight +data rather than fetching it separately. + +A client-side navigation produced an RSC body of roughly 220-260 KiB decoded +and 35-40 KiB compressed. Repeating the same source-route to target-route +navigation produced the same deterministic `_rsc` query, byte-identical body, +and a cache hit. Navigating to the same target from a different source route +produced a different `_rsc` query and different body. + +A practical RSC key can therefore include at least: + +```text +target route × source router state × experiment × encoding +``` + +This evidence supports deferring RSC caching until key cardinality, body impact, +and hit ratio are measured for each configured response family. + +### Audit limitations + +The audit is evidence, not proof that any route is universally anonymous. It did +not cover authenticated accounts, preview mode, every geography, every +experiment assignment, or the publisher's private origin endpoint. Enabling a +template rule remains an explicit operator assertion that its canonical origin +request is safe. + +## Terminology and cache boundaries + +### Origin template + +The unmodified response returned by the publisher origin for a canonical +anonymous request. Phase one caches this object. + +An origin template may be compressed. It includes only origin response metadata +that is safe to reuse. It never includes response cookies. + +### Transformed template + +Origin HTML after auction-independent Trusted Server rewriting and head +injection, but before per-user slot state and bids are bound. This is deferred. + +### Assembled SSAT response + +The browser-facing HTML after route-specific slot state, per-request auction +results, consent-dependent behavior, and other response finalization. This +response remains private and is never entered into a shared cache. + +### Dynamic response family + +A configured class of non-HTML dynamic responses, such as selected RSC or JSON +API responses. Dynamic response families are deferred until their key +cardinality and body impact are measured. + +## Goals + +- Cache anonymous origin HTML templates for explicitly configured routes. +- Keep personalized SSAT output `private, max-age=0` and outside shared caches. +- Build template requests from a canonical, cookie-free request profile. +- Use a deterministic, versioned, collision-resistant 32-byte Fastly cache key. +- Treat query strings, authorization, ranges, prefetches, and configured + personalization signals as bypass conditions. +- Require every origin `Vary` dimension to be canonicalized or included in the + explicit bounded key policy. +- Admit only safe statuses, content types, encodings, headers, and bounded bodies. +- Use Fastly request collapsing, TTL, SWR, revalidation, and surrogate keys. +- Define a generic purge contract suitable for CMS webhooks and operator tools. +- Make unsupported runtimes retain the current request and origin behavior. +- Expose bounded, privacy-safe hit, miss, stale, rejection, and bypass + observability. +- Provide an explicit kill switch through disabled-by-default rules. + +## Non-goals + +- Shared-caching final SSAT HTML. +- Transformed-template caching in phase one. +- Dynamic RSC or API caching in phase one. +- Query normalization or ignored query parameters in phase one. +- Response-cookie stripping or replay in phase one. +- Authenticated, preview, paywall, or account-specific templates. +- Arbitrary raw request-header values as unbounded cache-key dimensions. +- Hard-coded Next.js router-header behavior in core or adapters. +- Cloudflare, Axum, or Spin dynamic cache implementations in phase one. +- Akamai support. +- Replacing the existing static/fingerprinted asset cache rules. +- Changing publisher streaming or compression-offload scope. +- Inferring CMS content identifiers by parsing HTML. + +## Privacy and correctness invariants + +The following invariants are non-negotiable: + +1. Only the origin response from a canonical anonymous request may enter the + template cache. +2. Client cookies, authorization, EC identifiers, consent strings, client IP + headers, and auction data never enter a template key or cached body. +3. A request carrying a configured login, preview, or personalization signal + bypasses template mode before request canonicalization. +4. An origin response carrying `Set-Cookie`, `private`, or `no-store` is not + stored. +5. Unknown or unaccounted `Vary` dimensions prevent storage. +6. Variant values are bounded by configuration; unlisted values bypass rather + than create arbitrary keys. +7. Cache hits still run the existing integration rewrite, auction, late-binding, + response-header finalization, and response-privacy pipeline. +8. SSAT HTML remains `Cache-Control: private, max-age=0` and strips shared-cache + control headers regardless of template cache outcome. +9. Unsupported runtimes do not canonicalize the request and do not pretend to + cache it. +10. Cache or purge diagnostics never log cookie values, authorization values, + consent strings, EC IDs, full query strings, or cached bodies. + +## Threat model + +### Cross-user response leakage + +**Risk:** A personalized origin response is stored and replayed to another user. + +**Controls:** Canonical cookie-free request construction, authorization bypass, +configured personalization bypasses, bounded variants, strict response +admission, and final SSAT privacy. + +### Cache poisoning + +**Risk:** A client supplies a host, header, path representation, or variant value +that collides with another representation. + +**Controls:** Trusted host/scheme extraction, exact path framing, normalized +variant values, explicit allowed values, versioned length-delimited key +material, and SHA-256. + +### Cache-key cardinality denial of service + +**Risk:** Arbitrary query strings, cookies, router state, or headers create +unbounded cache objects. + +**Controls:** Query bypass, no cookie keying, no raw arbitrary header keying, +allowed-value variants, per-rule body limits, and deferred RSC/API support. + +### Response-header replay + +**Risk:** A cached origin response replays cookies, hop-by-hop metadata, or stale +payload validators after HTML transformation. + +**Controls:** Reject `Set-Cookie`, rely on Fastly's HTTP cache framing rules, and +normalize transformed-response headers before browser delivery. + +### Stale content after CMS publication + +**Risk:** TTL/SWR serves an old article, homepage, or section after a CMS update. + +**Controls:** Explicit TTL/SWR, path and group surrogate keys, authenticated +soft/hard purge hooks, and a site-wide purge fallback. + +### Purge abuse + +**Risk:** An unauthenticated or oversized purge request evicts large portions of +the cache. + +**Controls:** Existing admin authentication, strict request limits, canonical +path validation, configured group names, Fastly-only capability checks, and +rate limiting at the administrative boundary. + +## Proposed configuration model + +Template rules extend the existing `[cache]` settings while remaining separate +from `cache.asset_rules`. + +Illustrative configuration: + +```toml +[cache.template_purge] +allow_hard_purge = false + +[[cache.template_rules]] +id = "anonymous-html" +enabled = false +anonymous_only = true +path_globs = ["/", "/articles/**"] +ttl_seconds = 60 +stale_while_revalidate_seconds = 300 +max_object_bytes = 2097152 +bypass_cookie_names = ["example_session", "example_preview"] +bypass_cookie_prefixes = ["example_auth_"] +surrogate_groups = ["home", "articles"] + +[[cache.template_rules.header_rules]] +name = "x-example-router-state" +action = "bypass-if-present" + +[[cache.template_rules.header_rules]] +name = "x-example-experiment" +action = "vary" +allowed_values = ["control", "treatment"] +``` + +All examples are fictional. Real route, cookie, header, and CMS values remain in +operator-owned configuration. + +### Rule matching + +Template rules use the existing matcher vocabulary where practical: + +- `path_prefix` +- `path_glob` +- `path_globs` +- `path_regex` + +An enabled rule configures exactly one matcher family. Rules are ordered and the +first enabled path match wins. If that rule subsequently bypasses because of +request state, matching does not fall through to a later, potentially weaker +rule. + +Phase one has no built-in framework preset. A framework preset may be added only +later as a convenience that expands into the same generic rule model. + +### Required fields + +An enabled rule requires: + +- a unique diagnostic-safe `id` matching + `[a-z0-9][a-z0-9_-]{0,63}`; +- `anonymous_only = true` as an explicit operator assertion; +- exactly one path matcher family; +- a positive `ttl_seconds`; +- a nonzero `max_object_bytes` within the Fastly/platform response limit. + +`stale_while_revalidate_seconds` is optional and defaults to zero. There is no +hidden nonzero TTL or SWR default. `max_object_bytes` caps the actual encoded +body written into the internal cache; the existing decoded/processed HTML caps +remain separate. + +### Configuration bounds + +Configuration validation enforces phase-one cardinality limits before serving +traffic: + +- at most 16 header rules per template rule; +- no duplicate header-rule names after case-insensitive normalization; +- no duplicate allowed values within one `vary` rule; +- at most 4 `vary` header rules; +- at most 16 allowed values per `vary` rule; +- at most 64 total Cartesian variant combinations, including an allowed missing + value; +- at most 128 bytes per fixed or allowed header value; +- at most 64 exact cookie names and 64 cookie prefixes; +- diagnostic IDs and surrogate group slugs no longer than 64 bytes. + +The Cartesian limit is the product of each `vary` rule's allowed values plus its +optional missing state. Several individually small lists cannot combine into an +unbounded attacker-selected keyspace. + +### Cookie bypass fields + +Before removing the `Cookie` header, core inspects every `Cookie` field and +checks: + +- `bypass_cookie_names`: exact, case-sensitive cookie-name matches; +- `bypass_cookie_prefixes`: case-sensitive cookie-name prefixes. + +Cookie-name inspection is stricter than ordinary application cookie extraction. +Invalid header bytes, malformed cookie pairs, duplicate cookie names, or any +other ambiguous syntax bypass template mode with the original request. A +configured bypass cookie in any repeated `Cookie` field must be found; the +implementation must not use only the first field. + +A match bypasses template mode to preserve authenticated, preview, paywall, or +otherwise personalized origin behavior. If every field is valid and no name +matches, every `Cookie` field is removed from the canonical origin request. +Unknown cookies are therefore stripped, not keyed. + +Enabling a rule asserts that serving the canonical anonymous representation to a +request with an unknown cookie is acceptable. The safe failure is anonymous +content, not another user's personalized content. Operators that cannot make +that assertion must leave the rule disabled. + +Cookie values are never logged, hashed into the key, or retained for cache +processing. + +### Header rules + +Header rules are generic and case-insensitive by header name. Phase one supports +these actions: + +| Action | Request behavior | Key behavior | +| ------------------- | -------------------------------------------------------------- | ------------------------------------------ | +| `bypass-if-present` | Bypass if any field with this name is present | Eligible requests have one absent variant | +| `remove` | Remove every field with this name | One canonical absent variant | +| `fixed` | Replace every field with one configured fixed value | Fixed value is in the policy fingerprint | +| `vary` | Forward one configured allowed scalar; bypass all other shapes | Canonical allowed value is framed into key | + +A `fixed` rule requires one validated `value`. A `vary` rule requires a finite, +non-empty `allowed_values` list and may set `allow_missing = true`. Missing is a +distinct framed value only when explicitly allowed. + +Custom `vary` headers are singleton scalar fields in phase one. Eligibility +requires exactly one valid field, strips optional whitespace, rejects comma +lists and repeated fields, and matches configured values case-sensitively. The +canonical configured value—not an independently interpreted raw value—is used +both for origin forwarding and key construction. Invalid bytes, duplicates, +comma-combined values, missing disallowed values, and unlisted values bypass. + +`Accept-Encoding` remains a built-in, grammar-aware exception. Multiple +`Accept-Encoding` fields bypass; one field is parsed by the existing encoding +normalizer and its normalized finite result is forwarded and keyed. + +Phase one does not support unkeyed `forward` behavior for arbitrary +payload-affecting headers. A value is canonicalized, bounded and keyed, or it +bypasses. + +Configuration cannot weaken built-in handling for `Cookie`, `Authorization`, +range headers, forwarding/client-IP headers, client cache-bypass directives, or +hop-by-hop headers. + +### Surrogate groups + +`surrogate_groups` assigns one or more operator-defined, low-cardinality groups +to every object stored by the rule. Group names use a conservative ASCII slug +format and have configured count and length limits. + +Groups support cases where a single CMS update affects an article path plus a +homepage or section listing. Phase one does not inspect HTML or API payloads to +derive groups. + +### Purge settings + +`cache.template_purge.allow_hard_purge` defaults to `false`. It is independent +of rule enablement and does not grant access by itself; the purge endpoint still +requires the existing admin authentication. The setting only permits an already +authenticated caller to choose hard rather than soft purge. + +## Request eligibility + +The decision runs against the original client request before it is mutated for +the publisher origin. + +An origin-template request is eligible only when all of the following hold: + +- the adapter declares Fastly HTTP readthrough cache support; +- the method is `GET`; +- the request has no query string; +- the request is a document navigation according to the existing navigation + classifier; +- one enabled template rule matches the exact request path; +- every `Authorization` field is absent; +- range and all `If-*` conditional headers are absent; +- client `Cache-Control`/`Pragma` does not request no-store, no-cache, or + immediate revalidation; +- the request does not request `only-if-cached`, which has separate local + handling described below; +- the request is not a prefetch according to existing request classification; +- every Cookie field is valid and no configured bypass cookie is present; +- every configured header rule accepts the request; +- every variant value is listed and bounded. + +Phase one does not share `GET` and `HEAD` cache objects. `HEAD` bypasses until +its body and metadata semantics are designed and tested explicitly. + +### Bypass order + +The implementation records the first stable bypass reason from this sequence: + +1. runtime unsupported; +2. feature/rule disabled or no path match; +3. method; +4. query present; +5. not a document navigation; +6. authorization; +7. range or conditional request; +8. `only-if-cached` local response; +9. client cache-bypass directive; +10. prefetch; +11. invalid/ambiguous cookie syntax; +12. configured cookie; +13. configured header; +14. unlisted or ambiguous variant. + +Request cache directives are parsed across every repeated `Cache-Control` and +`Pragma` field. Directive names are case-insensitive and comma-list aware. +`no-cache`, `no-store`, `max-age=0`, and `Pragma: no-cache` bypass template mode +with the original request. Malformed or ambiguous syntax also bypasses. + +Phase one does not implement cache-only template reads. Parsing checks every +field for an exact `only-if-cached` directive before applying any ordinary +cache-bypass directive. If present, it returns `504 Gateway Timeout` locally +without an origin dispatch even when combined with `no-cache`, `no-store`, +`max-age=0`, or malformed directives. It must not fall through to the ordinary +publisher proxy, which could contact origin contrary to the request directive. + +Every other bypass executes the existing publisher-origin path with the original +request headers. It does not partially canonicalize an unsupported or +personalized request. + +## Canonical anonymous origin request + +Core constructs a new origin request for an eligible cache operation instead of +incrementally deleting fields from an arbitrary browser request. + +### Canonical fields + +The canonical request contains: + +- method `GET`; +- the configured publisher-origin URI with the exact eligible path and no query; +- the configured origin `Host` behavior; +- trusted public host and scheme values where the publisher-origin contract + requires them; +- `Accept` fixed to an HTML-capable value; +- the existing normalized supported `Accept-Encoding` representation; +- fixed or bounded variant headers accepted by the matched rule; +- no request body. + +The public scheme and host participate in the cache key because they can affect +origin-generated absolute URLs and later rewrite context. + +### Removed fields + +The canonical request does not carry: + +- `Cookie`; +- `Authorization` or proxy authorization; +- `Forwarded`, `X-Forwarded-For`, or client-IP headers; +- client hints or raw user-agent values unless represented by a future bounded + variant policy; +- `Referer` or `Origin`; +- range, conditional, and client cache-bypass headers; +- prefetch/purpose headers; +- router-state headers classified as removed or bypass-only; +- hop-by-hop headers; +- internal edge signals not explicitly required by the platform adapter. + +If a publisher requires device-specific, language-specific, geographic, or +experiment-specific server HTML, the operator must model that input as a +bounded variant or leave the route uncached. Client IP is not a supported +phase-one key dimension. + +### Bot-management prerequisite + +The canonical request has no end-user bot-management cookie. Before enabling a +rule, the operator must ensure the TS-to-origin connection uses one of: + +- a private or allowlisted origin endpoint; +- service authentication independent of the browser; +- bot management outside the TS template-cache boundary. + +A route that requires each visitor's bot cookie at the publisher origin is not +eligible. + +## Cache-key design + +### Primary key + +Fastly accepts an exact 32-byte custom cache key. Core constructs canonical +binary key material and hashes it with SHA-256. + +The key material includes: + +1. object namespace, `origin-template`; +2. cache-key schema version; +3. stable publisher/site identity; +4. normalized publisher-origin identity, including host-override behavior; +5. matched rule ID and policy fingerprint; +6. trusted public scheme and normalized public host/port; +7. method representation (`GET` in phase one); +8. exact URI path bytes; +9. normalized origin `Accept-Encoding` value; +10. configured variant names and normalized values in sorted header-name order. + +It never includes cookies, authorization, client IP, consent, EC IDs, auction +state, or query strings. + +### Framing + +Fields are not concatenated with delimiters. Each field is encoded as a type/name +identifier plus an explicit byte length and value. Lists include an item count, +and variant names are normalized before sorting. + +Conceptually: + +```text +magic | schema_version | +field_count | + field_name_length | field_name | field_value_length | field_value | + ... +``` + +The SHA-256 digest of this framed material is the 32-byte Fastly key. Tests use +adversarial values to prove that different field boundaries cannot collide +before hashing. + +### Policy fingerprint + +The policy fingerprint is computed during settings preparation, not by +serializing and hashing the entire settings object on every request. It includes +every setting that can change: + +- eligibility and canonical request behavior; +- route matching semantics; +- variant normalization; +- response admission; +- TTL and SWR; +- body cap; +- surrogate groups; +- origin URL/Host behavior relevant to representation. + +Changing one of these settings produces a new key namespace immediately. Old +objects expire naturally and remain purgeable through stable site/path keys. + +### Path handling + +Phase one uses the exact URI path representation supplied by the HTTP request +type after edge host/forwarding sanitization. It does not decode, reorder, or +collapse percent-encoded path forms. Treating equivalent paths as separate keys +may reduce hit ratio but cannot cross-contaminate representations. + +### Encoding variants + +Phase one retains the existing normalized supported `Accept-Encoding` behavior +and includes that normalized value in the key. This creates a small bounded set +of origin representation variants without refactoring the current +input/output-compression pipeline. + +A future implementation may use one canonical stored encoding, but that is not +required for the origin-template phase. + +## Fastly architecture + +### Why HTTP readthrough cache + +Fastly's HTTP cache API is preferred over the lower-level Core Cache API for +phase one because it already provides: + +- complete HTTP response storage; +- custom 32-byte keys; +- request collapsing; +- stale revalidation and 304 update handling; +- SWR; +- response admission hooks; +- surrogate keys and soft/hard purge; +- streaming between backend and cache without inventing a response envelope. + +Using Core Cache would require Trusted Server to define and version its own +status/header metadata envelope, insertion writer abstraction, revalidation +protocol, and cross-runtime body streaming contract. That flexibility is not +needed for the first Fastly-only implementation. + +### Shared platform contract + +`PlatformHttpRequest` gains optional, platform-neutral readthrough cache +metadata. The exact Rust names remain implementation details, but the contract +contains: + +- 32-byte primary key; +- TTL; +- SWR; +- surrogate keys; +- maximum stored object bytes; +- allowed/canonical `Vary` disposition; +- response admission settings; +- rule ID or equivalent diagnostic label. + +The Fastly `PlatformHttpClient` converts this metadata into Fastly request cache +options and an after-send candidate-response hook. + +`PlatformResponse` also gains optional bounded cache-result metadata. The +phase-one response outcome is deliberately coarse: fresh hit, stale hit, stored +miss, or uncacheable miss, together with optional age and the diagnostic rule +ID. Cache/send failures have no `PlatformResponse` and are recorded from the +adapter's existing `Result` error path. Background SWR revalidation errors are +swallowed by the pinned SDK and cannot be reported individually. The metadata +does not claim that a particular stale request was selected for revalidation +unless the SDK exposes that fact reliably. Core does not infer +correctness-critical outcomes by parsing `X-Cache`. The Fastly adapter derives +this metadata before converting and discarding SDK-specific response state. + +Axum, Cloudflare, and Spin do not receive cache metadata in phase one. The +publisher handler checks an explicit adapter capability before matching or +canonicalizing. If an unsupported adapter receives cache metadata because of a +programming error, it returns a typed unsupported-contract error rather than +silently pretending to cache. + +This follows the existing explicit capability pattern used for delivery +compression. + +### High-level request flow + +```text +original client request + -> derive request info, cookies, consent, EC, slots, and auction inputs + -> match template rule and evaluate bypasses + -> if bypassed: + existing origin request path, unchanged + -> if eligible: + build canonical anonymous origin request + build versioned 32-byte key and surrogate keys + attach Fastly HTTP cache policy + -> PlatformHttpClient::send + Fastly cache lookup + hit/stale -> reusable origin response + miss -> collapsed backend fetch + -> candidate response admission + -> store or reject + -> existing response classification and HTML transformation + -> existing parser-safe auction late binding + -> transformed-response header normalization + -> final response privacy + -> browser +``` + +Cookie parsing and auction dispatch use the original request before the +canonical origin request is built. A template hit therefore does not remove +client state needed by the auction or EC lifecycle. + +### Request collapsing + +Fastly's HTTP cache transaction owns miss collapsing. One eligible request +fetches a missing key while concurrent requests for the same complete key wait +for response metadata and then consume the inserted object. + +Collapsing must use the full 32-byte key. Requests with different site, path, +encoding, policy fingerprint, or bounded variants never collapse together. + +If a non-304 response is rejected from storage, its body is not cached. The +after-send hook returns `Ok(())` after calling +`CandidateResponse::set_uncacheable(false)`; it does not return a hook error, +which would discard the fetched response. This abandons the transaction without +recording hit-for-pass metadata. Fastly may wake collapsed waiters serially for +a consistently rejected key; that fail-safe behavior is preferable to an +origin-age-dependent uncacheable marker. + +## Response admission + +The Fastly after-send hook evaluates origin response metadata before storage. +Admission is deterministic and side-effect free. + +### Admission matrix + +| Property | Phase-one requirement | +| --------------------------------------------- | ----------------------------------------------------------------------------------------- | +| Status | `200 OK` for a new/replacement object; validated `304 Not Modified` only for revalidation | +| Method | Eligible `GET` transaction | +| Content type | Parsed HTML media type for a 200 response | +| Content encoding | Identity or an encoding supported by the existing HTML pipeline for a 200 response | +| Body length | Valid declared `Content-Length` for a 200 response, followed by an actual streaming cap | +| `Set-Cookie` | Must be absent | +| `Cache-Control: private` | Reject | +| `Cache-Control: no-store` | Reject | +| `Cache-Control: no-cache` / `must-revalidate` | Reject in phase one | +| `Vary: *` | Reject | +| Other `Vary` fields | Every field must be canonicalized or represented by a bounded key rule | +| Partial/range metadata | Reject partial responses and `Content-Range` | +| Payload | Must remain below the existing decoded/processed response caps during later processing | + +An absent origin `Cache-Control` is not by itself a rejection. The explicit +operator rule supplies the internal template TTL after all safety checks pass. + +Admission reads every header field. Scalar fields such as `Content-Type`, +`Content-Encoding`, and `Content-Length` must have one unambiguous valid value; +repeated/conflicting values reject. Every `Set-Cookie` field rejects, and +`Cache-Control`/`Vary` retain their defined multi-field comma-list parsing. + +Phase one requires `Content-Length` as an early rejection check, but treats it +as advisory. For admitted 200 responses, the hook installs a counting no-op +body transform that copies the encoded backend body into the cache writer while +enforcing `max_object_bytes` against the actual bytes. This sacrifices +host-only insertion on misses but does not buffer the entire body in guest +memory. + +If the actual body exceeds the cap or the copy fails, the transform returns an +error before calling `StreamingBody::finish()`. The current request fails; the +consumed origin response cannot also be recovered and served. The implementation +must prove in Fastly staging that an unfinished writer never becomes a reusable +partial cache object. If that guarantee cannot be demonstrated, phase one must +switch to a lower-level bounded insertion design rather than weakening the cap. + +Supporting bounded chunked insertion without a declared length remains +deferred even though the counting transform could detect its eventual size. + +### `Vary` validation + +Origin `Vary` values are parsed across all header fields and comma-separated +tokens. Matching is case-insensitive and directive-exact. + +A token is accounted for when the matched rule or a built-in invariant gives it +one of these dispositions: + +- canonical fixed value; +- canonical absence/removal; +- bypass whenever present; +- finite allowed variant included in the key. + +Unknown tokens reject storage. Phase one does not rewrite an unknown `Vary` +into a smaller set. + +The Fastly HTTP cache may retain the validated origin vary rule as secondary +HTTP metadata. The TS primary key already contains every bounded forwarded +variant, so retaining the rule is redundant but safe and preserves normal HTTP +cache semantics. + +### Revalidation + +Fastly may revalidate a stale admitted object with origin validators. A `304 Not +Modified` updates only an existing object and never creates a new body. + +The 200 content-type, content-encoding, length, and body-transform requirements +do not apply to a valid 304. The 304 branch validates only replacement metadata: +it must not introduce `Set-Cookie`, forbidden cache-control semantics, wildcard +`Vary`, or unaccounted vary dimensions. It preserves the SDK-suggested update +action and the previously admitted body. + +An unsafe 304 is not handled like a rejected 200 response. Calling +`set_uncacheable` could return an empty 304 to an originally unconditional +request instead of the cached body. The hook therefore returns a typed error for +an unsafe 304 and abandons the update, with no automatic origin retry. During +SWR, the stale response may already have been served and the pinned SDK swallows +the background update error; during foreground revalidation, the request fails +through the existing typed proxy path. Unsafe metadata never refreshes or +replaces the admitted object. + +### Rejected responses + +A non-304 response rejected from storage still proceeds through the existing +publisher response path for that request. The hook calls +`set_uncacheable(false)`, returns `Ok`, and allows the response to be transformed +and sent to the browser without storage. + +If it contains `Set-Cookie` or private directives, the existing final response +privacy layer continues to strip shared-cache control and enforce private +browser behavior. + +## Freshness and stale behavior + +### Rule-owned freshness + +Every enabled rule supplies a positive TTL. SWR is optional and zero when +omitted. + +After admission, the Fastly hook applies the rule TTL and SWR instead of +extending an origin response implicitly. The policy fingerprint includes these +values so a config change does not silently reuse an object with old metadata. + +### SWR behavior + +Within TTL, Fastly serves the fresh template. During SWR, Fastly may serve the +stale template while designating one request to refresh it. All responses still +run per-request TS transformation and personalization after lookup. + +The pinned SDK attaches the selected background revalidation transaction to the +returned `fastly::Response` and completes it when that value is dropped. The +current `fastly_response_to_platform` conversion consumes the body and drops the +Fastly response before returning to core. Therefore the one request selected to +revalidate may block on origin revalidation at the adapter boundary; other +stale hits can still use the stale object. Phase one documents and measures this +limitation instead of claiming fully non-blocking SWR. Preserving the SDK +response lifetime through downstream commit is a future optimization. + +A stale template is anonymous. Serving it cannot replay another user's state, +but it can serve old publisher content. Operators choose TTL/SWR together with +their CMS purge guarantees. + +### Stale-if-error + +Phase one does not add a separate configurable stale-if-error override. SWR is +not an error fallback after its window expires; it solves a different freshness +problem. A later implementation may add stale-if-error only after its runtime +semantics and purge interaction are specified. + +## Failure behavior + +| Failure | Behavior | +| ---------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | +| No rule or request bypass | Use the existing origin path with the original request | +| Runtime unsupported | Use the existing origin path with the original request | +| Key/policy construction failure before request mutation | Log a bounded reason and use the existing origin path | +| Fastly cache/send failure | Preserve typed proxy failure behavior; do not retry because the SDK cannot generally prove that origin dispatch did not occur | +| Origin transport failure | Preserve current publisher proxy error behavior; do not insert | +| Non-304 response admission rejection | Call `set_uncacheable(false)`, return `Ok`, and serve the fetched response through the existing pipeline | +| Unsafe 304 revalidation metadata | Return a typed hook error, abandon the update, and do not retry or return an empty 304 as the template response | +| Actual body exceeds `max_object_bytes` during insertion | Abort the unfinished cache writer and fail the request; do not retry the consumed origin response | +| Other insertion/finalization failure after origin dispatch | Do not repeat the origin request; return the recoverable origin response only when the SDK explicitly preserves one, otherwise proxy failure | +| Stale object within SWR | Serve stale and allow one Fastly revalidation transaction | +| Purge unsupported on adapter | Return an explicit unsupported/`501` administrative result | + +No automatic retry occurs after entering `Request::send`, even for `GET`, because +`SendError` does not expose a general pre-origin-dispatch guarantee. An unfinished +counting-transform writer must not be committed as a partial object; this is a +mandatory Fastly staging assertion. Phase one handles only `GET`. + +## Surrogate keys + +Surrogate keys are attached to the internal origin-template object through the +Fastly cache API. They do not make the final assembled response shared-cacheable +and do not need to be exposed to the browser. + +Every object receives: + +- a schema/global template key; +- a stable site key; +- a policy/rule key; +- a stable path key; +- zero or more configured group keys. + +Conceptually: + +```text +ts-tpl-v1 +ts-tpl-site- +ts-tpl-policy-- +ts-tpl-path-- +ts-tpl-group-- +``` + +Hashes are lowercase fixed-length hexadecimal or another explicitly specified +URL/ASCII-safe encoding. Raw hosts, paths, customer names, and query values do +not appear in surrogate keys. + +The path key intentionally excludes policy fingerprints and representation +variants so one path purge removes old/new policy revisions and every encoding +or bounded header variant still resident in cache. + +Phase one does not inherit arbitrary origin `Surrogate-Key` values. The +after-send hook calls `CandidateResponse::set_surrogate_keys` with the complete +TS-generated set, replacing the SDK's suggested/origin key set. Merely adding a +request cache override would extend origin keys and would violate this +invariant. Origin key namespacing and trust require a separate allowlist design. + +## CMS purge contract + +The design reserves `POST /_ts/admin/cache/purge`, implemented in a later +origin-cache PR or a tightly coupled purge PR, with a payload conceptually like: + +```json +{ + "mode": "soft", + "paths": ["/articles/example-story"], + "groups": ["home", "articles"], + "purge_site": false +} +``` + +The endpoint is `POST`-only, requires the expected JSON media type, and +authenticates before parsing the body or computing purge keys. Its path must be +included in `Settings::ADMIN_ENDPOINTS` so configuration validation and +cross-adapter route tests guarantee authentication coverage. + +The endpoint uses the existing administrative authentication boundary and: + +- accepts only Fastly when purge capability is available; +- allows authenticated soft purge by default; +- allows authenticated hard purge only when the separate default-disabled + `cache.template_purge.allow_hard_purge` setting is true; +- requires absolute paths beginning with `/` and rejects schemes, hosts, + fragments, queries, control characters, and traversal-like invalid forms; +- accepts only configured group slugs; +- caps path count, group count, body size, and total purge operations; +- computes surrogate keys server-side; +- never accepts arbitrary raw surrogate keys from any caller; +- returns per-key success/failure without echoing sensitive configuration. + +### CMS publication strategy + +For a content update, the CMS or operator should purge: + +1. the canonical content path; +2. every listing group affected by the update, such as a homepage or section; +3. the site key only when dependency mapping is unavailable or a global element + changes. + +Soft purge is the normal mode so a stale anonymous template can remain available +while Fastly revalidates. Hard purge is reserved for privacy, legal, or urgent +content-removal events where stale delivery is unacceptable. + +The first implementation may expose operator-triggered path/group purge before +a framework-specific CMS webhook. The contract remains generic and does not +hard-code WordPress or Next.js behavior. + +## Transformed-response normalization + +A cached template's headers describe the origin bytes, not the transformed +browser representation. Any processed HTML response must remove payload-derived +metadata unless recomputed. + +At minimum, processed HTML removes or recomputes: + +- `Content-Length`; +- `ETag` and `Last-Modified`; +- `Content-MD5`, `Digest`, and `Repr-Digest`; +- `Content-Range` and `Accept-Ranges`; +- stale origin `Transfer-Encoding` framing where the adapter does not already + normalize it; +- origin cache-age/debug headers that would describe the template rather than + the assembled response. + +`Content-Encoding` follows the existing processing/compression-offload path. +`Vary: Accept-Encoding` remains where delivery can vary by encoding. + +This normalization applies whether the template came from cache or origin and +must occur before final response commit. + +## Final response privacy + +Template caching does not alter the final SSAT privacy rule: + +```http +Cache-Control: private, max-age=0 +``` + +The final response strips shared-cache control headers, including: + +```http +Surrogate-Control +Fastly-Surrogate-Control +CDN-Cache-Control +Cloudflare-CDN-Cache-Control +``` + +The existing `response_privacy.rs` finalizer remains the last safety net for +cookie-bearing responses and operator-configured headers. A cache hit must not +skip it. + +Internal template surrogate keys tag only the origin-template object. They are +not evidence that assembled HTML may be stored. + +## Observability + +### Request outcome + +Each publisher request records one bounded template-cache outcome: + +- disabled/no matching rule; +- bypass with stable reason; +- hit; +- stale hit; +- miss/store; +- uncacheable miss with stable rejection reason; +- cache error. + +The optional `PlatformResponse` cache-result metadata is the source for these +outcomes. If the pinned SDK cannot distinguish two states reliably, telemetry +uses a coarser truthful outcome rather than inferring one from timing or an +upstream `X-Cache` string. + +Logs include rule ID, outcome, object-size bucket, and a short cache-key digest +prefix where useful. They do not include cookie values, authorization, query +strings, body data, consent data, EC IDs, or raw variant values. + +### Metrics + +The implementation should expose counters/histograms for: + +- eligible and bypassed requests by rule/reason; +- hit, stale, miss, and insertion outcomes, plus revalidation only when the + adapter can prove it; +- admission rejection reason; +- compressed stored object size; +- origin latency on miss versus lookup latency on hit; +- rewrite/assembly time after hit versus miss; +- purge requests and failures; +- cache/send errors. + +These measurements determine whether origin-template caching removes meaningful +latency when the publisher already has an upstream CDN and whether +transformed-template caching should be prioritized next. + +### Debug headers + +A debug-only response header may expose a coarse outcome such as `hit`, `miss`, +`stale`, or `bypass`. It must be gated by existing debug/operator controls and +must not expose the primary key, variant values, origin details, or purge keys. + +## Runtime behavior + +| Runtime | Phase-one behavior | +| ---------- | ----------------------------------------------------------------------------------------------- | +| Fastly | HTTP readthrough template cache, custom key, admission hook, collapse, TTL/SWR, surrogate purge | +| Axum | Existing uncached publisher request; no canonicalization | +| Cloudflare | Existing uncached publisher request; no canonicalization | +| Spin | Existing uncached publisher request; no canonicalization | + +The non-Fastly behavior is an explicit fallback, not a local in-memory cache. +An in-process cache would have different eviction, isolation, collapse, and +purge semantics and would provide misleading parity. + +## Rollout strategy + +1. Land this design without runtime behavior changes. +2. Implement pure rule matching, eligibility, canonicalization, key material, + and response-admission tests. +3. Implement Fastly HTTP cache metadata and hooks behind disabled rules. +4. Validate bot-management/service-origin prerequisites in staging. +5. Enable one low-risk anonymous route with a short explicit TTL and no SWR. +6. Verify hit/miss, canonical origin headers, response privacy, body limits, and + purge behavior. +7. Enable bounded SWR and CMS purge. +8. Expand route rules gradually while monitoring bypass ratio, rejection ratio, + hit rate, origin latency, and rewrite CPU. +9. Keep `enabled = false` as the immediate per-rule kill switch. + +Deploy a binary that understands `cache.template_rules` before publishing an +enabled rule. Disable and republish rules before rolling back to a binary that +does not recognize the new configuration fields. + +## Test strategy + +### Configuration and matching + +- Rules default disabled. +- Enabled rules require `anonymous_only = true`. +- IDs are unique diagnostic-safe slugs and matchers compile eagerly. +- Exactly one matcher family is configured. +- First matching rule wins. +- A bypass on the first matching rule does not fall through. +- TTL/body/variant/group bounds reject invalid config. +- Duplicate header-rule names after case folding and duplicate allowed values + reject config. +- Variant Cartesian products over the configured limit reject config. + +### Request eligibility + +- GET document navigation is eligible when all policy inputs match. +- HEAD, POST, range, prefetch, RSC, API-like, and query-bearing requests bypass. +- Authorization and every conditional request field bypass. +- Repeated/mixed-case `Cache-Control` and `Pragma` fields detect `no-cache`, + `no-store`, `max-age=0`, and malformed/ambiguous syntax and bypass unchanged. +- `only-if-cached` returns local 504 without origin dispatch, including when + mixed with `no-cache`, `no-store`, `max-age=0`, or malformed directives. +- Each exact/prefix login, preview, paywall, and personalization cookie bypass + works. +- A bypass cookie in any repeated Cookie field is found. +- Malformed, invalid, or duplicate cookie syntax bypasses with the unchanged + request. +- Unknown valid cookies are stripped from an otherwise eligible canonical + request. +- Unsupported runtimes retain the original cookie/header request. +- Fixed, removed, bypass, and bounded-vary header actions work as specified. +- Repeated fields, comma lists, invalid bytes, and reordered mixed allowed/ + disallowed values cannot create key/origin mismatches. +- Missing/unlisted variant values cannot create arbitrary keys. + +### Canonical request + +- Cookie, authorization, IP/forwarding, client hints, referer, ranges, + conditionals, and internal signals do not reach the template origin. +- Trusted host/scheme and origin Host override remain correct. +- Accept and encoding normalization are deterministic. +- Auction/EC logic still sees the original client request before + canonicalization. + +### Key construction + +- Same normalized request and policy produce the same 32-byte key. +- Site, origin, path, scheme, host, policy, encoding, and variant changes alter + the key. +- Cookie values, EC IDs, consent, auction data, and queries are absent. +- Length framing prevents ambiguous concatenation. +- Variant ordering does not change the key. +- Policy changes produce a new fingerprint. + +### Response admission + +- Safe 200 HTML with supported encoding and bounded length is admitted. +- Missing/oversized/invalid declared `Content-Length` rejects. +- The counting body transform admits exact-limit bodies and aborts actual + over-limit or truncated/mismatched bodies without committing a partial object. +- Non-HTML, partial, redirect, 4xx, and 5xx responses reject. +- `Set-Cookie`, `private`, `no-store`, `no-cache`, and `must-revalidate` reject. +- `Vary: *` and unknown `Vary` reject. +- Canonicalized and bounded variant `Vary` fields admit. +- A valid 304 follows its metadata-only branch without 200 body requirements. +- Unsafe 304 metadata cannot refresh a stale object. +- Non-304 admission rejection calls `set_uncacheable(false)`, returns `Ok`, and + serves the current response without hit-for-pass metadata. +- Unsafe 304 metadata produces a typed update failure and can never return an + empty 304 in place of the admitted body. +- The hook replaces, rather than extends, origin surrogate keys. + +### Fastly behavior + +- Cache options map to the 32-byte key, TTL, SWR, surrogate keys, and + after-send hook. +- Pure tests cover cache-option and hook construction without invoking cache + hostcalls. +- Fastly staging proves same-key miss collapse, insertion, actual body-cap abort, + SWR/revalidation, 304 update, and purge because Viceroy cannot emulate them. +- Different variants do not collapse. +- Fresh hit, stale hit, inserted miss, uncacheable miss, and rejection outcomes + are observable; staging origin counts verify revalidation when the SDK does + not expose selection directly. +- Soft and hard purge cover every encoding/variant for a path. +- Cache/send failures do not trigger an unsafe automatic origin retry. +- Response-side cache-result metadata reports only outcomes the adapter can + prove. + +### Privacy and finalization + +- Cached templates never contain request cookie/authorization data. +- SSAT output remains private on hit, stale hit, miss, and revalidation. +- Edge-cache control headers cannot be reintroduced by operator headers. +- Origin validators/ranges/length do not describe transformed bytes. +- Set-Cookie responses are never inserted. +- Logged-in, preview, paywall, and personalized signals bypass. + +### Cross-adapter behavior + +- Axum, Cloudflare, and Spin make the original origin request without template + cache metadata. +- Unsupported adapters do not strip cookies or canonicalize headers. +- `POST /_ts/admin/cache/purge` authenticates before body parsing on every + adapter, requires POST plus JSON, and rejects oversized/invalid payloads. +- Unsupported adapters return `501` only after authentication. +- Hard purge is rejected unless `allow_hard_purge` is enabled; arbitrary raw + surrogate keys are always rejected. + +### Manual/staging verification + +- Confirm the service-authenticated or allowlisted origin works without browser + bot cookies. +- Compare origin request counts before and after a controlled miss burst. +- Verify object age, SWR, revalidation, and purge with representative HTML. +- Confirm publisher content updates invalidate article and listing groups. +- Confirm final HTML never becomes shared-cacheable. +- Measure origin RTT saved and rewrite CPU remaining on hits. + +## Future phase: transformed-template cache + +The transformed cache may store auction-independent rewritten/head-injected HTML +and late-bind only per-request slot/bid data. It is not a trivial reuse of the +origin-template key. + +A future design must resolve: + +- a stable cached placeholder representation; the current marker is deliberately + random per request; +- transformation inputs such as public host/scheme, integration configuration, + TSJS module hashes, route-matched slots, and post-processors; +- a transformation-policy fingerprint; +- whether full-document post-processors are safe and deterministic; +- payload validator and compression representation; +- transformed object body limits and rewrite CPU measurement; +- purge fan-out between origin and transformed object namespaces. + +The production audit suggests this phase may provide substantial value when the +publisher origin is already behind an effective CDN, because it can remove +per-request rewrite CPU rather than only upstream RTT. + +## Future phase: RSC/API dynamic families + +RSC/API caching requires a separate configured family with measured evidence. +Before implementation, each family must record: + +- request count and candidate hit ratio; +- unique normalized key count; +- `Vary` and router/header cardinality; +- decoded/compressed body sizes; +- whether source router state changes the target body; +- cookie/auth/personalization behavior; +- query semantics; +- purge relationship to HTML and CMS content. + +A framework preset may later populate generic header/query rules, but core and +adapters must not branch on Next.js header names. Unbounded router state must +not be copied into a generic dynamic key without explicit measurement and caps. + +## Implementation prerequisites and open deployment inputs + +The design is complete without hard-coding deployment-specific values, but each +enabled publisher must supply: + +- exact anonymous route matchers; +- login/preview/personalization cookie names or prefixes; +- any bounded header variant values; +- confirmation that the canonical request works without end-user bot cookies; +- a TTL/SWR choice; +- a compressed object-size cap; +- CMS path/group purge mapping; +- a staging validation result. + +The pinned Fastly SDK 0.12.1 exposes the required HTTP cache primitives. Pinned +Viceroy 0.17.0 does not support the HTTP readthrough cache hostcalls, so local +Viceroy tests cannot prove lookup, collapse, insertion, SWR, 304 update, or +purge behavior. The implementation PR must test pure policy/hook construction +locally and execute a documented Fastly staging verification matrix for runtime +semantics. + +## Acceptance-criteria mapping + +| Issue #859 criterion | Design coverage | +| ------------------------------------------------------------------------------------ | ------------------------------------------------------------------- | +| Design covers key, bypass, cookie/header policy, purge, runtime support, and privacy | Dedicated sections above | +| Personalized SSAT HTML remains private and never enters shared cache | Cache boundaries, invariants, final response privacy | +| Origin-template cache stores only proven-safe routes/responses | Disabled rules, operator assertion, eligibility, response admission | +| Template cache emits purgeable keys and has a CMS purge strategy | Surrogate keys and CMS purge contract | +| Dynamic Vary/key normalization is generic/configurable | Generic header actions and bounded variants; no framework branch | +| RSC/API caching is gated by measurement | Production evidence and deferred RSC/API phase | +| Tests cover logged-in/preview/paywall/personalized bypass | Eligibility, privacy, and cross-adapter test matrices | + +## Locked decisions + +1. Fastly is the first cache runtime; all other adapters preserve current origin + behavior. +2. The first PR for #859 is this design only. +3. Rules are disabled by default and explicitly allowlist anonymous HTML routes. +4. Eligible origin requests are canonical and cookie-free. +5. Configured login/preview/personalization cookies bypass before all cookies are + stripped. +6. Query-bearing requests bypass in phase one. +7. Authorization, ranges, prefetches, and non-document families bypass. +8. Every `Vary` field is canonicalized or represented by a bounded generic + policy; unknown fields reject storage. +9. Any origin `Set-Cookie`, `private`, `no-store`, `no-cache`, or + `must-revalidate` rejects storage in phase one. +10. Fastly HTTP readthrough cache provides storage, collapsing, revalidation, + SWR, and purge. +11. TTL is required per rule; SWR defaults to zero. +12. Site, policy, path, and configured group surrogate keys are attached to + internal template objects. +13. RSC/API, transformed templates, query normalization, and response-cookie + stripping are deferred. +14. Cookie and custom-header parsing fail closed on malformed, repeated, or + ambiguous values. +15. The actual stored body cap is enforced by a counting insertion transform; + over-limit insertion fails closed and must not commit a partial object. +16. Non-304 admission rejection returns the fetched response through + `set_uncacheable(false)`; unsafe 304 metadata is a distinct typed update + failure. +17. Fastly cache/send errors are not retried without a provable pre-dispatch + signal. +18. Hard purge is disabled unless separately enabled for authenticated admins. +19. SSAT-assembled HTML remains `private, max-age=0` regardless of cache outcome.