diff --git a/.env.example b/.env.example index 1121ecd9b..c2ac88e3a 100644 --- a/.env.example +++ b/.env.example @@ -37,7 +37,7 @@ TRUSTED_SERVER__REQUEST_SIGNING__ENABLED=false # Prebid TRUSTED_SERVER__INTEGRATIONS__PREBID__ENABLED=false -# TRUSTED_SERVER__INTEGRATIONS__PREBID__SERVER_URL=https://prebid-server.com/openrtb2/auction +# TRUSTED_SERVER__INTEGRATIONS__PREBID__SERVER_URL=https://prebid-server.example.com/openrtb2/auction # TRUSTED_SERVER__INTEGRATIONS__PREBID__TIMEOUT_MS=1000 # TRUSTED_SERVER__INTEGRATIONS__PREBID__BIDDERS=kargo,rubicon,appnexus # TRUSTED_SERVER__INTEGRATIONS__PREBID__BID_PARAM_OVERRIDES='{"bidder-name":{"param1":12345,"param2":"value"}}' @@ -45,6 +45,7 @@ TRUSTED_SERVER__INTEGRATIONS__PREBID__ENABLED=false # TRUSTED_SERVER__INTEGRATIONS__PREBID__BID_PARAM_ZONE_OVERRIDES='{"kargo":{"header":{"placementId":"_abc"}}}' # Preferred canonical env shape for future generic rules # TRUSTED_SERVER__INTEGRATIONS__PREBID__BID_PARAM_OVERRIDE_RULES='[{"when":{"bidder":"kargo","zone":"header"},"set":{"placementId":"_abc"}}]' +# TRUSTED_SERVER__INTEGRATIONS__PREBID__SUPPRESS_NURL_BIDDERS=exampleBidder,anotherBidder # TRUSTED_SERVER__INTEGRATIONS__PREBID__AUTO_CONFIGURE=false # TRUSTED_SERVER__INTEGRATIONS__PREBID__DEBUG=false # TRUSTED_SERVER__INTEGRATIONS__PREBID__TEST_MODE=false diff --git a/.gitignore b/.gitignore index dabeec55d..106202684 100644 --- a/.gitignore +++ b/.gitignore @@ -53,3 +53,8 @@ src/*.html /crates/trusted-server-integration-tests/browser/test-results/ /crates/trusted-server-integration-tests/browser/playwright-report/ /crates/trusted-server-integration-tests/browser/.browser-test-state.json + +# Defunct pre-rename crate dirs (renamed to crates/trusted-server-*); ignore the +# leftover local build artifacts (node_modules, target, dist) that remain on disk. +/crates/js/ +/crates/integration-tests/ diff --git a/Cargo.lock b/Cargo.lock index c77167838..0427b972d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2028,6 +2028,12 @@ dependencies = [ "r-efi 6.0.0", ] +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + [[package]] name = "group" version = "0.13.0" @@ -5283,6 +5289,7 @@ dependencies = [ "flate2", "futures", "getrandom 0.2.17", + "glob", "hex", "hmac", "http", @@ -5299,6 +5306,7 @@ dependencies = [ "sha2 0.10.9", "subtle", "temp-env", + "tokio", "toml", "trusted-server-js", "trusted-server-openrtb", diff --git a/Cargo.toml b/Cargo.toml index 3d139333e..f410c011b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -55,6 +55,7 @@ fastly = "0.12" fern = "0.7.1" flate2 = "1.1" futures = "0.3" +glob = "0.3" getrandom = "0.2" hex = "0.4.3" hmac = "0.12.1" diff --git a/crates/trusted-server-adapter-axum/src/app.rs b/crates/trusted-server-adapter-axum/src/app.rs index f312b171e..170654e2a 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}; @@ -19,7 +20,8 @@ use trusted_server_core::proxy::{ handle_first_party_proxy_sign, }; use trusted_server_core::publisher::{ - buffer_publisher_response, handle_publisher_request, handle_tsjs_dynamic, + AuctionDispatch, 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, @@ -134,6 +136,34 @@ where .unwrap_or_else(|e| http_error(&e))) } +// --------------------------------------------------------------------------- +// EC context +// --------------------------------------------------------------------------- + +/// Builds the geo-aware [`EcContext`] for consent-gated endpoints (`/auction`, +/// `/__ts/page-bids`, and the publisher fallback). +/// +/// Mirrors the Fastly entry point: `EcContext::default()` leaves jurisdiction +/// Unknown, which fails the auction consent gate closed even for consented +/// users. Geo comes from the platform (a no-op on the local Axum dev server, so +/// jurisdiction stays Unknown there unless the request carries TCF consent). A +/// malformed consent string is logged and falls back to the default +/// (fail-closed) context rather than being silently swallowed. +fn build_ec_context(state: &AppState, services: &RuntimeServices, req: &Request) -> EcContext { + let geo_info = services + .geo() + .lookup(services.client_info().client_ip) + .unwrap_or_else(|e| { + log::warn!("geo lookup failed: {e}"); + None + }); + EcContext::read_from_request_with_geo(&state.settings, req, services, geo_info.as_ref()) + .unwrap_or_else(|e| { + log::warn!("EC context read failed: {e:?}"); + EcContext::default() + }) +} + // --------------------------------------------------------------------------- // Fallback dispatcher (tsjs / integration proxy / publisher) // --------------------------------------------------------------------------- @@ -147,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) { @@ -171,9 +201,35 @@ async fn dispatch_fallback( }); } - handle_publisher_request(&state.settings, &state.registry, services, req) - .await - .and_then(|pr| buffer_publisher_response(pr, &method, &state.settings, &state.registry)) + // Run the server-side auction with the configured creative-opportunity + // slots; `handle_publisher_request` matches them against the request path. + let mut ec_context = build_ec_context(state, services, &req); + let auction = AuctionDispatch { + orchestrator: &state.orchestrator, + slots: state.settings.creative_opportunity_slots(), + registry: None, + }; + let publisher_response = handle_publisher_request( + &state.settings, + services, + None, + &mut ec_context, + auction, + req, + EdgeCacheHeader::SMaxageFallback, + ) + .await?; + // Async finalize so the dispatched auction is collected and its bids are + // injected before `` (the sync buffer path would drop them). + buffer_publisher_response_async( + publisher_response, + &method, + &state.settings, + &state.registry, + &state.orchestrator, + services, + ) + .await } fn fallback_handler( @@ -202,6 +258,7 @@ enum NamedRouteHandler { /// reach the publisher fallback (which would leak admin credentials). LegacyAdminDenied, Auction, + PageBids, FirstPartyProxy, FirstPartyClick, FirstPartySign, @@ -224,7 +281,7 @@ const LEGACY_ADMIN_DENY_METHODS: &[Method] = &[ Method::DELETE, ]; -fn named_routes() -> [NamedRoute; 11] { +fn named_routes() -> [NamedRoute; 12] { [ NamedRoute { path: "/.well-known/trusted-server.json", @@ -270,6 +327,13 @@ fn named_routes() -> [NamedRoute; 11] { primary_methods: &[Method::POST], handler: NamedRouteHandler::Auction, }, + // GET runs the SPA re-auction; OPTIONS is denied in-handler as a CORS + // preflight guard for this side-effecting endpoint. + NamedRoute { + path: "/__ts/page-bids", + primary_methods: &[Method::GET, Method::OPTIONS], + handler: NamedRouteHandler::PageBids, + }, NamedRoute { path: "/first-party/proxy", primary_methods: &[Method::GET], @@ -328,7 +392,10 @@ fn named_route_handler( } NamedRouteHandler::LegacyAdminDenied => Ok(legacy_admin_alias_denied()), NamedRouteHandler::Auction => { - let ec_context = EcContext::default(); + // Build the geo-aware EC context so the auction consent + // gate sees the caller's jurisdiction — `EcContext::default()` + // fails it closed for consented users. + let ec_context = build_ec_context(&state, &services, &req); handle_auction( &state.settings, &state.orchestrator, @@ -340,6 +407,30 @@ fn named_route_handler( ) .await } + NamedRouteHandler::PageBids => { + // SPA re-auction endpoint. `OPTIONS` is a CORS preflight + // for this side-effecting GET and is always denied so the + // GET handler's `X-TSJS-Page-Bids` gate stays trustworthy. + if req.method() == Method::OPTIONS { + Ok(page_bids_preflight_denied()) + } else { + let ec_context = build_ec_context(&state, &services, &req); + let auction = AuctionDispatch { + orchestrator: &state.orchestrator, + slots: state.settings.creative_opportunity_slots(), + registry: None, + }; + handle_page_bids( + &state.settings, + &services, + None, + auction, + &ec_context, + req, + ) + .await + } + } NamedRouteHandler::FirstPartyProxy => { handle_first_party_proxy(&state.settings, &services, req).await } diff --git a/crates/trusted-server-adapter-axum/src/middleware.rs b/crates/trusted-server-adapter-axum/src/middleware.rs index 8ad362a97..45cbedc2c 100644 --- a/crates/trusted-server-adapter-axum/src/middleware.rs +++ b/crates/trusted-server-adapter-axum/src/middleware.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use async_trait::async_trait; use edgezero_core::context::RequestContext; use edgezero_core::error::EdgeError; -use edgezero_core::http::{HeaderName, HeaderValue, Response}; +use edgezero_core::http::{HeaderValue, Response}; use edgezero_core::middleware::{Middleware, Next}; use trusted_server_core::auth::enforce_basic_auth; use trusted_server_core::constants::HEADER_X_GEO_INFO_AVAILABLE; @@ -88,26 +88,19 @@ impl Middleware for AuthMiddleware { /// /// Unlike the Fastly variant, geo is always unavailable so `X-Geo-Info-Available: false` /// is unconditionally emitted. Fastly-specific headers are omitted. -/// Operator-configured `settings.response_headers` are applied last and can override -/// any managed header. +/// Operator-configured `settings.response_headers` are applied last (with the +/// shared cookie cache-privacy hardening) and can override any managed header. pub(crate) fn apply_finalize_headers(settings: &Settings, response: &mut Response) { response.headers_mut().insert( HEADER_X_GEO_INFO_AVAILABLE, HeaderValue::from_static("false"), ); - for (key, value) in &settings.response_headers { - let header_name = HeaderName::from_bytes(key.as_bytes()); - let header_value = HeaderValue::from_str(value); - if let (Ok(header_name), Ok(header_value)) = (header_name, header_value) { - response.headers_mut().insert(header_name, header_value); - } else { - log::warn!( - "Skipping invalid configured response header value for {}", - key - ); - } - } + // Cookie-bearing responses stay private to shared caches and operator + // headers cannot re-enable caching for uncacheable per-user payloads. + trusted_server_core::response_privacy::apply_response_headers_with_cache_privacy( + settings, response, + ); } // --------------------------------------------------------------------------- diff --git a/crates/trusted-server-adapter-axum/src/platform.rs b/crates/trusted-server-adapter-axum/src/platform.rs index 2765131dd..461b567d1 100644 --- a/crates/trusted-server-adapter-axum/src/platform.rs +++ b/crates/trusted-server-adapter-axum/src/platform.rs @@ -642,6 +642,7 @@ mod tests { port: None, certificate_check: true, first_byte_timeout: Duration::from_secs(15), + between_bytes_timeout: Duration::from_secs(15), host_header_override: None, }; let name1 = backend.predict_name(&spec).expect("should return a name"); @@ -661,6 +662,7 @@ mod tests { port: None, certificate_check: true, first_byte_timeout: Duration::from_secs(15), + between_bytes_timeout: Duration::from_secs(15), host_header_override: None, }; assert_eq!( diff --git a/crates/trusted-server-adapter-axum/tests/routes.rs b/crates/trusted-server-adapter-axum/tests/routes.rs index c4bf7d990..629c2b0b8 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 0319ab1c9..9bce07d37 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; @@ -21,7 +22,8 @@ use trusted_server_core::proxy::{ handle_first_party_proxy_sign, }; use trusted_server_core::publisher::{ - PublisherResponse, buffer_publisher_response, handle_publisher_request, handle_tsjs_dynamic, + AuctionDispatch, 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_deactivate_key, handle_rotate_key, handle_trusted_server_discovery, @@ -124,6 +126,29 @@ fn build_per_request_services(ctx: &RequestContext) -> RuntimeServices { build_runtime_services(ctx) } +/// Builds the geo-aware [`EcContext`] for consent-gated endpoints (`/auction`, +/// `/__ts/page-bids`, and the publisher fallback). +/// +/// Mirrors the Fastly entry point: `EcContext::default()` leaves jurisdiction +/// Unknown, which fails the auction consent gate closed even for consented +/// users. Geo comes from the Workers `cf` object when deployed. A malformed +/// consent string is logged and falls back to the default (fail-closed) context +/// rather than being silently swallowed. +fn build_ec_context(settings: &Settings, services: &RuntimeServices, req: &Request) -> EcContext { + let geo_info = services + .geo() + .lookup(services.client_info().client_ip) + .unwrap_or_else(|e| { + log::warn!("geo lookup failed: {e}"); + None + }); + EcContext::read_from_request_with_geo(settings, req, services, geo_info.as_ref()) + .unwrap_or_else(|e| { + log::warn!("EC context read failed: {e:?}"); + EcContext::default() + }) +} + // --------------------------------------------------------------------------- // Handler factory // --------------------------------------------------------------------------- @@ -161,16 +186,27 @@ where /// Collapse a [`PublisherResponse`] into a plain [`Response`]. /// -/// Delegates to the shared [`buffer_publisher_response`], which enforces +/// Delegates to the shared [`buffer_publisher_response_async`], which collects +/// the dispatched server-side auction and enforces /// `settings.publisher.max_buffered_body_bytes`, then removes any /// `Transfer-Encoding` header since the buffered body is no longer chunked. -fn resolve_publisher_response( +async fn resolve_publisher_response( publisher_response: PublisherResponse, method: &Method, settings: &Settings, registry: &IntegrationRegistry, + orchestrator: &AuctionOrchestrator, + services: &RuntimeServices, ) -> Result> { - let mut response = buffer_publisher_response(publisher_response, method, settings, registry)?; + let mut response = buffer_publisher_response_async( + publisher_response, + method, + settings, + registry, + orchestrator, + services, + ) + .await?; response.headers_mut().remove(header::TRANSFER_ENCODING); Ok(response) } @@ -319,7 +355,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 @@ -340,11 +380,36 @@ fn build_router(state: &Arc) -> RouterService { })) }) } else { - handle_publisher_request(&state.settings, &state.registry, &services, req) - .await - .and_then(|pr| { - resolve_publisher_response(pr, &method, &state.settings, &state.registry) - }) + let mut ec_context = build_ec_context(&state.settings, &services, &req); + let auction = AuctionDispatch { + orchestrator: &state.orchestrator, + slots: state.settings.creative_opportunity_slots(), + registry: None, + }; + match handle_publisher_request( + &state.settings, + &services, + None, + &mut ec_context, + auction, + req, + EdgeCacheHeader::CloudflareCdnCacheControl, + ) + .await + { + Ok(pr) => { + resolve_publisher_response( + pr, + &method, + &state.settings, + &state.registry, + &state.orchestrator, + &services, + ) + .await + } + Err(e) => Err(e), + } }; Ok(result.unwrap_or_else(|e| http_error(&e))) @@ -398,7 +463,10 @@ fn build_router(state: &Arc) -> RouterService { .post( "/auction", make_handler(Arc::clone(&state), |s, services, req| async move { - let ec_context = EcContext::default(); + // Build the geo-aware EC context so the auction consent gate + // sees the caller's jurisdiction — `EcContext::default()` + // fails it closed for consented users. + let ec_context = build_ec_context(&s.settings, &services, &req); handle_auction( &s.settings, &s.orchestrator, @@ -411,6 +479,28 @@ fn build_router(state: &Arc) -> RouterService { .await }), ) + // SPA re-auction endpoint. The OPTIONS preflight for this + // side-effecting GET is denied so the GET handler's `X-TSJS-Page-Bids` + // gate stays trustworthy. + .route( + "/__ts/page-bids", + Method::OPTIONS, + make_handler(Arc::clone(&state), |_s, _services, _req| async move { + Ok(page_bids_preflight_denied()) + }), + ) + .get( + "/__ts/page-bids", + make_handler(Arc::clone(&state), |s, services, req| async move { + let ec_context = build_ec_context(&s.settings, &services, &req); + let auction = AuctionDispatch { + orchestrator: &s.orchestrator, + slots: s.settings.creative_opportunity_slots(), + registry: None, + }; + handle_page_bids(&s.settings, &services, None, auction, &ec_context, req).await + }), + ) .get( "/first-party/proxy", make_handler(Arc::clone(&state), |s, services, req| async move { diff --git a/crates/trusted-server-adapter-cloudflare/src/middleware.rs b/crates/trusted-server-adapter-cloudflare/src/middleware.rs index 3b60cae3f..5b605bcff 100644 --- a/crates/trusted-server-adapter-cloudflare/src/middleware.rs +++ b/crates/trusted-server-adapter-cloudflare/src/middleware.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use async_trait::async_trait; use edgezero_core::context::RequestContext; use edgezero_core::error::EdgeError; -use edgezero_core::http::{HeaderName, HeaderValue, Response}; +use edgezero_core::http::{HeaderValue, Response}; use edgezero_core::middleware::{Middleware, Next}; use trusted_server_core::auth::enforce_basic_auth; use trusted_server_core::constants::HEADER_X_GEO_INFO_AVAILABLE; @@ -96,8 +96,8 @@ impl Middleware for AuthMiddleware { /// /// `geo_available` controls `X-Geo-Info-Available`; pass `true` when /// `cf-ipcountry` was present and non-`XX` in the incoming request. -/// Operator-configured `settings.response_headers` are applied last and can -/// override any managed header. +/// Operator-configured `settings.response_headers` are applied last (with the +/// shared cookie cache-privacy hardening) and can override any managed header. pub(crate) fn apply_finalize_headers( settings: &Settings, geo_available: bool, @@ -108,18 +108,12 @@ pub(crate) fn apply_finalize_headers( HeaderValue::from_static(if geo_available { "true" } else { "false" }), ); - for (key, value) in &settings.response_headers { - let header_name = HeaderName::from_bytes(key.as_bytes()); - let header_value = HeaderValue::from_str(value); - if let (Ok(header_name), Ok(header_value)) = (header_name, header_value) { - response.headers_mut().insert(header_name, header_value); - } else { - log::warn!( - "Skipping invalid configured response header value for {}", - key - ); - } - } + // Cloudflare is a real shared cache: cookie-bearing responses must stay + // private and operator headers must not re-enable caching for uncacheable + // per-user payloads. + trusted_server_core::response_privacy::apply_response_headers_with_cache_privacy( + settings, response, + ); } // --------------------------------------------------------------------------- diff --git a/crates/trusted-server-adapter-cloudflare/tests/routes.rs b/crates/trusted-server-adapter-cloudflare/tests/routes.rs index 305559be8..d13384619 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 ee0e94185..89662e1c9 100644 --- a/crates/trusted-server-adapter-fastly/src/app.rs +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -95,6 +95,7 @@ use edgezero_core::router::RouterService; use error_stack::Report; use trusted_server_core::auction::endpoints::handle_auction; 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; @@ -115,7 +116,8 @@ use trusted_server_core::proxy::{ handle_first_party_proxy_rebuild, handle_first_party_proxy_sign, AssetProxyCachePolicy, }; use trusted_server_core::publisher::{ - buffer_publisher_response, handle_publisher_request, handle_tsjs_dynamic, + buffer_publisher_response_async, handle_page_bids, handle_publisher_request, + handle_tsjs_dynamic, page_bids_preflight_denied, AuctionDispatch, }; use trusted_server_core::request_signing::{ handle_deactivate_key, handle_rotate_key, handle_trusted_server_discovery, @@ -603,6 +605,38 @@ async fn run_named_route( ) .await } + NamedRouteHandler::PageBids => { + // SPA re-auction endpoint. `OPTIONS` is a CORS preflight for this + // side-effecting GET and is always denied so the GET handler's + // `X-TSJS-Page-Bids` gate stays trustworthy. + if req.method() == Method::OPTIONS { + return Ok(page_bids_preflight_denied()); + } + // Like the auction, page-bids reads consent data, so the consent KV + // store must be available — fail closed with 503 when configured but + // unopenable, matching legacy. + let consent_services = runtime_services_for_consent_route(&state.settings, services)?; + let partner_registry = PartnerRegistry::from_config(&state.settings.ec.partners)?; + let registry_ref = if partner_registry.is_empty() { + None + } else { + Some(&partner_registry) + }; + let auction = AuctionDispatch { + orchestrator: &state.orchestrator, + slots: state.settings.creative_opportunity_slots(), + registry: registry_ref, + }; + handle_page_bids( + &state.settings, + &consent_services, + ec.kv_graph.as_ref(), + auction, + &ec.ec_context, + req, + ) + .await + } NamedRouteHandler::FirstPartyProxy => { handle_first_party_proxy(&state.settings, services, req).await } @@ -681,7 +715,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 @@ -734,16 +768,48 @@ async fn dispatch_fallback( // be opened, matching legacy behavior. match runtime_services_for_consent_route(&state.settings, services) { Ok(publisher_services) => { - handle_publisher_request(&state.settings, &state.registry, &publisher_services, req) - .await - .and_then(|pub_response| { - buffer_publisher_response( - pub_response, - &method, + // Run the server-side auction with the configured creative- + // opportunity slots and collect the dispatched bids in the + // buffered finalize (`buffer_publisher_response_async`), matching + // the legacy streaming path. `handle_publisher_request` matches the + // slots against the request path. The partner registry plus the + // EC identity-graph KV (`ec.kv_graph`) enrich the bid request with + // server-side EIDs, same as the legacy auction. + let slots = state.settings.creative_opportunity_slots(); + match PartnerRegistry::from_config(&state.settings.ec.partners) { + Ok(partner_registry) => { + let auction = AuctionDispatch { + orchestrator: &state.orchestrator, + slots, + registry: Some(&partner_registry), + }; + match handle_publisher_request( &state.settings, - &state.registry, + &publisher_services, + ec.kv_graph.as_ref(), + &mut ec.ec_context, + auction, + req, + EdgeCacheHeader::SurrogateControl, ) - }) + .await + { + Ok(pub_response) => { + buffer_publisher_response_async( + pub_response, + &method, + &state.settings, + &state.registry, + &state.orchestrator, + &publisher_services, + ) + .await + } + Err(e) => Err(e), + } + } + Err(e) => Err(e), + } } Err(e) => Err(e), } @@ -926,6 +992,7 @@ enum NamedRouteHandler { SetTester, ClearTester, Auction, + PageBids, FirstPartyProxy, FirstPartyClick, FirstPartySign, @@ -1010,6 +1077,13 @@ const NAMED_ROUTES: &[NamedRoute] = &[ primary_methods: &[Method::POST], handler: NamedRouteHandler::Auction, }, + // GET runs the SPA re-auction; OPTIONS is denied in-handler as a CORS + // preflight guard for this side-effecting endpoint. + NamedRoute { + path: "/__ts/page-bids", + primary_methods: &[Method::GET, Method::OPTIONS], + handler: NamedRouteHandler::PageBids, + }, NamedRoute { path: "/first-party/proxy", primary_methods: &[Method::GET], diff --git a/crates/trusted-server-adapter-fastly/src/backend.rs b/crates/trusted-server-adapter-fastly/src/backend.rs index 7763eaf0e..4056c81da 100644 --- a/crates/trusted-server-adapter-fastly/src/backend.rs +++ b/crates/trusted-server-adapter-fastly/src/backend.rs @@ -49,6 +49,8 @@ fn sanitize_backend_name_component(value: &str) -> String { /// Default first-byte timeout for backends (15 seconds). pub(crate) const DEFAULT_FIRST_BYTE_TIMEOUT: Duration = Duration::from_secs(15); +/// Default timeout between response body bytes for backends (10 seconds). +pub(crate) const DEFAULT_BETWEEN_BYTES_TIMEOUT: Duration = Duration::from_secs(10); /// Configuration for creating a dynamic Fastly backend. /// @@ -60,6 +62,7 @@ pub struct BackendConfig<'a> { port: Option, certificate_check: bool, first_byte_timeout: Duration, + between_bytes_timeout: Duration, host_header_override: Option<&'a str>, } @@ -76,6 +79,7 @@ impl<'a> BackendConfig<'a> { port: None, certificate_check: true, first_byte_timeout: DEFAULT_FIRST_BYTE_TIMEOUT, + between_bytes_timeout: DEFAULT_BETWEEN_BYTES_TIMEOUT, host_header_override: None, } } @@ -106,6 +110,17 @@ impl<'a> BackendConfig<'a> { self } + /// Set the maximum time to wait between response body bytes. + /// + /// Defaults to 10 seconds. Auction backends should set this to the same + /// remaining budget as the first-byte timeout so slow-drip bodies cannot + /// hold the auction past its deadline. + #[must_use] + pub fn between_bytes_timeout(mut self, timeout: Duration) -> Self { + self.between_bytes_timeout = timeout; + self + } + /// Set the outbound Host header sent to the backend origin. #[must_use] pub fn host_header_override(mut self, host: Option<&'a str>) -> Self { @@ -159,13 +174,15 @@ impl<'a> BackendConfig<'a> { } else { "_nocert" }; - let timeout_ms = self.first_byte_timeout.as_millis(); + let first_byte_timeout_ms = self.first_byte_timeout.as_millis(); + let between_bytes_timeout_ms = self.between_bytes_timeout.as_millis(); let backend_name = format!( - "backend_{}{}{}_t{}", + "backend_{}{}{}_fb{}_bb{}", sanitize_backend_name_component(&name_base), host_override_suffix, cert_suffix, - timeout_ms + first_byte_timeout_ms, + between_bytes_timeout_ms ); Ok((backend_name, target_port)) @@ -187,9 +204,10 @@ impl<'a> BackendConfig<'a> { /// Ensure a dynamic backend exists for this configuration and return its name. /// /// The backend name is derived from the scheme, host, port, certificate - /// setting, and `first_byte_timeout` to avoid collisions. Different - /// timeout values produce different backend registrations so that a - /// tight deadline cannot be silently widened by an earlier registration. + /// setting, `first_byte_timeout`, and `between_bytes_timeout` to avoid + /// collisions. Different timeout values produce different backend + /// registrations so that a tight deadline cannot be silently widened by an + /// earlier registration. /// /// # Errors /// @@ -210,7 +228,7 @@ impl<'a> BackendConfig<'a> { .override_host(&host_header) .connect_timeout(Duration::from_secs(1)) .first_byte_timeout(self.first_byte_timeout) - .between_bytes_timeout(Duration::from_secs(10)); + .between_bytes_timeout(self.between_bytes_timeout); if self.scheme.eq_ignore_ascii_case("https") { builder = builder.enable_ssl().sni_hostname(self.host); if self.certificate_check { @@ -381,7 +399,7 @@ mod tests { let name = BackendConfig::new("https", "origin.example.com") .ensure() .expect("should create backend for valid HTTPS origin"); - assert_eq!(name, "backend_https_origin_example_com_443_t15000"); + assert_eq!(name, "backend_https_origin_example_com_443_fb15000_bb10000"); } #[test] @@ -390,7 +408,10 @@ mod tests { .certificate_check(false) .ensure() .expect("should create backend with cert check disabled"); - assert_eq!(name, "backend_https_origin_example_com_443_nocert_t15000"); + assert_eq!( + name, + "backend_https_origin_example_com_443_nocert_fb15000_bb10000" + ); } #[test] @@ -399,7 +420,7 @@ mod tests { .port(Some(8080)) .ensure() .expect("should create backend for HTTP origin with explicit port"); - assert_eq!(name, "backend_http_api_test-site_org_8080_t15000"); + assert_eq!(name, "backend_http_api_test-site_org_8080_fb15000_bb10000"); } #[test] @@ -407,7 +428,7 @@ mod tests { let name = BackendConfig::new("http", "example.org") .ensure() .expect("should create backend defaulting to port 80 for HTTP"); - assert_eq!(name, "backend_http_example_org_80_t15000"); + assert_eq!(name, "backend_http_example_org_80_fb15000_bb10000"); } #[test] @@ -464,11 +485,11 @@ mod tests { ); assert_eq!( name_a, - "backend_https_origin_example_com_443_oh_www_example_com_t15000" + "backend_https_origin_example_com_443_oh_www_example_com_fb15000_bb10000" ); assert_eq!( name_b, - "backend_https_origin_example_com_443_oh_m_example_com_t15000" + "backend_https_origin_example_com_443_oh_m_example_com_fb15000_bb10000" ); } @@ -523,12 +544,39 @@ mod tests { "backends with different timeouts should have different names" ); assert!( - name_a.ends_with("_t2000"), - "name should include timeout suffix" + name_a.ends_with("_fb2000_bb10000"), + "name should include first-byte and between-bytes timeout suffix" + ); + assert!( + name_b.ends_with("_fb500_bb10000"), + "name should include first-byte and between-bytes timeout suffix" + ); + } + + #[test] + fn different_between_bytes_timeouts_produce_different_names() { + use std::time::Duration; + + let (name_a, _) = BackendConfig::new("https", "origin.example.com") + .between_bytes_timeout(Duration::from_secs(2)) + .compute_name() + .expect("should compute name with 2000ms between-bytes timeout"); + let (name_b, _) = BackendConfig::new("https", "origin.example.com") + .between_bytes_timeout(Duration::from_millis(500)) + .compute_name() + .expect("should compute name with 500ms between-bytes timeout"); + + assert_ne!( + name_a, name_b, + "backends with different between-bytes timeouts should have different names" + ); + assert!( + name_a.ends_with("_fb15000_bb2000"), + "name should include first-byte and between-bytes timeout suffix" ); assert!( - name_b.ends_with("_t500"), - "name should include timeout suffix" + name_b.ends_with("_fb15000_bb500"), + "name should include first-byte and between-bytes timeout suffix" ); } } diff --git a/crates/trusted-server-adapter-fastly/src/main.rs b/crates/trusted-server-adapter-fastly/src/main.rs index 489c4982d..ac0d36490 100644 --- a/crates/trusted-server-adapter-fastly/src/main.rs +++ b/crates/trusted-server-adapter-fastly/src/main.rs @@ -19,6 +19,7 @@ use fastly::{ use trusted_server_core::auction::endpoints::handle_auction; use trusted_server_core::auction::AuctionOrchestrator; use trusted_server_core::auth::enforce_basic_auth; +use trusted_server_core::cache_policy::{cache_control_value_has_directive, 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; @@ -46,7 +47,7 @@ use trusted_server_core::proxy::{ AssetProxyCachePolicy, }; use trusted_server_core::publisher::{ - handle_publisher_request, handle_tsjs_dynamic, stream_publisher_body, + handle_page_bids, handle_publisher_request, handle_tsjs_dynamic, stream_publisher_body_async, OwnedProcessResponseParams, PublisherResponse, }; use trusted_server_core::request_signing::{ @@ -94,7 +95,7 @@ enum HandlerOutcome { Streaming { response: HttpResponse, body: EdgeBody, - params: OwnedProcessResponseParams, + params: Box, }, AssetStreaming { response: HttpResponse, @@ -539,7 +540,7 @@ fn edgezero_main(mut req: FastlyRequest, config_store: ConfigStoreHandle) { // Reapply protected asset cache directives after finalization, mirroring // legacy_main. A no-op for OriginControlled responses. if let Some(policy) = asset_cache_policy { - policy.apply_after_route_finalization(&mut response); + policy.apply_after_route_finalization(&mut response, EdgeCacheHeader::SurrogateControl); } // EC response lifecycle, mirroring legacy_main: finalize EC cookies and @@ -680,6 +681,11 @@ fn send_edgezero_response( effects.apply_to_response(&mut response); } + // Final cache guard: EC finalization and request-filter effects may have + // added a per-user Set-Cookie after `apply_finalize_headers` ran, so + // re-apply the privacy downgrade before send, mirroring legacy_main. + crate::middleware::enforce_set_cookie_cache_privacy(&mut response); + let (parts, body) = response.into_parts(); match body { EdgeBody::Stream(_) => { @@ -771,6 +777,7 @@ fn legacy_main(mut req: FastlyRequest) { &state.registry, &partner_registry, &runtime_services, + state.settings.creative_opportunity_slots(), http_req, device_signals, )) @@ -816,7 +823,8 @@ fn legacy_main(mut req: FastlyRequest) { match outcome { HandlerOutcome::Buffered(mut response) | HandlerOutcome::AuthChallenge(mut response) => { finalize_response(&state.settings, geo_info.as_ref(), &mut response); - asset_cache_policy.apply_after_route_finalization(&mut response); + asset_cache_policy + .apply_after_route_finalization(&mut response, EdgeCacheHeader::SurrogateControl); if should_finalize_ec { ec_finalize_response( &state.settings, @@ -828,8 +836,14 @@ fn legacy_main(mut req: FastlyRequest) { &mut response, ); } + // Apply request-filter response effects (e.g. a DataDome allow + // Set-Cookie) before the final cache guard so any per-user cookie + // they add is covered. EC finalization above may also have added the + // identity Set-Cookie; the guard runs last so it observes both. request_filter_effects.apply_to_response(&mut response); - compat::to_fastly_response(response).send_to_client(); + let mut fastly_resp = compat::to_fastly_response(response); + enforce_set_cookie_cache_privacy(&mut fastly_resp); + fastly_resp.send_to_client(); if is_real_browser { if let Some(context) = build_pull_sync_context(&ec_context) { @@ -845,10 +859,11 @@ fn legacy_main(mut req: FastlyRequest) { HandlerOutcome::Streaming { mut response, body, - params, + mut params, } => { finalize_response(&state.settings, geo_info.as_ref(), &mut response); - asset_cache_policy.apply_after_route_finalization(&mut response); + asset_cache_policy + .apply_after_route_finalization(&mut response, EdgeCacheHeader::SurrogateControl); if should_finalize_ec { ec_finalize_response( &state.settings, @@ -860,17 +875,24 @@ fn legacy_main(mut req: FastlyRequest) { &mut response, ); } + // Apply request-filter response effects (e.g. a DataDome allow + // Set-Cookie) before the final cache guard so any per-user cookie + // they add is covered. EC finalization above may also have added the + // identity Set-Cookie; the guard runs last so it observes both. request_filter_effects.apply_to_response(&mut response); - let fastly_resp = compat::to_fastly_response_skeleton(response); + let mut fastly_resp = compat::to_fastly_response_skeleton(response); + enforce_set_cookie_cache_privacy(&mut fastly_resp); let mut streaming_body = fastly_resp.stream_to_client(); let mut stream_succeeded = false; - match stream_publisher_body( + match futures::executor::block_on(stream_publisher_body_async( body, &mut streaming_body, - ¶ms, + &mut params, &state.settings, &state.registry, - ) { + &state.orchestrator, + &runtime_services, + )) { Ok(()) => { if let Err(e) = streaming_body.finish() { log::error!("failed to finish streaming body: {e}"); @@ -899,9 +921,14 @@ fn legacy_main(mut req: FastlyRequest) { } HandlerOutcome::AssetStreaming { mut response, body } => { finalize_response(&state.settings, geo_info.as_ref(), &mut response); - asset_cache_policy.apply_after_route_finalization(&mut response); + asset_cache_policy + .apply_after_route_finalization(&mut response, EdgeCacheHeader::SurrogateControl); + // A request filter (e.g. DataDome allow) can append a per-user + // Set-Cookie via response effects even on an otherwise cacheable + // asset, so guard against shared caching after applying them. request_filter_effects.apply_to_response(&mut response); - let fastly_resp = compat::to_fastly_response_skeleton(response); + let mut fastly_resp = compat::to_fastly_response_skeleton(response); + enforce_set_cookie_cache_privacy(&mut fastly_resp); let mut streaming_body = fastly_resp.stream_to_client(); if let Err(e) = futures::executor::block_on(stream_asset_body(body, &mut streaming_body)) @@ -963,12 +990,18 @@ fn build_ja4_debug_response(req: &FastlyRequest) -> FastlyResponse { .with_body(body) } +// Combines the server-side ad-stack inputs (creative-opportunity `slots`) with +// the EdgeZero dual-path requirement that `device_signals` be derived from the +// `FastlyRequest` before conversion and passed in, pushing this central +// dispatch helper to eight arguments. +#[allow(clippy::too_many_arguments)] async fn route_request( settings: &Settings, orchestrator: &AuctionOrchestrator, integration_registry: &IntegrationRegistry, partner_registry: &PartnerRegistry, runtime_services: &RuntimeServices, + slots: &[trusted_server_core::creative_opportunities::CreativeOpportunitySlot], mut req: HttpRequest, device_signals: DeviceSignals, ) -> Result> { @@ -1142,15 +1175,26 @@ async fn route_request( let path = req.uri().path().to_string(); let method = req.method().clone(); + let registry_ref = if partner_registry.is_empty() { + None + } else { + Some(partner_registry) + }; + let mut asset_cache_policy = AssetProxyCachePolicy::OriginControlled; let mut should_finalize_ec = true; // Match known routes and handle them let (result, organic_route) = match (method, path.as_str()) { // Serve the tsjs library - (Method::GET, path) if path.starts_with("/static/tsjs=") => { - (handle_tsjs_dynamic(&req, integration_registry), false) - } + (Method::GET, path) if path.starts_with("/static/tsjs=") => ( + handle_tsjs_dynamic( + &req, + integration_registry, + EdgeCacheHeader::SurrogateControl, + ), + false, + ), // Discovery endpoint for trusted-server capabilities and JWKS (Method::GET, "/.well-known/trusted-server.json") => ( @@ -1202,28 +1246,55 @@ async fn route_request( (Method::GET, "/_ts/set-tester") => (handle_set_tester(settings), false), (Method::GET, "/_ts/clear-tester") => (handle_clear_tester(settings), false), - // Unified auction endpoint. - (Method::POST, "/auction") => { - let registry_ref = if partner_registry.is_empty() { - None - } else { - Some(partner_registry) - }; - ( - handle_auction( - settings, - orchestrator, - kv_graph.as_ref(), - registry_ref, - &ec_context, - runtime_services, - req, - ) - .await, - false, + // Unified auction endpoint (returns creative HTML inline) + (Method::POST, "/auction") => ( + handle_auction( + settings, + orchestrator, + kv_graph.as_ref(), + registry_ref, + &ec_context, + runtime_services, + req, ) + .await, + false, + ), + + // Reject CORS preflight for the side-effecting page-bids endpoint at the + // adapter. The GET handler's legacy fallback trusts `X-TSJS-Page-Bids` + // precisely because this endpoint never grants a preflight; letting + // OPTIONS fall through to the publisher origin (which may return + // permissive CORS) would defeat that, allowing a cross-site page to + // trigger real PBS/APS auctions from a visitor's browser. + (Method::OPTIONS, "/__ts/page-bids") => { + let mut response = HttpResponse::new(EdgeBody::from("Forbidden")); + *response.status_mut() = edgezero_core::http::StatusCode::FORBIDDEN; + response.headers_mut().insert( + header::CACHE_CONTROL, + HeaderValue::from_static("private, no-store"), + ); + (Ok(response), false) } + // SPA/CSR navigation endpoint — returns slots + bids JSON for the given path + (Method::GET, "/__ts/page-bids") => ( + handle_page_bids( + settings, + runtime_services, + kv_graph.as_ref(), + trusted_server_core::publisher::AuctionDispatch { + orchestrator, + slots, + registry: registry_ref, + }, + &ec_context, + req, + ) + .await, + false, + ), + // First-party proxy/click/sign/rebuild endpoints (Method::GET, "/first-party/proxy") => ( handle_first_party_proxy(settings, runtime_services, req).await, @@ -1315,9 +1386,16 @@ async fn route_request( match handle_publisher_request( settings, - integration_registry, runtime_services, + kv_graph.as_ref(), + &mut ec_context, + trusted_server_core::publisher::AuctionDispatch { + orchestrator, + slots, + registry: registry_ref, + }, req, + EdgeCacheHeader::SurrogateControl, ) .await { @@ -1410,9 +1488,48 @@ fn run_pull_sync_after_send( /// version/staging, then operator-configured `settings.response_headers`. /// This means operators can intentionally override any managed header. fn finalize_response(settings: &Settings, geo_info: Option<&GeoInfo>, response: &mut HttpResponse) { + // Legacy and EdgeZero paths share one protected finalizer so the cache / + // Set-Cookie privacy hardening cannot drift between them. `HttpResponse` and + // the middleware's `Response` are the same `edgezero_core::http::Response`. apply_finalize_headers(settings, geo_info, response); } +/// Forces cookie-bearing Fastly responses to stay private to shared caches. +/// +/// [`finalize_response`] applies this same downgrade on the [`HttpResponse`], +/// but the EC identity cookie is written later by [`ec_finalize_response`] onto +/// the converted [`FastlyResponse`], so the earlier guard never sees it. +/// Re-apply it here so a first-visit navigation whose only per-user payload is +/// the EC `Set-Cookie` can never be served with `public`/surrogate cache headers +/// inherited from the origin or operator response headers — a shared cache must +/// not be able to store and replay one visitor's EC cookie to others. +/// +/// Idempotent: a response already marked `private`/`no-store` keeps its stricter +/// `Cache-Control`, but the surrogate cache headers are stripped regardless so a +/// `no-store` cookie response can never retain shared Fastly cacheability. +fn enforce_set_cookie_cache_privacy(response: &mut FastlyResponse) { + if response.get_header("set-cookie").is_none() { + return; + } + // Strip surrogate cache headers on every cookie-bearing response, even when + // keeping a stricter `no-store`/`private` directive — Surrogate-Control is + // independent of Cache-Control and would otherwise let a shared cache store + // and replay one visitor's Set-Cookie. + for name in crate::middleware::SURROGATE_CACHE_HEADERS { + response.remove_header(*name); + } + let already_uncacheable = response.get_headers().any(|(name, value)| { + name.as_str().eq_ignore_ascii_case("cache-control") + && value.to_str().ok().is_some_and(|value| { + cache_control_value_has_directive(value, "private") + || cache_control_value_has_directive(value, "no-store") + }) + }); + if !already_uncacheable { + response.set_header("cache-control", "private, max-age=0"); + } +} + /// Builds the local `404 Not Found` returned for legacy `/admin/keys/*` /// aliases. /// diff --git a/crates/trusted-server-adapter-fastly/src/middleware.rs b/crates/trusted-server-adapter-fastly/src/middleware.rs index ceb470b7d..238afa9eb 100644 --- a/crates/trusted-server-adapter-fastly/src/middleware.rs +++ b/crates/trusted-server-adapter-fastly/src/middleware.rs @@ -16,7 +16,7 @@ use async_trait::async_trait; use edgezero_adapter_fastly::context::FastlyRequestContext; use edgezero_core::context::RequestContext; use edgezero_core::error::EdgeError; -use edgezero_core::http::{HeaderName, HeaderValue, Response, StatusCode}; +use edgezero_core::http::{HeaderValue, Response, StatusCode}; use edgezero_core::middleware::{Middleware, Next}; use edgezero_core::response::IntoResponse; use std::net::IpAddr; @@ -181,13 +181,19 @@ where /// Applies all standard Trusted Server response headers to the given response. /// /// Mirrors [`crate::finalize_response`] exactly, operating on [`Response`] from -/// `edgezero_core::http` instead of `HttpResponse`. +/// `edgezero_core::http` instead of `HttpResponse`. [`crate::finalize_response`] +/// delegates here so the legacy and `EdgeZero` paths share one protected +/// finalizer. /// /// Header write order (last write wins): /// 1. Geo headers (`x-geo-*`) — or `X-Geo-Info-Available: false` when absent /// 2. `X-TS-Version` from `FASTLY_SERVICE_VERSION` env var /// 3. `X-TS-ENV: staging` when `FASTLY_IS_STAGING == "1"` -/// 4. `settings.response_headers` — operator-configured overrides applied last +/// 4. Set-Cookie cache privacy — strip surrogate cache headers and downgrade +/// `Cache-Control` to `private, max-age=0` on cookie-bearing responses +/// 5. `settings.response_headers` — operator-configured overrides, except the +/// cache-controlling headers are skipped on uncacheable (`private`/`no-store`) +/// responses so operators cannot re-enable shared caching for per-user payloads pub(crate) fn apply_finalize_headers( settings: &Settings, geo_info: Option<&GeoInfo>, @@ -216,16 +222,31 @@ pub(crate) fn apply_finalize_headers( .insert(HEADER_X_TS_ENV, HeaderValue::from_static("staging")); } - for (key, value) in &settings.response_headers { - let header_name = HeaderName::from_bytes(key.as_bytes()) - .expect("should be a valid header name: response_headers validated in prepare_runtime"); - let header_value = HeaderValue::from_str(value).expect( - "should be a valid header value: response_headers validated in prepare_runtime", - ); - response.headers_mut().insert(header_name, header_value); - } + // Any response that sets a per-user cookie (notably the EC identity cookie) + // must never be shared-cached, and per-user responses (assembled HTML, + // page-bids, cookie-bearing navigations) must not have their uncacheable + // Cache-Control re-enabled by operator headers. This shared helper runs + // byte-identically on every adapter so the privacy guarantee can't drift. + trusted_server_core::response_privacy::apply_response_headers_with_cache_privacy( + settings, response, + ); } +/// Surrogate cache headers stripped from every cookie-bearing response. +/// +/// Re-exported from [`trusted_server_core::response_privacy`] so the legacy +/// [`crate::enforce_set_cookie_cache_privacy`] `FastlyResponse` variant and the +/// shared [`Response`] downgrade cannot drift apart. +pub(crate) use trusted_server_core::response_privacy::SURROGATE_CACHE_HEADERS; + +/// Forces cookie-bearing responses to stay private to shared caches. +/// +/// Re-exported from [`trusted_server_core::response_privacy`] so the `EdgeZero` +/// entry point (`main.rs`) can re-apply it after +/// [`ec_finalize_response`](trusted_server_core::ec::finalize::ec_finalize_response) +/// writes the EC identity `Set-Cookie`, using the single shared implementation. +pub(crate) use trusted_server_core::response_privacy::enforce_set_cookie_cache_privacy; + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -241,7 +262,7 @@ mod tests { use edgezero_core::body::Body; use edgezero_core::context::RequestContext; use edgezero_core::error::EdgeError; - use edgezero_core::http::{request_builder, response_builder, Method, StatusCode}; + use edgezero_core::http::{request_builder, response_builder, HeaderName, Method, StatusCode}; use edgezero_core::middleware::Next; use edgezero_core::params::PathParams; use error_stack::Report; @@ -342,6 +363,149 @@ mod tests { ); } + fn response_with_headers(pairs: &[(&'static str, &'static str)]) -> Response { + let mut response = empty_response(); + for (key, value) in pairs { + response.headers_mut().insert( + HeaderName::from_static(key), + HeaderValue::from_static(value), + ); + } + response + } + + #[test] + fn apply_finalize_headers_downgrades_public_set_cookie_response() { + // A per-user cookie response that arrives shared-cacheable (origin-public + // plus a surrogate directive) must be downgraded so a shared cache cannot + // store and replay one visitor's Set-Cookie. + let settings = settings_with_response_headers(vec![]); + let mut response = response_with_headers(&[ + ("set-cookie", "ts-ec=abc; Path=/"), + ("cache-control", "public, max-age=600"), + ("surrogate-control", "max-age=600"), + ]); + + apply_finalize_headers(&settings, None, &mut response); + + assert_eq!( + response + .headers() + .get("cache-control") + .and_then(|v| v.to_str().ok()), + Some("private, max-age=0"), + "should downgrade a public cookie response to private" + ); + assert!( + response.headers().get("surrogate-control").is_none(), + "should strip surrogate-control from a cookie-bearing response" + ); + } + + #[test] + fn apply_finalize_headers_blocks_operator_surrogate_on_private_response() { + // Operator response_headers must not re-enable shared caching for an + // uncacheable (private) per-user response — neither by replacing + // Cache-Control nor by reintroducing surrogate cache headers. + let settings = settings_with_response_headers(vec![ + ("cache-control", "public, max-age=3600"), + ("surrogate-control", "max-age=3600"), + ("x-operator", "kept"), + ]); + let mut response = response_with_headers(&[("cache-control", "private, max-age=0")]); + + apply_finalize_headers(&settings, None, &mut response); + + assert_eq!( + response + .headers() + .get("cache-control") + .and_then(|v| v.to_str().ok()), + Some("private, max-age=0"), + "operator cache-control must not weaken a private response" + ); + assert!( + response.headers().get("surrogate-control").is_none(), + "operator surrogate-control must not be applied to a private response" + ); + assert_eq!( + response + .headers() + .get("x-operator") + .and_then(|v| v.to_str().ok()), + Some("kept"), + "non-cache operator headers must still apply" + ); + } + + #[test] + fn enforce_set_cookie_cache_privacy_downgrades_late_cookie() { + // Mirrors the EdgeZero post-ec_finalize guard: a Set-Cookie added after + // finalize headers ran (origin-public response) must be downgraded. + let mut response = response_with_headers(&[ + ("set-cookie", "ts-ec=abc; Path=/"), + ("cache-control", "public, max-age=600"), + ("surrogate-control", "max-age=600"), + ]); + + enforce_set_cookie_cache_privacy(&mut response); + + assert_eq!( + response + .headers() + .get("cache-control") + .and_then(|v| v.to_str().ok()), + Some("private, max-age=0"), + "should downgrade a late public cookie response to private" + ); + assert!( + response.headers().get("surrogate-control").is_none(), + "should strip surrogate-control from the late cookie response" + ); + } + + #[test] + fn enforce_set_cookie_cache_privacy_keeps_stricter_no_store() { + // Idempotent: a stricter no-store directive is preserved, but surrogate + // headers still come off. + let mut response = response_with_headers(&[ + ("set-cookie", "ts-ec=abc; Path=/"), + ("cache-control", "no-store"), + ("surrogate-control", "max-age=600"), + ]); + + enforce_set_cookie_cache_privacy(&mut response); + + assert_eq!( + response + .headers() + .get("cache-control") + .and_then(|v| v.to_str().ok()), + Some("no-store"), + "should keep the stricter no-store directive" + ); + assert!( + response.headers().get("surrogate-control").is_none(), + "should strip surrogate-control even when keeping no-store" + ); + } + + #[test] + fn enforce_set_cookie_cache_privacy_ignores_cookieless_response() { + let mut response = response_with_headers(&[("cache-control", "public, max-age=600")]); + + enforce_set_cookie_cache_privacy(&mut response); + + assert_eq!( + response + .headers() + .get("cache-control") + .and_then(|v| v.to_str().ok()), + Some("public, max-age=600"), + "should leave a cookieless response untouched" + ); + } + // --------------------------------------------------------------------------- // FinalizeResponseMiddleware::handle tests // --------------------------------------------------------------------------- diff --git a/crates/trusted-server-adapter-fastly/src/platform.rs b/crates/trusted-server-adapter-fastly/src/platform.rs index 9b1a73422..935c957ea 100644 --- a/crates/trusted-server-adapter-fastly/src/platform.rs +++ b/crates/trusted-server-adapter-fastly/src/platform.rs @@ -159,6 +159,7 @@ fn backend_config_from_spec(spec: &PlatformBackendSpec) -> BackendConfig<'_> { .host_header_override(spec.host_header_override.as_deref()) .certificate_check(spec.certificate_check) .first_byte_timeout(spec.first_byte_timeout) + .between_bytes_timeout(spec.between_bytes_timeout) } impl PlatformBackend for FastlyPlatformBackend { @@ -676,6 +677,7 @@ mod tests { host_header_override: None, certificate_check: true, first_byte_timeout: Duration::from_secs(15), + between_bytes_timeout: Duration::from_secs(15), }; let name = backend @@ -683,7 +685,7 @@ mod tests { .expect("should compute backend name for valid spec"); assert_eq!( - name, "backend_https_origin_example_com_443_t15000", + name, "backend_https_origin_example_com_443_fb15000_bb15000", "should match BackendConfig naming convention" ); } @@ -698,6 +700,7 @@ mod tests { host_header_override: Some("www.example.com".to_string()), certificate_check: true, first_byte_timeout: Duration::from_secs(15), + between_bytes_timeout: Duration::from_secs(15), }; let name = backend @@ -705,7 +708,7 @@ mod tests { .expect("should compute backend name for host header override"); assert_eq!( - name, "backend_https_origin_example_com_443_oh_www_example_com_t15000", + name, "backend_https_origin_example_com_443_oh_www_example_com_fb15000_bb15000", "should match BackendConfig naming convention with host header override" ); } @@ -720,6 +723,7 @@ mod tests { host_header_override: None, certificate_check: false, first_byte_timeout: Duration::from_secs(15), + between_bytes_timeout: Duration::from_secs(15), }; let name = backend @@ -742,6 +746,7 @@ mod tests { host_header_override: None, certificate_check: true, first_byte_timeout: Duration::from_secs(15), + between_bytes_timeout: Duration::from_secs(15), }; let result = backend.predict_name(&spec); @@ -759,6 +764,7 @@ mod tests { host_header_override: None, certificate_check: true, first_byte_timeout: Duration::from_millis(2000), + between_bytes_timeout: Duration::from_millis(2000), }; let name = backend @@ -766,8 +772,8 @@ mod tests { .expect("should compute name with custom timeout"); assert!( - name.ends_with("_t2000"), - "should encode 2000ms timeout in name" + name.ends_with("_fb2000_bb2000"), + "should encode 2000ms first-byte and between-bytes timeouts in name" ); } diff --git a/crates/trusted-server-adapter-fastly/src/route_tests.rs b/crates/trusted-server-adapter-fastly/src/route_tests.rs index ce542a7c0..ffaba67eb 100644 --- a/crates/trusted-server-adapter-fastly/src/route_tests.rs +++ b/crates/trusted-server-adapter-fastly/src/route_tests.rs @@ -15,6 +15,7 @@ use trusted_server_core::auction::{ build_orchestrator, AuctionContext, AuctionOrchestrator, AuctionProvider, AuctionRequest, AuctionResponse, }; +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::registry::PartnerRegistry; @@ -35,6 +36,16 @@ use trusted_server_core::settings::{ use super::{route_request, HandlerOutcome}; +#[test] +fn streaming_publisher_path_uses_async_auction_collector() { + let router_source = include_str!("main.rs"); + + assert!( + router_source.contains("stream_publisher_body_async("), + "streaming publisher responses must collect dispatched auctions before injection" + ); +} + struct StubJwksConfigStore; impl PlatformConfigStore for StubJwksConfigStore { @@ -376,6 +387,23 @@ pub(crate) fn us_california_geo() -> GeoInfo { } } +/// Geo resolving to a non-regulated jurisdiction, so the server-side auction +/// consent gate (which fails closed for GDPR/unknown jurisdictions without TCF +/// Purpose 1) allows the auction to proceed. Used by `/auction` route tests +/// that exercise orchestration behavior rather than consent. +fn non_regulated_geo() -> GeoInfo { + GeoInfo { + city: "Example City".to_string(), + country: "AU".to_string(), + continent: "OC".to_string(), + latitude: -33.8, + longitude: 151.2, + metro_code: 0, + region: Some("NSW".to_string()), + asn: None, + } +} + fn valid_ec_id() -> String { format!("{}.Abc123", "a".repeat(64)) } @@ -602,7 +630,8 @@ fn route_result_to_fastly_response( .unwrap_or(None) }; super::finalize_response(settings, geo_info.as_ref(), &mut response); - asset_cache_policy.apply_after_route_finalization(&mut response); + asset_cache_policy + .apply_after_route_finalization(&mut response, EdgeCacheHeader::SurrogateControl); if should_finalize_ec { ec_finalize_response( @@ -615,8 +644,12 @@ fn route_result_to_fastly_response( &mut response, ); } + // Apply request-filter response effects (which may append a per-user + // Set-Cookie) before the final cache guard so the guard observes them. request_filter_effects.apply_to_response(&mut response); - compat::to_fastly_response(response) + let mut fastly_response = compat::to_fastly_response(response); + super::enforce_set_cookie_cache_privacy(&mut fastly_response); + fastly_response } fn browser_device_signals() -> DeviceSignals { @@ -644,7 +677,16 @@ fn route_auction_with_stack( let req = Request::post("https://test.com/auction") .with_header(header::CONTENT_TYPE, "application/json") .with_body(body.into()); - let services = test_runtime_services(&req); + // Resolve to a non-regulated jurisdiction so the server-side auction consent + // gate allows the auction; these tests assert orchestration behavior, not + // consent gating (covered separately in endpoints.rs). + let services = test_runtime_services_with_secret_http_client_and_geo( + &req, + Arc::new(NoopBackend), + Arc::new(NoopSecretStore), + Arc::new(NoopHttpClient) as Arc, + Arc::new(FixedGeo(non_regulated_geo())), + ); let route_result = futures::executor::block_on(route_request( settings, @@ -652,6 +694,7 @@ fn route_auction_with_stack( integration_registry, &partner_registry, &services, + &[], compat::from_fastly_request(req), browser_device_signals(), )) @@ -676,6 +719,7 @@ fn route_buffered_response( integration_registry, &partner_registry, services, + &[], compat::from_fastly_request(req), browser_device_signals(), )) @@ -884,11 +928,15 @@ fn datadome_allow_applies_downstream_headers_and_protects_auction() { ("x-dd-b", "allowed"), ]), ); - let services = test_runtime_services_with_secret_and_http_client( + // Resolve to a non-regulated jurisdiction so the server-side auction consent + // gate allows the auction to run; this test asserts DataDome protection plus + // auction orchestration, not consent gating (covered in endpoints.rs). + let services = test_runtime_services_with_secret_http_client_and_geo( &req, Arc::new(FixedBackend), datadome_secret_store(), Arc::clone(&http_client) as Arc, + Arc::new(FixedGeo(non_regulated_geo())), ); let response = route_buffered_response( @@ -1102,6 +1150,7 @@ fn routes_use_request_local_consent() { &integration_registry, &partner_registry, &discovery_services, + &[], compat::from_fastly_request(discovery_fastly_req), DeviceSignals::derive("", None, None), )) @@ -1120,6 +1169,7 @@ fn routes_use_request_local_consent() { &integration_registry, &partner_registry, &admin_services, + &[], compat::from_fastly_request(admin_fastly_req), DeviceSignals::derive("", None, None), )) @@ -1166,6 +1216,7 @@ fn legacy_admin_aliases_denied_locally_not_proxied_to_publisher() { &integration_registry, &partner_registry, &services, + &[], compat::from_fastly_request(alias_req), DeviceSignals::derive("", None, None), )) @@ -1499,6 +1550,7 @@ fn asset_routes_stream_asset_responses_directly() { &integration_registry, &partner_registry, &services, + &[], req, browser_device_signals(), )) @@ -1633,6 +1685,605 @@ fn asset_handler_error_stays_uncacheable_after_global_headers() { ); } +#[test] +fn finalize_response_preserves_origin_cache_headers_for_plain_html() { + // Reviewer P1.2: a zero-slot / non-matching navigation injects no per-user + // data and sets no cookie, so the publisher path leaves Cache-Control alone. + // finalize_response must not downgrade shared cacheability for it. + let settings = create_test_settings(); + let mut response = edge_response_builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, "text/html; charset=utf-8") + .header(header::CACHE_CONTROL, "public, max-age=3600") + .header("surrogate-control", "max-age=86400") + .body(EdgeBody::empty()) + .expect("should build test response"); + + super::finalize_response(&settings, None, &mut response); + + assert_eq!( + response + .headers() + .get(header::CACHE_CONTROL) + .and_then(|v| v.to_str().ok()), + Some("public, max-age=3600"), + "plain cookieless HTML should keep its shared cache directive" + ); + assert_eq!( + response + .headers() + .get("surrogate-control") + .and_then(|v| v.to_str().ok()), + Some("max-age=86400"), + "plain cookieless HTML should keep its surrogate cache directive" + ); +} + +#[test] +fn finalize_response_makes_cookie_bearing_responses_private() { + // A first-visit navigation that only sets the EC identity cookie must not be + // shared-cached, even though no ad data was injected. + let settings = create_test_settings(); + let mut response = edge_response_builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, "text/html; charset=utf-8") + .header(header::CACHE_CONTROL, "public, max-age=3600") + .header("surrogate-control", "max-age=86400") + .header(header::SET_COOKIE, "ec=abc; Path=/; HttpOnly") + .body(EdgeBody::empty()) + .expect("should build test response"); + + super::finalize_response(&settings, None, &mut response); + + assert_eq!( + response + .headers() + .get(header::CACHE_CONTROL) + .and_then(|v| v.to_str().ok()), + Some("private, max-age=0"), + "a Set-Cookie response must be downgraded to private" + ); + assert_eq!( + response + .headers() + .get("surrogate-control") + .and_then(|v| v.to_str().ok()), + None, + "a Set-Cookie response must not retain surrogate cacheability" + ); +} + +#[test] +fn ec_set_cookie_added_after_finalize_downgrades_origin_public_cache() { + // First-visit navigation: the origin response is shared-cacheable and carries + // no cookie, so the HttpResponse-stage finalizer keeps its cache headers. EC + // finalization then mints the identity Set-Cookie on the converted Fastly + // response, after that guard has already run. The post-EC privacy guard must + // downgrade caching so a shared cache cannot replay one visitor's EC cookie. + let settings = create_test_settings(); + let mut response = edge_response_builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, "text/html; charset=utf-8") + .header(header::CACHE_CONTROL, "public, max-age=3600") + .header("surrogate-control", "max-age=86400") + .body(EdgeBody::empty()) + .expect("should build test response"); + + // No cookie at this stage, so the cookie net does not fire and the origin + // cache directive survives finalize_response — reproducing the gap. + super::finalize_response(&settings, None, &mut response); + assert_eq!( + response + .headers() + .get(header::CACHE_CONTROL) + .and_then(|v| v.to_str().ok()), + Some("public, max-age=3600"), + "a cookieless response should keep its origin cache directive" + ); + + let mut fastly_response = compat::to_fastly_response(response); + // Stand in for ec_finalize_response minting the first-visit identity cookie: + // its EcContext constructors are #[cfg(test)] in trusted-server-core and are + // not reachable from this crate, but the only behavior under test here is the + // post-EC ordering — a Set-Cookie appearing after finalize_response ran. + fastly_response.set_header(header::SET_COOKIE, "ec=abc; Path=/; HttpOnly"); + + super::enforce_set_cookie_cache_privacy(&mut fastly_response); + + assert_eq!( + fastly_response.get_header_str("cache-control"), + Some("private, max-age=0"), + "an EC Set-Cookie added after finalize_response must downgrade caching" + ); + assert!( + fastly_response.get_header("surrogate-control").is_none(), + "EC Set-Cookie responses must not retain surrogate cacheability" + ); +} + +#[test] +fn enforce_set_cookie_cache_privacy_keeps_stricter_no_store() { + // A stricter directive minted alongside the cookie must not be weakened to + // the `private, max-age=0` downgrade. + let mut fastly_response = compat::to_fastly_response( + edge_response_builder() + .status(StatusCode::OK) + .header(header::CACHE_CONTROL, "no-store") + .header(header::SET_COOKIE, "ec=abc; Path=/") + .body(EdgeBody::empty()) + .expect("should build test response"), + ); + + super::enforce_set_cookie_cache_privacy(&mut fastly_response); + + assert_eq!( + fastly_response.get_header_str("cache-control"), + Some("no-store"), + "an already-uncacheable response should keep its stricter directive" + ); +} + +#[test] +fn enforce_set_cookie_cache_privacy_respects_split_no_store_header() { + let mut fastly_response = compat::to_fastly_response( + edge_response_builder() + .status(StatusCode::OK) + .header(header::CACHE_CONTROL, "public, max-age=3600") + .header(header::CACHE_CONTROL, "no-store") + .header("surrogate-control", "max-age=86400") + .header(header::SET_COOKIE, "ec=abc; Path=/") + .body(EdgeBody::empty()) + .expect("should build test response"), + ); + + super::enforce_set_cookie_cache_privacy(&mut fastly_response); + + let cache_control_values = fastly_response + .get_headers() + .filter(|(name, _)| name.as_str().eq_ignore_ascii_case("cache-control")) + .filter_map(|(_, value)| value.to_str().ok()) + .collect::>(); + assert_eq!( + cache_control_values, + vec!["public, max-age=3600", "no-store"], + "a no-store directive in any Cache-Control field should avoid weakening the response" + ); + assert!( + fastly_response.get_header("surrogate-control").is_none(), + "split no-store cookie responses must not retain Surrogate-Control" + ); +} + +#[test] +fn enforce_set_cookie_cache_privacy_does_not_trust_pseudo_directives() { + let mut fastly_response = compat::to_fastly_response( + edge_response_builder() + .status(StatusCode::OK) + .header( + header::CACHE_CONTROL, + "public, max-age=3600, no-storey, not-private", + ) + .header("surrogate-control", "max-age=86400") + .header(header::SET_COOKIE, "ec=abc; Path=/") + .body(EdgeBody::empty()) + .expect("should build test response"), + ); + + super::enforce_set_cookie_cache_privacy(&mut fastly_response); + + assert_eq!( + fastly_response.get_header_str("cache-control"), + Some("private, max-age=0"), + "pseudo-directives must not prevent the Fastly cookie privacy downgrade" + ); + assert!( + fastly_response.get_header("surrogate-control").is_none(), + "cookie responses must not retain surrogate cacheability" + ); +} + +#[test] +fn enforce_set_cookie_cache_privacy_strips_surrogate_on_no_store() { + // A `no-store` cookie response keeps its stricter Cache-Control but must still + // lose the surrogate cache headers — they are independent of Cache-Control and + // would otherwise let a shared cache store and replay the visitor's cookie. + let mut fastly_response = compat::to_fastly_response( + edge_response_builder() + .status(StatusCode::OK) + .header(header::CACHE_CONTROL, "no-store") + .header("surrogate-control", "max-age=86400") + .header("fastly-surrogate-control", "max-age=86400") + .header(header::SET_COOKIE, "ec=abc; Path=/") + .body(EdgeBody::empty()) + .expect("should build test response"), + ); + + super::enforce_set_cookie_cache_privacy(&mut fastly_response); + + assert_eq!( + fastly_response.get_header_str("cache-control"), + Some("no-store"), + "should keep the stricter no-store directive" + ); + assert!( + fastly_response.get_header("surrogate-control").is_none(), + "no-store cookie responses must not retain Surrogate-Control" + ); + assert!( + fastly_response + .get_header("fastly-surrogate-control") + .is_none(), + "no-store cookie responses must not retain Fastly-Surrogate-Control" + ); +} + +#[test] +fn request_filter_set_cookie_after_guard_still_downgrades_cache() { + // A request filter (e.g. a DataDome allow) can append a per-user Set-Cookie via + // response effects. main applies those effects before the final cache guard, so + // an origin response still marked `public` with surrogate headers must be + // downgraded once the filter cookie is present. + use trusted_server_core::integrations::{HeaderMutation, RequestFilterEffects}; + + let mut edge_response = edge_response_builder() + .status(StatusCode::OK) + .header(header::CACHE_CONTROL, "public, max-age=3600") + .header("surrogate-control", "max-age=86400") + .body(EdgeBody::empty()) + .expect("should build test response"); + + let effects = RequestFilterEffects { + request_headers: vec![], + response_headers: vec![HeaderMutation::append( + "set-cookie", + "datadome=allow; Path=/; HttpOnly", + )], + }; + + // Mirror main's ordering: apply effects first, then the guard. + effects.apply_to_response(&mut edge_response); + let mut fastly_response = compat::to_fastly_response(edge_response); + super::enforce_set_cookie_cache_privacy(&mut fastly_response); + + assert_eq!( + fastly_response.get_header_str("cache-control"), + Some("private, max-age=0"), + "a filter-added Set-Cookie must downgrade a public origin response" + ); + assert!( + fastly_response.get_header("surrogate-control").is_none(), + "a filter-added Set-Cookie must strip surrogate cacheability" + ); +} + +#[test] +fn finalize_response_no_store_cookie_blocks_operator_surrogate_reenable() { + // Operator response_headers must not re-add surrogate caching to a Set-Cookie + // response carrying the stricter `no-store` directive — the operator guard must + // treat no-store as protected, not just `private`. + let mut settings = create_test_settings(); + settings + .response_headers + .insert("Surrogate-Control".to_string(), "max-age=86400".to_string()); + settings.response_headers.insert( + "Fastly-Surrogate-Control".to_string(), + "max-age=86400".to_string(), + ); + settings.response_headers.insert( + header::CACHE_CONTROL.as_str().to_string(), + "public, max-age=3600".to_string(), + ); + let mut response = edge_response_builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, "text/html; charset=utf-8") + .header(header::CACHE_CONTROL, "no-store") + .header(header::SET_COOKIE, "ec=abc; Path=/") + .body(EdgeBody::empty()) + .expect("should build test response"); + + super::finalize_response(&settings, None, &mut response); + + assert_eq!( + response + .headers() + .get(header::CACHE_CONTROL) + .and_then(|v| v.to_str().ok()), + Some("no-store"), + "operator Cache-Control must not weaken the stricter no-store directive" + ); + assert_eq!( + response + .headers() + .get("surrogate-control") + .and_then(|v| v.to_str().ok()), + None, + "operator Surrogate-Control must not re-enable caching for a no-store cookie response" + ); + assert_eq!( + response + .headers() + .get("fastly-surrogate-control") + .and_then(|v| v.to_str().ok()), + None, + "operator Fastly-Surrogate-Control must not re-enable caching for a no-store cookie response" + ); +} + +#[test] +fn finalize_response_leaves_stricter_no_store_untouched() { + let settings = create_test_settings(); + let mut response = edge_response_builder() + .status(StatusCode::OK) + .header(header::CACHE_CONTROL, "no-store") + .header(header::SET_COOKIE, "ec=abc; Path=/") + .body(EdgeBody::empty()) + .expect("should build test response"); + + super::finalize_response(&settings, None, &mut response); + + assert_eq!( + response + .headers() + .get(header::CACHE_CONTROL) + .and_then(|v| v.to_str().ok()), + Some("no-store"), + "an already-uncacheable response should keep its stricter directive" + ); +} + +#[test] +fn finalize_response_treats_mixed_case_no_store_as_uncacheable() { + // Cache-Control directives are case-insensitive: `No-Store` on a Set-Cookie + // response must be recognized as already-uncacheable and left untouched, not + // downgraded to the weaker `private, max-age=0`. + let settings = create_test_settings(); + let mut response = edge_response_builder() + .status(StatusCode::OK) + .header(header::CACHE_CONTROL, "No-Store") + .header(header::SET_COOKIE, "ec=abc; Path=/") + .body(EdgeBody::empty()) + .expect("should build test response"); + + super::finalize_response(&settings, None, &mut response); + + assert_eq!( + response + .headers() + .get(header::CACHE_CONTROL) + .and_then(|v| v.to_str().ok()), + Some("No-Store"), + "mixed-case No-Store must be treated as uncacheable and preserved" + ); +} + +#[test] +fn finalize_response_mixed_case_private_blocks_operator_surrogate_reenable() { + // A mixed-case `Private` directive must still mark the response private so + // operator response_headers cannot re-enable shared caching. + let mut settings = create_test_settings(); + settings + .response_headers + .insert("Surrogate-Control".to_string(), "max-age=86400".to_string()); + let mut response = edge_response_builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, "text/html; charset=utf-8") + .header(header::CACHE_CONTROL, "Private, max-age=0") + .body(EdgeBody::empty()) + .expect("should build test response"); + + super::finalize_response(&settings, None, &mut response); + + assert_eq!( + response + .headers() + .get("surrogate-control") + .and_then(|v| v.to_str().ok()), + None, + "operator Surrogate-Control must not re-enable caching for a mixed-case Private response" + ); +} + +#[test] +fn finalize_response_cookie_net_blocks_operator_surrogate_reenable() { + // Operator response_headers must not re-add surrogate caching once the + // cookie net has marked the response private. + let mut settings = create_test_settings(); + settings + .response_headers + .insert("Surrogate-Control".to_string(), "max-age=86400".to_string()); + settings.response_headers.insert( + header::CACHE_CONTROL.as_str().to_string(), + "public, max-age=3600".to_string(), + ); + let mut response = edge_response_builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, "text/html; charset=utf-8") + .header(header::SET_COOKIE, "ec=abc; Path=/") + .body(EdgeBody::empty()) + .expect("should build test response"); + + super::finalize_response(&settings, None, &mut response); + + assert_eq!( + response + .headers() + .get(header::CACHE_CONTROL) + .and_then(|v| v.to_str().ok()), + Some("private, max-age=0"), + "operator Cache-Control must not override the cookie privacy directive" + ); + assert_eq!( + response + .headers() + .get("surrogate-control") + .and_then(|v| v.to_str().ok()), + None, + "operator Surrogate-Control must not re-enable shared caching for a cookie response" + ); +} + +#[test] +fn page_bids_response_cannot_regain_surrogate_headers_from_settings() { + let base = base_route_settings_toml(); + let prebid = prebid_integration_toml(); + let config = format!( + r#"{base} + +{prebid} + + [auction] + enabled = true + providers = ["prebid"] + timeout_ms = 2000 + + [creative_opportunities] + gam_network_id = "1234" + "#, + ); + let mut settings = + Settings::from_toml(&config).expect("should parse page-bids route test settings"); + settings + .response_headers + .insert("Surrogate-Control".to_string(), "max-age=86400".to_string()); + settings.response_headers.insert( + "Fastly-Surrogate-Control".to_string(), + "max-age=86400".to_string(), + ); + settings.response_headers.insert( + header::CACHE_CONTROL.as_str().to_string(), + "public, max-age=3600".to_string(), + ); + let (orchestrator, integration_registry) = build_route_stack(&settings); + + let mut req = Request::get("https://test-publisher.com/__ts/page-bids?path=/2024/article/"); + req.set_header("sec-fetch-site", "same-origin"); + let services = test_runtime_services(&req); + + let resp = route_buffered_response( + &settings, + &orchestrator, + &integration_registry, + &services, + req, + "should route page-bids request", + ); + + assert_eq!( + resp.get_status(), + StatusCode::OK, + "should serve the page-bids response" + ); + assert_eq!( + resp.get_header_str(header::CACHE_CONTROL), + Some("private, no-store"), + "should keep the per-user cache directive despite operator Cache-Control" + ); + assert_eq!( + resp.get_header_str("surrogate-control"), + None, + "should not let operator headers re-enable shared surrogate caching" + ); + assert_eq!( + resp.get_header_str("fastly-surrogate-control"), + None, + "should not let operator headers re-enable Fastly surrogate caching" + ); +} + +#[test] +fn page_bids_cross_site_request_is_rejected_at_the_route() { + let base = base_route_settings_toml(); + let prebid = prebid_integration_toml(); + let config = format!( + r#"{base} + +{prebid} + + [auction] + enabled = true + providers = ["prebid"] + timeout_ms = 2000 + + [creative_opportunities] + gam_network_id = "1234" + "#, + ); + let settings = + Settings::from_toml(&config).expect("should parse page-bids route test settings"); + let (orchestrator, integration_registry) = build_route_stack(&settings); + + let mut req = Request::get("https://test-publisher.com/__ts/page-bids?path=/2024/article/"); + req.set_header("sec-fetch-site", "cross-site"); + let services = test_runtime_services(&req); + + let resp = route_buffered_response( + &settings, + &orchestrator, + &integration_registry, + &services, + req, + "should route cross-site page-bids request", + ); + + assert_eq!( + resp.get_status(), + StatusCode::FORBIDDEN, + "should reject cross-site page-bids requests" + ); +} + +#[test] +fn page_bids_options_preflight_is_rejected_at_the_route() { + // OPTIONS must not fall through to the publisher origin (which may return + // permissive CORS); the GET handler's legacy `X-TSJS-Page-Bids` fallback + // relies on this endpoint never granting a preflight. + let base = base_route_settings_toml(); + let prebid = prebid_integration_toml(); + let config = format!( + r#"{base} + +{prebid} + + [auction] + enabled = true + providers = ["prebid"] + timeout_ms = 2000 + + [creative_opportunities] + gam_network_id = "1234" + "#, + ); + let settings = + Settings::from_toml(&config).expect("should parse page-bids route test settings"); + let (orchestrator, integration_registry) = build_route_stack(&settings); + + let req = Request::new( + Method::OPTIONS, + "https://test-publisher.com/__ts/page-bids?path=/2024/article/", + ); + let services = test_runtime_services(&req); + + let resp = route_buffered_response( + &settings, + &orchestrator, + &integration_registry, + &services, + req, + "should route page-bids preflight request", + ); + + assert_eq!( + resp.get_status(), + StatusCode::FORBIDDEN, + "should reject the page-bids CORS preflight at the adapter" + ); + assert_eq!( + resp.get_header_str(header::CACHE_CONTROL), + Some("private, no-store"), + "preflight rejection must not be shared-cached" + ); +} + #[test] fn s3_asset_origin_error_stays_uncacheable_after_global_headers() { let mut settings = create_test_settings(); diff --git a/crates/trusted-server-adapter-spin/src/app.rs b/crates/trusted-server-adapter-spin/src/app.rs index 0dddd7a1d..9457f3872 100644 --- a/crates/trusted-server-adapter-spin/src/app.rs +++ b/crates/trusted-server-adapter-spin/src/app.rs @@ -10,16 +10,19 @@ 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; use trusted_server_core::integrations::{IntegrationRegistry, ProxyDispatchInput}; +use trusted_server_core::platform::RuntimeServices; use trusted_server_core::proxy::{ handle_first_party_click, handle_first_party_proxy, handle_first_party_proxy_rebuild, handle_first_party_proxy_sign, }; use trusted_server_core::publisher::{ - PublisherResponse, buffer_publisher_response, handle_publisher_request, handle_tsjs_dynamic, + AuctionDispatch, 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_deactivate_key, handle_rotate_key, handle_trusted_server_discovery, @@ -77,16 +80,27 @@ fn build_state_with_settings( /// Collapse a [`PublisherResponse`] into a plain [`Response`]. /// -/// Delegates to the shared [`buffer_publisher_response`], which enforces -/// `settings.publisher.max_buffered_body_bytes` so a large processable -/// origin response fails safely instead of exhausting the Wasm heap. -fn resolve_publisher_response( +/// Delegates to the shared [`buffer_publisher_response_async`], which collects +/// the dispatched server-side auction and enforces +/// `settings.publisher.max_buffered_body_bytes` so a large processable origin +/// response fails safely instead of exhausting the Wasm heap. +async fn resolve_publisher_response( publisher_response: PublisherResponse, method: &Method, settings: &Settings, registry: &IntegrationRegistry, + orchestrator: &AuctionOrchestrator, + services: &RuntimeServices, ) -> Result> { - buffer_publisher_response(publisher_response, method, settings, registry) + buffer_publisher_response_async( + publisher_response, + method, + settings, + registry, + orchestrator, + services, + ) + .await } // --------------------------------------------------------------------------- @@ -129,7 +143,7 @@ const LEGACY_ADMIN_DENY_METHODS: &[Method] = &[ Method::DELETE, ]; -fn named_fallback_paths() -> [(&'static str, &'static [Method]); 11] { +fn named_fallback_paths() -> [(&'static str, &'static [Method]); 12] { [ ("/.well-known/trusted-server.json", &[Method::GET]), ("/verify-signature", &[Method::POST]), @@ -138,6 +152,7 @@ fn named_fallback_paths() -> [(&'static str, &'static [Method]); 11] { ("/admin/keys/rotate", LEGACY_ADMIN_DENY_METHODS), ("/admin/keys/deactivate", LEGACY_ADMIN_DENY_METHODS), ("/auction", &[Method::POST]), + ("/__ts/page-bids", &[Method::GET, Method::OPTIONS]), ("/first-party/proxy", &[Method::GET]), ("/first-party/click", &[Method::GET]), ("/first-party/sign", &[Method::GET, Method::POST]), @@ -308,6 +323,30 @@ fn health_response() -> Response { resp } +/// Builds the geo-aware [`EcContext`] for consent-gated endpoints (`/auction`, +/// `/__ts/page-bids`, and the publisher fallback). +/// +/// Mirrors the Fastly entry point: `EcContext::default()` leaves jurisdiction +/// Unknown, which fails the auction consent gate closed even for consented +/// users. Spin's platform geo is a no-op, so jurisdiction stays Unknown unless +/// the request carries TCF consent. A malformed consent string is logged and +/// falls back to the default (fail-closed) context rather than being silently +/// swallowed. +fn build_ec_context(settings: &Settings, services: &RuntimeServices, req: &Request) -> EcContext { + let geo_info = services + .geo() + .lookup(services.client_info().client_ip) + .unwrap_or_else(|e| { + log::warn!("geo lookup failed: {e}"); + None + }); + EcContext::read_from_request_with_geo(settings, req, services, geo_info.as_ref()) + .unwrap_or_else(|e| { + log::warn!("EC context read failed: {e:?}"); + EcContext::default() + }) +} + // --------------------------------------------------------------------------- // Error helper // --------------------------------------------------------------------------- @@ -492,7 +531,10 @@ fn build_router(state: &Arc) -> RouterService { // OpenRTB metadata that auction signing derives from // `RequestInfo::from_request` uses the trusted runtime authority. let req = ctx.into_request(); - let ec_context = EcContext::default(); + // Build the geo-aware EC context so the auction consent gate sees + // the caller's jurisdiction — `EcContext::default()` fails it + // closed for consented users. + let ec_context = build_ec_context(&s.settings, &services, &req); Ok(handle_auction( &s.settings, &s.orchestrator, @@ -507,6 +549,33 @@ fn build_router(state: &Arc) -> RouterService { } }; + // GET /__ts/page-bids — SPA re-auction endpoint. + let s = Arc::clone(&state); + let page_bids_handler = move |ctx: RequestContext| { + let s = Arc::clone(&s); + async move { + let services = build_runtime_services(&ctx); + let req = ctx.into_request(); + let ec_context = build_ec_context(&s.settings, &services, &req); + let auction = AuctionDispatch { + orchestrator: &s.orchestrator, + slots: s.settings.creative_opportunity_slots(), + registry: None, + }; + Ok( + handle_page_bids(&s.settings, &services, None, auction, &ec_context, req) + .await + .unwrap_or_else(|e| http_error(&e)), + ) + } + }; + + // OPTIONS /__ts/page-bids — deny the CORS preflight for this + // side-effecting GET so the `X-TSJS-Page-Bids` gate stays trustworthy. + let page_bids_options_handler = |_ctx: RequestContext| async { + Ok::(page_bids_preflight_denied()) + }; + // GET /first-party/proxy let s = Arc::clone(&state); let fp_proxy_handler = move |ctx: RequestContext| { @@ -576,7 +645,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 @@ -597,11 +666,36 @@ fn build_router(state: &Arc) -> RouterService { })) }) } else { - handle_publisher_request(&state.settings, &state.registry, &services, req) - .await - .and_then(|pr| { - resolve_publisher_response(pr, &method, &state.settings, &state.registry) - }) + let mut ec_context = build_ec_context(&state.settings, &services, &req); + let auction = AuctionDispatch { + orchestrator: &state.orchestrator, + slots: state.settings.creative_opportunity_slots(), + registry: None, + }; + match handle_publisher_request( + &state.settings, + &services, + None, + &mut ec_context, + auction, + req, + EdgeCacheHeader::SMaxageFallback, + ) + .await + { + Ok(pr) => { + resolve_publisher_response( + pr, + &method, + &state.settings, + &state.registry, + &state.orchestrator, + &services, + ) + .await + } + Err(e) => Err(e), + } }; Ok(result.unwrap_or_else(|e| http_error(&e))) @@ -646,6 +740,12 @@ fn build_router(state: &Arc) -> RouterService { .post("/_ts/admin/keys/rotate", rotate_handler) .post("/_ts/admin/keys/deactivate", deactivate_handler) .post("/auction", auction_handler) + .get("/__ts/page-bids", page_bids_handler) + .route( + "/__ts/page-bids", + Method::OPTIONS, + page_bids_options_handler, + ) .get("/first-party/proxy", fp_proxy_handler) .get("/first-party/click", fp_click_handler) .get("/first-party/sign", fp_sign_handler) diff --git a/crates/trusted-server-adapter-spin/src/middleware.rs b/crates/trusted-server-adapter-spin/src/middleware.rs index 62f83e1ea..1bcede1fc 100644 --- a/crates/trusted-server-adapter-spin/src/middleware.rs +++ b/crates/trusted-server-adapter-spin/src/middleware.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use async_trait::async_trait; use edgezero_core::context::RequestContext; use edgezero_core::error::EdgeError; -use edgezero_core::http::{HeaderName, HeaderValue, Response}; +use edgezero_core::http::{HeaderValue, Response}; use edgezero_core::middleware::{Middleware, Next}; use trusted_server_core::auth::enforce_basic_auth; use trusted_server_core::constants::HEADER_X_GEO_INFO_AVAILABLE; @@ -124,8 +124,8 @@ impl Middleware for NormalizeMiddleware { /// /// `geo_available` controls `X-Geo-Info-Available`. Spin passes `false` /// because it has no geo headers. Operator-configured -/// `settings.response_headers` are applied last and can override any managed -/// header. +/// `settings.response_headers` are applied last (with the shared cookie +/// cache-privacy hardening) and can override any managed header. pub(crate) fn apply_finalize_headers( settings: &Settings, geo_available: bool, @@ -136,18 +136,11 @@ pub(crate) fn apply_finalize_headers( HeaderValue::from_static(if geo_available { "true" } else { "false" }), ); - for (key, value) in &settings.response_headers { - let header_name = HeaderName::from_bytes(key.as_bytes()); - let header_value = HeaderValue::from_str(value); - if let (Ok(header_name), Ok(header_value)) = (header_name, header_value) { - response.headers_mut().insert(header_name, header_value); - } else { - log::warn!( - "Skipping invalid configured response header value for {}", - key - ); - } - } + // Cookie-bearing responses stay private to shared caches and operator + // headers cannot re-enable caching for uncacheable per-user payloads. + trusted_server_core::response_privacy::apply_response_headers_with_cache_privacy( + settings, response, + ); } // --------------------------------------------------------------------------- diff --git a/crates/trusted-server-adapter-spin/tests/routes.rs b/crates/trusted-server-adapter-spin/tests/routes.rs index 943717e84..110ba3eea 100644 --- a/crates/trusted-server-adapter-spin/tests/routes.rs +++ b/crates/trusted-server-adapter-spin/tests/routes.rs @@ -183,6 +183,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/Cargo.toml b/crates/trusted-server-core/Cargo.toml index d593390bb..ba88f361f 100644 --- a/crates/trusted-server-core/Cargo.toml +++ b/crates/trusted-server-core/Cargo.toml @@ -25,6 +25,7 @@ edgezero-core = { workspace = true } error-stack = { workspace = true } flate2 = { workspace = true } futures = { workspace = true } +glob = { workspace = true } hex = { workspace = true } hmac = { workspace = true } http = { workspace = true } @@ -67,6 +68,7 @@ config = { workspace = true } criterion = { workspace = true } edgezero-core = { workspace = true, features = ["test-utils"] } temp-env = { workspace = true } +tokio = { workspace = true } [[bench]] name = "consent_decode" diff --git a/crates/trusted-server-core/benches/html_processor_bench.rs b/crates/trusted-server-core/benches/html_processor_bench.rs index 016d5c51e..9e81d67d9 100644 --- a/crates/trusted-server-core/benches/html_processor_bench.rs +++ b/crates/trusted-server-core/benches/html_processor_bench.rs @@ -9,6 +9,8 @@ fn make_config() -> HtmlProcessorConfig { request_host: "proxy.bench.example.com".to_string(), request_scheme: "https".to_string(), integrations: IntegrationRegistry::default(), + ad_slots_script: None, + ad_bids_state: std::sync::Arc::new(std::sync::Mutex::new(None)), max_buffered_body_bytes: 16 * 1024 * 1024, } } diff --git a/crates/trusted-server-core/src/auction/endpoints.rs b/crates/trusted-server-core/src/auction/endpoints.rs index 000f1c482..c642c9ec3 100644 --- a/crates/trusted-server-core/src/auction/endpoints.rs +++ b/crates/trusted-server-core/src/auction/endpoints.rs @@ -1,12 +1,15 @@ //! HTTP endpoint handlers for auction requests. +use std::collections::HashMap; + use edgezero_core::body::Body as EdgeBody; use error_stack::{Report, ResultExt}; use http::{header, Request, Response, StatusCode}; use serde_json::Value as JsonValue; use crate::auction::formats::AdRequest; -use crate::consent::gate_eids_by_consent; +use crate::auction::orchestrator::OrchestrationResult; +use crate::consent::{consent_allows_server_side_auction, gate_eids_by_consent}; use crate::constants::COOKIE_TS_EIDS; use crate::ec::eids::{resolve_partner_ids, to_eids}; use crate::ec::kv::KvIdentityGraph; @@ -34,11 +37,62 @@ const MAX_CLIENT_EID_SOURCE_BYTES: usize = 255; /// arbitrary WASM linear memory. const MAX_AUCTION_BODY_SIZE: usize = 256 * 1024; -/// Handle auction request from /auction endpoint. +/// Handle auction request from `POST /auction`. +/// +/// Accepts a JSON body matching [`AdRequest`][`super::formats::AdRequest`]. +/// The minimum valid request is: +/// +/// ```json +/// { +/// "adUnits": [{ +/// "code": "atf_sidebar_ad", +/// "mediaTypes": { "banner": { "sizes": [[300, 250]] } } +/// }] +/// } +/// ``` +/// +/// ## Bidder params: inline vs. stored-request +/// +/// Each ad unit's `bids` array is **optional**. When absent or empty the PBS +/// integration falls back to a stored-request keyed by the unit's `code` +/// field (`imp.ext.prebid.storedrequest = { id: "" }`). A PBS stored +/// request must therefore exist for every slot code that omits inline params. +/// +/// When `bids` is supplied, each entry's `bidder`/`params` pair is forwarded +/// directly as `imp.ext.prebid.bidder.`. +/// +/// ## Context passthrough (`config`) +/// +/// The optional `config` object is filtered through +/// [`auction.allowed_context_keys`][`crate::settings::AuctionConfig::allowed_context_keys`]. +/// Only keys listed there reach the auction providers (e.g. `"permutive_segments"`). +/// All other keys are silently dropped. Values must be either strings or arrays of +/// strings. +/// +/// ## Response +/// +/// Returns an `OpenRTB 2.x` response. Creative HTML is inlined in each bid's +/// `adm` field after sanitisation and first-party URL rewriting. Response +/// headers include `X-TS-EC` (the caller's Edge Cookie ID) and +/// `X-TS-EC-Fresh` (a freshly generated ID for cookie renewal). +/// +/// ## Scroll, refresh, and SPA navigation /// -/// This is the main entry point for running header bidding auctions. -/// It orchestrates bids from multiple providers (Prebid, APS, GAM, etc.) and returns -/// the winning bids in `OpenRTB` format with creative HTML inline in the `adm` field. +/// This endpoint is intended for **initial page render** and **programmatic +/// callers** (e.g. slim-Prebid, native apps, server-to-server integrations). +/// It is **not** the intended path for scroll or GPT refresh events. +/// +/// **SPA navigation** is handled by `GET /__ts/page-bids`: the client-side SPA +/// hook (`installSpaAuctionHook`) intercepts `pushState`/`replaceState`/`popstate` +/// events and calls that endpoint to fetch fresh slots and bids for each new +/// route, then invokes `window.tsjs.adInit()` with the updated data. +/// +/// **Scroll and GPT refresh** are owned by slim-Prebid in Phase 1: it runs +/// post-`window.load`, listens for GPT refresh events, and runs client-side +/// auctions independently of this endpoint. +/// +/// A slot-template-aware refresh API (`POST /auction/refresh`) is deferred to a +/// future phase and not designed here. /// /// # Errors /// @@ -112,14 +166,64 @@ pub async fn handle_auction( }; let consent_context = ec_context.consent().clone(); + // Server-side auction consent gate. The publisher-navigation and + // `/__ts/page-bids` paths fail closed for GDPR/unknown jurisdictions that + // lack effective TCF Purpose 1. `/auction` is the programmatic entry point + // for the same server-side auction, so it must gate identically: returning + // a no-bid response here prevents outbound PBS/APS calls and the forwarding + // of request-derived signals (UA/IP/geo, and cookies under some Prebid + // consent-forwarding modes) for traffic that must not run an auction. + if !consent_allows_server_side_auction(&consent_context) { + log::info!( + "/auction: server-side auction consent gate denied; returning no-bid response without contacting providers" + ); + // Build the request shape locally (no outbound calls, no geo lookup, no + // EID resolution) so the no-bid OpenRTB response echoes the request id. + let auction_request = convert_tsjs_to_auction_request( + &body, + settings, + services, + &http_req, + consent_context, + ec_id, + None, + )?; + let empty_result = OrchestrationResult { + provider_responses: Vec::new(), + mediator_response: None, + winning_bids: HashMap::new(), + total_time_ms: 0, + metadata: HashMap::new(), + }; + return convert_to_openrtb_response( + &empty_result, + settings, + &auction_request, + ec_context.ec_allowed(), + ); + } + // Parse client-provided EIDs from the current request body. When the // current request does not include them, fall back to the persisted // `ts-eids` cookie so later requests can still forward the browser's // full OpenRTB-style EID structure. - let client_eids = resolve_client_auction_eids( - body.eids.as_ref(), - extract_cookie_value(&http_req, COOKIE_TS_EIDS).as_deref(), - ); + // + // Gate this on the same identity-consent condition as the EC ID + // (`ec_id.is_some()`, which is already filtered by `ec_context.ec_allowed()`). + // Otherwise a US/GPC or US-Privacy opt-out context — where EC identity use is + // denied but a non-personalized auction may still run — could forward + // persistent client EIDs from the body/cookie, since `gate_eids_by_consent` + // only strips on TCF/GDPR signals. This matches the publisher and + // `/__ts/page-bids` paths, which also resolve client EIDs only when + // `ec_id.is_some()`. + let client_eids = if ec_id.is_some() { + resolve_client_auction_eids( + body.eids.as_ref(), + extract_cookie_value(&http_req, COOKIE_TS_EIDS).as_deref(), + ) + } else { + None + }; // Resolve partner EIDs from the KV identity graph when the user has // a valid EC and both KV and partner stores are available. @@ -188,7 +292,7 @@ pub async fn handle_auction( /// Returns `None` when any prerequisite is missing (no KV store, no partner /// store, no EC, consent denied). On KV or partner-resolution errors, logs a /// warning and returns empty EIDs so the auction can proceed in degraded mode. -fn resolve_auction_eids( +pub(crate) fn resolve_auction_eids( kv: Option<&KvIdentityGraph>, registry: Option<&PartnerRegistry>, ec_context: &EcContext, @@ -234,7 +338,7 @@ fn extract_cookie_value(req: &Request, name: &str) -> Option { None } -fn resolve_client_auction_eids( +pub(crate) fn resolve_client_auction_eids( raw: Option<&JsonValue>, cookie_value: Option<&str>, ) -> Option> { @@ -330,7 +434,7 @@ fn parse_client_auction_uid(raw: &JsonValue) -> Option { Some(Uid { id, atype, ext }) } -fn merge_auction_eids( +pub(crate) fn merge_auction_eids( client_eids: Option>, resolved_eids: Option>, ) -> Option> { @@ -393,12 +497,19 @@ fn merge_auction_eids( #[cfg(test)] mod tests { use super::*; + use crate::auction::config::AuctionConfig; + use crate::auction::provider::AuctionProvider; + use crate::auction::types::{AuctionRequest, AuctionResponse}; use crate::consent::jurisdiction::Jurisdiction; use crate::consent::types::ConsentContext; use crate::openrtb::Uid; + use crate::platform::test_support::noop_services; + use crate::platform::{PlatformPendingRequest, PlatformResponse}; + use crate::test_support::tests::create_test_settings; use base64::engine::general_purpose::STANDARD as BASE64; use base64::Engine as _; use serde_json::json; + use std::sync::Arc; fn make_ec_context(jurisdiction: Jurisdiction, ec_value: Option<&str>) -> EcContext { EcContext::new_for_test( @@ -410,6 +521,234 @@ mod tests { ) } + /// Provider that fails the test if it is ever contacted. Used to prove the + /// `/auction` consent gate short-circuits before any outbound bid request. + struct PanicOnBidProvider; + + #[async_trait::async_trait(?Send)] + impl AuctionProvider for PanicOnBidProvider { + fn provider_name(&self) -> &'static str { + "panic_provider" + } + + async fn request_bids( + &self, + _request: &AuctionRequest, + _context: &AuctionContext<'_>, + ) -> Result> { + panic!("provider must not be contacted when the consent gate denies the auction"); + } + + async fn parse_response( + &self, + _response: PlatformResponse, + _response_time_ms: u64, + ) -> Result> { + panic!("provider must not parse a response when the auction is gated off"); + } + + fn timeout_ms(&self) -> u32 { + 100 + } + + fn backend_name(&self, _services: &RuntimeServices, _timeout_ms: u32) -> Option { + Some("panic-backend".to_string()) + } + } + + #[tokio::test] + async fn auction_endpoint_consent_gate_returns_no_bid_without_contacting_providers() { + // GDPR/unknown jurisdiction lacking effective TCF Purpose 1 must not run + // a server-side auction. The /auction endpoint must short-circuit to a + // no-bid response before dispatching to any provider — matching the + // publisher-navigation and /__ts/page-bids paths. + let settings = create_test_settings(); + let config = AuctionConfig { + enabled: true, + providers: vec!["panic_provider".to_string()], + timeout_ms: 2000, + mediator: None, + ..Default::default() + }; + let mut orchestrator = AuctionOrchestrator::new(config); + orchestrator.register_provider(Arc::new(PanicOnBidProvider)); + let services = noop_services(); + let ec_id = format!("{}.ABC123", "a".repeat(64)); + let ec_context = make_ec_context(Jurisdiction::Unknown, Some(&ec_id)); + + let body = json!({ + "adUnits": [ + { + "code": "div-gpt-ad-1", + "mediaTypes": { "banner": { "sizes": [[300, 250]] } } + } + ] + }); + let req = Request::builder() + .method("POST") + .uri("https://test-publisher.com/auction") + .body(EdgeBody::from( + serde_json::to_vec(&body).expect("should serialize body"), + )) + .expect("should build auction request"); + + let response = handle_auction( + &settings, + &orchestrator, + None, + None, + &ec_context, + &services, + req, + ) + .await + .expect("gated auction should still return a valid response"); + + assert_eq!( + response.status(), + StatusCode::OK, + "gated auction should return a 200 no-bid response" + ); + let body_bytes = response.into_body().into_bytes().unwrap_or_default(); + let parsed: JsonValue = + serde_json::from_slice(&body_bytes).expect("response body should be valid JSON"); + let seatbid_empty = match parsed.get("seatbid").and_then(JsonValue::as_array) { + Some(seatbid) => seatbid.is_empty(), + None => true, + }; + assert!( + seatbid_empty, + "gated auction must return no bids, got: {parsed}" + ); + } + + /// Provider that records whether the auction request it received carried + /// EIDs, then fails its launch so no real transport handle is needed. + struct EidCapturingProvider { + had_eids: Arc>>, + } + + #[async_trait::async_trait(?Send)] + impl AuctionProvider for EidCapturingProvider { + fn provider_name(&self) -> &'static str { + "eid_capturing_provider" + } + + async fn request_bids( + &self, + request: &AuctionRequest, + _context: &AuctionContext<'_>, + ) -> Result> { + *self.had_eids.lock().expect("should lock captured eids") = + Some(request.user.eids.is_some()); + Err(Report::new(TrustedServerError::Auction { + message: "capture only".to_string(), + })) + } + + async fn parse_response( + &self, + _response: PlatformResponse, + _response_time_ms: u64, + ) -> Result> { + panic!("parse_response must not run when the launch fails"); + } + + fn timeout_ms(&self) -> u32 { + 100 + } + + fn backend_name(&self, _services: &RuntimeServices, _timeout_ms: u32) -> Option { + Some("capture-backend".to_string()) + } + } + + #[tokio::test] + async fn auction_strips_client_eids_when_ec_identity_denied() { + // US-state opt-out via GPC: the server-side auction consent gate still + // allows a non-personalized auction, but EC identity use is denied + // (`ec_allowed()` is false) and `gate_eids_by_consent` does not strip + // because no TCF signal is present and GDPR does not apply. Client EIDs + // supplied in the request body/cookie must NOT be forwarded — the + // outgoing auction request must have `user.eids == None`. + let settings = create_test_settings(); + let config = AuctionConfig { + enabled: true, + providers: vec!["eid_capturing_provider".to_string()], + timeout_ms: 2000, + mediator: None, + ..Default::default() + }; + let mut orchestrator = AuctionOrchestrator::new(config); + let had_eids = Arc::new(std::sync::Mutex::new(None)); + orchestrator.register_provider(Arc::new(EidCapturingProvider { + had_eids: Arc::clone(&had_eids), + })); + let services = noop_services(); + + // US-state jurisdiction with an explicit GPC opt-out: auction allowed, + // EC identity denied. + let ec_context = EcContext::new_for_test( + None, + ConsentContext { + jurisdiction: Jurisdiction::UsState("CA".to_owned()), + gpc: true, + ..ConsentContext::default() + }, + ); + + // Persistent EIDs supplied in both the request body and the ts-eids cookie. + let cookie_payload = json!([ + { + "source": "sharedid.org", + "uids": [{ "id": "cookie_uid", "atype": 3 }] + } + ]); + let encoded_cookie = BASE64 + .encode(serde_json::to_vec(&cookie_payload).expect("should serialize cookie payload")); + let body = json!({ + "adUnits": [ + { + "code": "div-gpt-ad-1", + "mediaTypes": { "banner": { "sizes": [[300, 250]] } } + } + ], + "eids": [ + { + "source": "id5-sync.com", + "uids": [{ "id": "body_uid", "atype": 1 }] + } + ] + }); + let req = Request::builder() + .method("POST") + .uri("https://test-publisher.com/auction") + .header("cookie", format!("{COOKIE_TS_EIDS}={encoded_cookie}")) + .body(EdgeBody::from( + serde_json::to_vec(&body).expect("should serialize body"), + )) + .expect("should build auction request"); + + // The capturing provider fails its launch, so the auction errors overall; + // the assertion is on the EIDs observed by the provider, not the result. + let _ = handle_auction( + &settings, + &orchestrator, + None, + None, + &ec_context, + &services, + req, + ) + .await; + + assert_eq!( + *had_eids.lock().expect("should lock captured eids"), + Some(false), + "outgoing auction request must carry no EIDs when EC identity is denied" + ); + } + #[test] fn resolve_auction_eids_returns_none_without_kv() { let registry = PartnerRegistry::empty(); diff --git a/crates/trusted-server-core/src/auction/formats.rs b/crates/trusted-server-core/src/auction/formats.rs index f6e6b43f4..691021d50 100644 --- a/crates/trusted-server-core/src/auction/formats.rs +++ b/crates/trusted-server-core/src/auction/formats.rs @@ -29,7 +29,11 @@ use super::types::{ PublisherInfo, SiteInfo, UserInfo, }; -/// Request body format for auction endpoints (tsjs/Prebid.js format). +/// Request body for `POST /auction` (tsjs / Prebid.js wire format). +/// +/// `adUnits` lists the placements to bid on. `config` carries optional +/// context values (e.g. audience segments) filtered through +/// [`auction.allowed_context_keys`][`crate::settings::AuctionConfig::allowed_context_keys`]. #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct AdRequest { @@ -38,6 +42,15 @@ pub struct AdRequest { pub eids: Option, } +/// A single ad placement in an [`AdRequest`]. +/// +/// `code` identifies the slot (e.g. `"atf_sidebar_ad"`) and becomes the +/// impression ID in the outgoing `OpenRTB` request. +/// +/// `bids` is optional. When absent or empty the PBS provider falls back to +/// a stored-request keyed by `code` (`imp.ext.prebid.storedrequest.id`). +/// When present, each entry's params are forwarded inline to PBS as +/// `imp.ext.prebid.bidder.`. #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct AdUnit { @@ -46,7 +59,11 @@ pub struct AdUnit { pub bids: Option>, } -/// Bidder configuration from the request. +/// Inline bidder params for one SSP within an [`AdUnit`]. +/// +/// `params` is passed verbatim to the corresponding PBS bidder adapter. +/// When the `bids` array is absent, the slot falls back to PBS stored +/// requests — see [`AdUnit`] for details. #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct BidConfig { @@ -420,6 +437,10 @@ mod tests { height: 250, nurl: None, burl: None, + ad_id: None, + cache_id: None, + cache_host: None, + cache_path: None, metadata: HashMap::new(), } } @@ -1129,3 +1150,184 @@ mod tests { assert!(bid.get("h").is_none(), "should omit out-of-range height"); } } + +#[cfg(test)] +mod convert_tests { + use super::*; + use crate::consent::ConsentContext; + use crate::platform::test_support::noop_services; + use crate::test_support::tests::crate_test_settings_str; + use http::Method; + + fn make_settings() -> Settings { + Settings::from_toml(&crate_test_settings_str()).expect("should parse test settings") + } + + fn make_req() -> Request { + Request::builder() + .method(Method::POST) + .uri("https://test-publisher.com/auction") + .body(EdgeBody::empty()) + .expect("should build test request") + } + + fn call_convert(body: &AdRequest) -> AuctionRequest { + let settings = make_settings(); + let services = noop_services(); + let req = make_req(); + convert_tsjs_to_auction_request( + body, + &settings, + &services, + &req, + ConsentContext::default(), + Some("test-ec-id"), + None, + ) + .expect("should convert without error") + } + + #[test] + fn no_bids_produces_empty_bidders_map() { + // An ad unit with no `bids` array must produce an empty bidders map. + // An empty bidders map triggers the PBS stored-request fallback: + // the PBS provider sets imp.ext.prebid.storedrequest = { id: "" }. + let body = AdRequest { + ad_units: vec![AdUnit { + code: "atf_sidebar_ad".to_string(), + media_types: Some(MediaTypes { + banner: Some(BannerUnit { + sizes: vec![vec![300, 250]], + }), + }), + bids: None, + }], + config: None, + eids: None, + }; + + let auction_request = call_convert(&body); + + assert_eq!(auction_request.slots.len(), 1, "should have one slot"); + let slot = &auction_request.slots[0]; + assert_eq!(slot.id, "atf_sidebar_ad", "slot id should match unit code"); + assert!( + slot.bidders.is_empty(), + "absent bids array should yield empty bidders map (PBS stored-request path)" + ); + } + + #[test] + fn inline_bids_populate_bidders_map() { + // When bids are supplied, each bidder+params pair should appear in the + // slot's bidders map so PBS receives inline params. + let body = AdRequest { + ad_units: vec![AdUnit { + code: "homepage_header_ad".to_string(), + media_types: Some(MediaTypes { + banner: Some(BannerUnit { + sizes: vec![vec![970, 90]], + }), + }), + bids: Some(vec![BidConfig { + bidder: "kargo".to_string(), + params: serde_json::json!({ "placementId": "client_123" }), + }]), + }], + config: None, + eids: None, + }; + + let auction_request = call_convert(&body); + + let slot = &auction_request.slots[0]; + assert!( + slot.bidders.contains_key("kargo"), + "kargo bidder should be present in slot bidders map" + ); + assert_eq!( + slot.bidders["kargo"]["placementId"], "client_123", + "bidder params should be forwarded verbatim" + ); + } + + #[test] + fn config_allowed_key_passes_through() { + // Keys in auction.allowed_context_keys must reach the auction context. + // The test settings do not set allowed_context_keys so the default + // (empty) applies — verify a key is NOT present rather than IS. + // To test the allow-list, inject a key via a custom settings string. + let settings_str = format!( + "{}\n[auction]\nallowed_context_keys = [\"permutive_segments\"]\n", + crate_test_settings_str() + ); + let settings = Settings::from_toml(&settings_str).expect("should parse"); + let services = noop_services(); + let req = make_req(); + + let body = AdRequest { + ad_units: vec![], + config: Some(serde_json::json!({ + "permutive_segments": ["seg1", "seg2"], + "disallowed_key": "should be dropped", + })), + eids: None, + }; + + let auction_request = convert_tsjs_to_auction_request( + &body, + &settings, + &services, + &req, + ConsentContext::default(), + Some("test-ec-id"), + None, + ) + .expect("should convert"); + + assert!( + auction_request.context.contains_key("permutive_segments"), + "allowed key should be in auction context" + ); + assert!( + !auction_request.context.contains_key("disallowed_key"), + "unlisted key should be dropped" + ); + } + + #[test] + fn invalid_banner_size_returns_error() { + // Banner sizes must be [width, height] pairs; a 3-element size is invalid. + let body = AdRequest { + ad_units: vec![AdUnit { + code: "bad_slot".to_string(), + media_types: Some(MediaTypes { + banner: Some(BannerUnit { + sizes: vec![vec![300, 250, 99]], // invalid — 3 elements + }), + }), + bids: None, + }], + config: None, + eids: None, + }; + + let settings = make_settings(); + let services = noop_services(); + let req = make_req(); + let result = convert_tsjs_to_auction_request( + &body, + &settings, + &services, + &req, + ConsentContext::default(), + Some("test-ec-id"), + None, + ); + + assert!( + result.is_err(), + "3-element banner size should return an error" + ); + } +} diff --git a/crates/trusted-server-core/src/auction/orchestrator.rs b/crates/trusted-server-core/src/auction/orchestrator.rs index c89f30ff4..7137ebdb3 100644 --- a/crates/trusted-server-core/src/auction/orchestrator.rs +++ b/crates/trusted-server-core/src/auction/orchestrator.rs @@ -7,11 +7,43 @@ use std::time::Duration; use web_time::Instant; use crate::error::TrustedServerError; +use crate::platform::{PlatformPendingRequest, RuntimeServices}; use super::config::AuctionConfig; use super::provider::AuctionProvider; use super::types::{AuctionContext, AuctionRequest, AuctionResponse, Bid, BidStatus}; +/// In-flight auction requests dispatched to SSP backends. +/// +/// Created by [`AuctionOrchestrator::dispatch_auction`] and consumed by +/// [`AuctionOrchestrator::collect_dispatched_auction`]. Carrying this handle +/// across `pending_origin.wait()` lets origin response and SSP HTTP requests +/// race in Fastly's native layer, enabling TTFB ≈ origin latency rather than +/// TTFB ≈ auction timeout. +pub struct DispatchedAuction { + pending_requests: Vec, + backend_to_provider: HashMap)>, + auction_start: Instant, + timeout_ms: u32, + floor_prices: HashMap, + /// Carried so the mediator call in collect can pass it as the auction request. + request: AuctionRequest, +} + +#[cfg(test)] +impl DispatchedAuction { + pub(crate) fn empty_for_test(request: AuctionRequest, timeout_ms: u32) -> Self { + Self { + pending_requests: Vec::new(), + backend_to_provider: HashMap::new(), + auction_start: Instant::now(), + timeout_ms, + floor_prices: HashMap::new(), + request, + } + } +} + const PROVIDER_ERROR_MESSAGE_CHARS: usize = 500; const ERROR_TYPE_PARSE_RESPONSE: &str = "parse_response"; @@ -212,7 +244,9 @@ impl AuctionOrchestrator { let mediator_context = AuctionContext { settings: context.settings, request: context.request, - timeout_ms: remaining_ms, + // Bound by both the remaining auction budget and the mediator's + // own configured timeout, matching the dispatched collect path. + timeout_ms: remaining_ms.min(mediator.timeout_ms()), provider_responses: Some(&provider_responses), services: context.services, }; @@ -235,8 +269,19 @@ impl AuctionOrchestrator { })?; let response_time_ms = start_time.elapsed().as_millis() as u64; + // Use the context-aware parse so mediators (e.g. adserver_mock) can + // restore nurl/burl/ad_id and PBS cache fields from the collected SSP + // responses. The dispatched collect path already does this; the + // synchronous mediation path used by POST /auction and + // /__ts/page-bids must match or mediated cache bids lose the metadata + // needed for creative rendering and win/billing beacons. let mediator_resp = mediator - .parse_response(platform_resp, response_time_ms) + .parse_response_with_context( + platform_resp, + response_time_ms, + request, + &mediator_context, + ) .await .change_context(TrustedServerError::Auction { message: format!("Mediator {} parse failed", mediator.provider_name()), @@ -366,9 +411,9 @@ impl AuctionOrchestrator { } // Give each provider only the remaining time from the auction - // deadline so that its backend first_byte_timeout doesn't extend - // past the overall budget. Also respect the provider's own - // configured timeout when it is tighter than the remaining budget. + // deadline so that backend transport timeouts do not extend past + // the overall budget. Also respect the provider's own configured + // timeout when it is tighter than the remaining budget. let remaining_ms = remaining_budget_ms(auction_start, context.timeout_ms); let effective_timeout = remaining_ms.min(provider.timeout_ms()); @@ -465,10 +510,11 @@ impl AuctionOrchestrator { // Enforce the auction deadline: after each select() returns, check // elapsed time and drop remaining requests if the timeout is exceeded. // - // NOTE: `select()` blocks until at least one backend responds (or its - // transport timeout fires). Hard deadline enforcement therefore depends - // on every backend's `first_byte_timeout` being set to at most the - // remaining auction budget — which Phase 1 above guarantees. + // NOTE: `select()` blocks until at least one backend responds and, on + // some adapters, buffers the selected response body before returning. + // Hard deadline enforcement therefore depends on every backend's + // first-byte and between-bytes timeouts being set to at most the + // remaining auction budget, which Phase 1 above guarantees. let mut remaining = pending_requests; while !remaining.is_empty() { @@ -500,7 +546,19 @@ impl AuctionOrchestrator { { let response_time_ms = start_time.elapsed().as_millis() as u64; - match provider.parse_response(response, response_time_ms).await { + // Use the context-aware parse so a provider overriding + // `parse_response_with_context` behaves identically on the + // parallel (`/auction`, page-bids) and collect (publisher) + // paths. The default impl delegates to `parse_response`. + match provider + .parse_response_with_context( + response, + response_time_ms, + request, + context, + ) + .await + { Ok(auction_response) => { log::info!( "Provider '{}' returned {} bids (status: {:?}, time: {}ms)", @@ -633,28 +691,29 @@ impl AuctionOrchestrator { } let starting_count = winning_bids.len(); - winning_bids.retain(|slot_id, bid| match floor_prices.get(slot_id) { - Some(floor) => { - // Bids without price (e.g., APS) pass through - floor checked in mediation - match bid.price { - Some(price) if price >= *floor => true, - Some(_) => { - log::info!( - "Dropping winning bid below floor price for slot '{}'", - slot_id - ); - false - } - None => { - log::debug!( - "Passing bid with encoded price for slot '{}' - floor check deferred to mediation", - slot_id - ); - true - } - } + winning_bids.retain(|slot_id, bid| match (floor_prices.get(slot_id), bid.price) { + (Some(floor), Some(price)) if price >= *floor => true, + (Some(_), Some(_)) => { + log::info!( + "Dropping winning bid below floor price for slot '{}'", + slot_id + ); + false } - None => true, + (_, None) => { + // Any caller that needs to keep an undecoded (encoded-price) + // bid must decode it *before* invoking this function — both + // `select_winning_bids` and the mediator path already do. + // Letting `None`-price bids through here would cause + // `winning_bids.len()` to overcount what `build_bid_map` + // downstream is willing to emit, so they get dropped instead. + log::debug!( + "Dropping bid for slot '{}' - no decoded price (caller must decode before apply_floor_prices)", + slot_id + ); + false + } + (None, Some(_)) => true, }); if winning_bids.len() != starting_count { @@ -694,6 +753,437 @@ impl AuctionOrchestrator { }) } + /// Dispatch SSP bid requests without blocking WASM. + /// + /// Calls each enabled provider's [`AuctionProvider::request_bids`] (which + /// internally calls Fastly's `send_async`), then returns immediately with a + /// [`DispatchedAuction`] token. The Fastly host begins the SSP round-trips + /// while WASM continues to `pending_origin.wait()`. + /// + /// Returns `None` when no providers are configured or all providers are + /// disabled / over budget. The caller should fall back to the synchronous + /// `run_auction` path. + #[must_use] + pub async fn dispatch_auction( + &self, + request: &AuctionRequest, + context: &AuctionContext<'_>, + ) -> Option { + let provider_names = self.config.provider_names(); + if provider_names.is_empty() { + return None; + } + + // Mirror run_providers_parallel: reject multi-provider fan-out before + // any request launches when the platform executes `send_async` eagerly + // (e.g. Cloudflare Workers, Spin). Sequential execution would accrue + // the sum of provider latencies before the origin fetch and then fail + // collection with empty bids. + if provider_names.len() > 1 && !context.services.http_client().supports_concurrent_fanout() + { + log::warn!( + "{} auction providers configured, but this platform's HTTP client \ + executes requests sequentially — skipping initial-page auction \ + dispatch; configure a single provider, or use an adapter with \ + concurrent fan-out support", + provider_names.len(), + ); + return None; + } + + let auction_start = Instant::now(); + let mut backend_to_provider: HashMap)> = + HashMap::new(); + let mut pending_requests: Vec = Vec::new(); + + for provider_name in provider_names { + let provider = match self.providers.get(provider_name) { + Some(p) => p, + None => { + // lgtm[rust/cleartext-logging] + // The provider name is a static config identifier (e.g. "prebid"), not a secret. + log::warn!("Provider '{}' not registered, skipping", provider_name); + continue; + } + }; + + if !provider.is_enabled() { + log::debug!( + "Provider '{}' is disabled, skipping", + provider.provider_name() + ); + continue; + } + + let remaining_ms = remaining_budget_ms(auction_start, context.timeout_ms); + let effective_timeout = remaining_ms.min(provider.timeout_ms()); + + if effective_timeout == 0 { + log::warn!( + "Auction timeout ({}ms) exhausted before launching '{}' — skipping", + context.timeout_ms, + provider.provider_name() + ); + continue; + } + + let backend_name = match provider.backend_name(context.services, effective_timeout) { + Some(name) => name, + None => { + log::warn!( + "Provider '{}' has no backend_name, skipping", + provider.provider_name() + ); + continue; + } + }; + + let provider_context = AuctionContext { + settings: context.settings, + request: context.request, + timeout_ms: effective_timeout, + provider_responses: context.provider_responses, + services: context.services, + }; + + let start_time = Instant::now(); + match provider.request_bids(request, &provider_context).await { + Ok(pending) => { + log::info!( + "Dispatching bid request to '{}' (backend: {}, budget: {}ms)", + provider.provider_name(), + backend_name, + effective_timeout + ); + backend_to_provider.insert( + backend_name.clone(), + ( + provider.provider_name().to_string(), + start_time, + Arc::clone(provider), + ), + ); + pending_requests.push(pending.with_backend_name(backend_name)); + } + Err(e) => { + log::warn!( + "Provider '{}' failed to dispatch request: {:?}", + provider.provider_name(), + e + ); + } + } + } + + if pending_requests.is_empty() { + return None; + } + + log::info!( + "Dispatched {} SSP requests (timeout: {}ms); Fastly host will race them against origin", + pending_requests.len(), + context.timeout_ms + ); + + Some(DispatchedAuction { + pending_requests, + backend_to_provider, + auction_start, + timeout_ms: context.timeout_ms, + floor_prices: self.floor_prices_by_slot(request), + request: request.clone(), + }) + } + + /// Collect bid responses from a previously-dispatched auction. + /// + /// Runs the select-loop phase (equivalent to Phase 2 of + /// `run_providers_parallel`) and, if the orchestrator has a mediator + /// configured, forwards collected bids to it. The overall auction deadline + /// is enforced from `dispatched.auction_start`. + /// + /// On any error or partial failure the method returns the best available + /// result rather than propagating — the caller should still inject the + /// winning bids even if some providers timed out. + pub async fn collect_dispatched_auction( + &self, + dispatched: DispatchedAuction, + services: &RuntimeServices, + context: &AuctionContext<'_>, + ) -> OrchestrationResult { + let DispatchedAuction { + pending_requests, + mut backend_to_provider, + auction_start, + timeout_ms, + floor_prices, + request, + } = dispatched; + + log::info!( + "Collecting {} in-flight SSP responses (timeout: {}ms remaining: {}ms)", + pending_requests.len(), + timeout_ms, + remaining_budget_ms(auction_start, timeout_ms), + ); + + let mut responses: Vec = Vec::new(); + let mut remaining = pending_requests; + + while !remaining.is_empty() { + let select_result = match services + .http_client() + .select(remaining) + .await + .change_context(TrustedServerError::Auction { + message: "HTTP select failed".to_string(), + }) { + Ok(r) => r, + Err(e) => { + log::warn!("select() failed during auction collection: {:?}", e); + break; + } + }; + // Destructure so transport failures can be attributed to a provider + // via `failed_backend_name`, mirroring run_providers_parallel. + let crate::platform::PlatformSelectResult { + ready, + remaining: new_remaining, + failed_backend_name, + } = select_result; + remaining = new_remaining; + + match ready { + Ok(platform_response) => { + let backend_name = platform_response.backend_name.clone().unwrap_or_default(); + if let Some((provider_name, start_time, provider)) = + backend_to_provider.remove(&backend_name) + { + let response_time_ms = start_time.elapsed().as_millis() as u64; + // Mirror run_providers_parallel: use the context-aware + // parse so providers behave identically on both paths. + match provider + .parse_response_with_context( + platform_response, + response_time_ms, + &request, + context, + ) + .await + { + Ok(auction_response) => { + log::info!( + "Provider '{}' returned {} bids ({}ms)", + auction_response.provider, + auction_response.bids.len(), + auction_response.response_time_ms + ); + responses.push(auction_response); + } + Err(e) => { + log::warn!("Provider '{}' parse failed: {:?}", provider_name, e); + // Mirror the parallel path so a parse failure is + // attributed (error_type + message) in provider_details. + responses.push(provider_error_response( + &provider_name, + response_time_ms, + ERROR_TYPE_PARSE_RESPONSE, + &e, + )); + } + } + } else { + log::warn!( + "Received response from unknown backend '{}', ignoring", + backend_name + ); + } + } + Err(e) => { + // Mirror the parallel path: attribute the transport failure to + // the provider behind `failed_backend_name` so it appears in + // provider_details instead of vanishing. + if let Some(ref backend_name) = failed_backend_name { + if let Some((provider_name, start_time, _)) = + backend_to_provider.remove(backend_name) + { + let response_time_ms = start_time.elapsed().as_millis() as u64; + log::warn!("Provider '{}' request failed: {:?}", provider_name, e); + responses.push(provider_transport_failed_response( + &provider_name, + response_time_ms, + )); + } else { + log::warn!( + "A provider request failed (backend '{}' not tracked): {:?}", + backend_name, + e + ); + } + } else { + log::warn!( + "A provider request failed during collection (backend not identified): {:?}", + e + ); + } + } + } + + // Drain every dispatched request. Each backend was capped with + // first-byte and between-bytes timeouts at dispatch time, so by the + // collect phase the remaining handles may already be ready even if + // wall-clock time elapsed while the origin was slow. Dropping them + // here would discard SSP responses that already arrived. The + // mediator launch below still observes A_deadline via + // `remaining_budget_ms`. + } + + let (mediator_response, winning_bids) = if let Some(mediator_name) = &self.config.mediator { + match self.providers.get(mediator_name.as_str()) { + Some(mediator) => { + // Cap the mediator at whichever is tighter: its own configured + // timeout or the remaining auction budget (A_deadline). The old + // comment here claimed origin drain could exhaust the budget before + // collection, but SSP backends are given first-byte and between-bytes + // timeouts equal to effective_timeout (capped at their provider + // timeout) at dispatch time, so they cannot run past A_deadline + // independently. Giving the mediator an uncapped timeout lets it run + // past A_deadline, violating the bounded hold invariant. + let remaining = remaining_budget_ms(auction_start, timeout_ms); + if remaining == 0 { + log::warn!( + "A_deadline exhausted before mediator '{}' — returning {} SSP bids without mediation", + mediator.provider_name(), + responses.len(), + ); + let winning = self.select_winning_bids(&responses, &floor_prices); + return OrchestrationResult { + provider_responses: responses, + mediator_response: None, + winning_bids: winning, + total_time_ms: auction_start.elapsed().as_millis() as u64, + metadata: HashMap::new(), + }; + } + let mediator_timeout = remaining.min(mediator.timeout_ms()); + let mediator_start = Instant::now(); + log::info!( + "Running mediator '{}' with {}ms budget (A_deadline remaining: {}ms, configured: {}ms)", + mediator.provider_name(), + mediator_timeout, + remaining, + mediator.timeout_ms(), + ); + // The mediator runs on the collect path. See the doc-comment on + // `AuctionContext::request`: the real client request was already + // consumed by `send_async` during dispatch, so we substitute a + // canonical placeholder URL. Any future mediator that needs real + // client headers must snapshot them at dispatch time onto + // `DispatchedAuction` rather than reading `context.request` here. + let placeholder = http::Request::builder() + .uri(crate::auction::types::MEDIATOR_PLACEHOLDER_URL) + .body(edgezero_core::body::Body::empty()) + .unwrap_or_else(|_| http::Request::new(edgezero_core::body::Body::empty())); + let mediator_context = AuctionContext { + settings: context.settings, + request: &placeholder, + timeout_ms: mediator_timeout, + provider_responses: Some(&responses), + services: context.services, + }; + match mediator.request_bids(&request, &mediator_context).await { + Ok(pending) => { + let platform_resp = services.http_client().wait(pending).await; + match platform_resp.change_context(TrustedServerError::Auction { + message: format!( + "Mediator {} request failed", + mediator.provider_name() + ), + }) { + Ok(platform_resp) => { + let response_time_ms = + mediator_start.elapsed().as_millis() as u64; + // Mirror run_parallel_mediation: use the + // context-aware parse so the mediator sees + // the collected provider responses. + match mediator + .parse_response_with_context( + platform_resp, + response_time_ms, + &request, + &mediator_context, + ) + .await + { + Ok(mediator_resp) => { + let winning = mediator_resp + .bids + .iter() + .filter_map(|bid| { + if bid.price.is_none() { + log::warn!( + "Mediator '{}' returned bid for slot '{}' without decoded price - skipping", + mediator.provider_name(), + bid.slot_id + ); + None + } else { + Some((bid.slot_id.clone(), bid.clone())) + } + }) + .collect(); + let winning = + self.apply_floor_prices(winning, &floor_prices); + (Some(mediator_resp), winning) + } + Err(e) => { + log::warn!( + "Mediator '{}' parse failed: {:?}", + mediator.provider_name(), + e + ); + let winning = + self.select_winning_bids(&responses, &floor_prices); + (None, winning) + } + } + } + Err(e) => { + log::warn!("Mediator request failed: {:?}", e); + (None, self.select_winning_bids(&responses, &floor_prices)) + } + } + } + Err(e) => { + log::warn!( + "Mediator '{}' failed to dispatch: {:?}", + mediator.provider_name(), + e + ); + (None, self.select_winning_bids(&responses, &floor_prices)) + } + } + } + None => { + // lgtm[rust/cleartext-logging] + // The mediator name is a static config identifier, not a secret. + log::warn!("Mediator '{}' not registered", mediator_name); + (None, self.select_winning_bids(&responses, &floor_prices)) + } + } + } else { + (None, self.select_winning_bids(&responses, &floor_prices)) + }; + + OrchestrationResult { + provider_responses: responses, + mediator_response, + winning_bids, + total_time_ms: auction_start.elapsed().as_millis() as u64, + metadata: HashMap::new(), + } + } + /// Check if orchestrator is enabled. #[must_use] pub fn is_enabled(&self) -> bool { @@ -823,6 +1313,160 @@ mod tests { } } + /// Mediator whose context-aware parse restores `nurl`/`ad_id` (mirroring + /// `adserver_mock`), while its context-free parse does not. Lets a test prove + /// the synchronous mediation path calls `parse_response_with_context`. + struct CacheRestoringMediator; + + fn mediated_bid(nurl: Option) -> Bid { + Bid { + slot_id: "header-banner".to_string(), + price: Some(2.5), + currency: "USD".to_string(), + creative: Some("
ad
".to_string()), + adomain: None, + bidder: "mediator".to_string(), + width: 728, + height: 90, + nurl: nurl.clone(), + burl: nurl, + ad_id: Some("creative-123".to_string()), + cache_id: Some("cache-abc".to_string()), + cache_host: None, + cache_path: None, + metadata: HashMap::new(), + } + } + + #[async_trait::async_trait(?Send)] + impl AuctionProvider for CacheRestoringMediator { + fn provider_name(&self) -> &'static str { + "mediator" + } + + async fn request_bids( + &self, + _request: &AuctionRequest, + context: &AuctionContext<'_>, + ) -> Result> { + let req = PlatformHttpRequest::new( + http::Request::builder() + .method("POST") + .uri("https://example.com/mediate") + .body(edgezero_core::body::Body::empty()) + .expect("should build mediator request"), + "mediator-backend", + ); + context + .services + .http_client() + .send_async(req) + .await + .change_context(TrustedServerError::Auction { + message: "mediator launch failed".to_string(), + }) + } + + async fn parse_response( + &self, + _response: PlatformResponse, + response_time_ms: u64, + ) -> Result> { + // Context-free path: cannot restore SSP-only render/accounting fields. + Ok(AuctionResponse::success( + "mediator", + vec![mediated_bid(None)], + response_time_ms, + )) + } + + async fn parse_response_with_context( + &self, + _response: PlatformResponse, + response_time_ms: u64, + _request: &AuctionRequest, + _context: &AuctionContext<'_>, + ) -> Result> { + // Context-aware path: restores nurl/ad_id from the collected SSP bids. + Ok(AuctionResponse::success( + "mediator", + vec![mediated_bid(Some("https://nurl.example/win".to_string()))], + response_time_ms, + )) + } + + fn timeout_ms(&self) -> u32 { + 2000 + } + + fn backend_name(&self, _services: &RuntimeServices, _timeout_ms: u32) -> Option { + Some("mediator-backend".to_string()) + } + } + + #[tokio::test] + async fn mediated_bid_preserves_restored_fields_through_run_auction() { + // run_parallel_mediation must parse the mediator response via + // parse_response_with_context so cache/nurl fields restored from SSP + // responses survive the synchronous mediation path (POST /auction, + // /__ts/page-bids), matching the dispatched collect path. + let stub = Arc::new(StubHttpClient::new()); + stub.push_response(200, b"{}".to_vec()); // bidder send_async + stub.push_response(200, b"{}".to_vec()); // mediator send_async + let services = build_services_with_http_client(stub); + // SAFETY: `Box::leak` creates a `'static` reference for test use only. + let services: &'static RuntimeServices = Box::leak(Box::new(services)); + + let config = AuctionConfig { + enabled: true, + providers: vec!["bidder".to_string()], + mediator: Some("mediator".to_string()), + timeout_ms: 2000, + ..Default::default() + }; + let mut orchestrator = AuctionOrchestrator::new(config); + orchestrator.register_provider(Arc::new(StubAuctionProvider { + name: "bidder", + backend: "bidder-backend", + })); + orchestrator.register_provider(Arc::new(CacheRestoringMediator)); + + let request = create_test_auction_request(); + let settings = create_test_settings(); + let req = http::Request::builder() + .method(http::Method::GET) + .uri("https://example.com/test") + .body(edgezero_core::body::Body::empty()) + .expect("should build request"); + let context = AuctionContext { + settings: &settings, + request: &req, + timeout_ms: 2000, + provider_responses: None, + services, + }; + + let result = orchestrator + .run_auction(&request, &context) + .await + .expect("mediated auction should complete"); + + let bid = result + .winning_bids + .get("header-banner") + .expect("mediator should produce a winning bid for the slot"); + assert_eq!( + bid.nurl.as_deref(), + Some("https://nurl.example/win"), + "synchronous mediation must restore nurl via parse_response_with_context" + ); + assert_eq!( + bid.ad_id.as_deref(), + Some("creative-123"), + "mediated bid must keep its restored ad_id" + ); + } + fn create_test_auction_request() -> AuctionRequest { AuctionRequest { id: "test-auction-123".to_string(), @@ -1025,6 +1669,10 @@ mod tests { height: 250, nurl: None, burl: None, + ad_id: None, + cache_id: None, + cache_host: None, + cache_path: None, metadata: HashMap::new(), }, ); @@ -1041,6 +1689,10 @@ mod tests { height: 250, nurl: None, burl: None, + ad_id: None, + cache_id: None, + cache_host: None, + cache_path: None, metadata: HashMap::new(), }, ); @@ -1338,9 +1990,78 @@ mod tests { } #[test] - fn test_apply_floor_prices_allows_none_prices_for_encoded_bids() { - // Test that bids with None prices (APS-style) pass through floor pricing - // This is correct behavior for parallel-only strategy where mediation happens later + fn dispatch_auction_skips_multi_provider_fanout_on_sequential_platform() { + futures::executor::block_on(async { + // Arrange: two configured providers on a platform whose HTTP + // client executes send_async eagerly (no concurrent fan-out). + // The initial-page dispatch path must apply the same guard as + // run_providers_parallel or the summed provider latency lands + // before the origin fetch. + let stub = Arc::new(StubHttpClient::new()); + stub.set_concurrent_fanout(false); + let stub_for_assertion = Arc::clone(&stub); + + let services = build_services_with_http_client(stub); + // SAFETY: `Box::leak` creates a `'static` reference for test use only. + // The leaked allocation is bounded to the test process lifetime. + let services: &'static RuntimeServices = Box::leak(Box::new(services)); + + let config = AuctionConfig { + enabled: true, + providers: vec!["provider-a".to_string(), "provider-b".to_string()], + timeout_ms: 2000, + mediator: None, + ..Default::default() + }; + let mut orchestrator = AuctionOrchestrator::new(config); + orchestrator.register_provider(Arc::new(StubAuctionProvider { + name: "provider-a", + backend: "backend-a", + })); + orchestrator.register_provider(Arc::new(StubAuctionProvider { + name: "provider-b", + backend: "backend-b", + })); + + let request = create_test_auction_request(); + let settings = create_test_settings(); + let req = http::Request::builder() + .method(http::Method::GET) + .uri("https://example.com/test") + .body(edgezero_core::body::Body::empty()) + .expect("should build request"); + let context = AuctionContext { + settings: &settings, + request: &req, + timeout_ms: 2000, + provider_responses: None, + services, + }; + + // Act + let dispatched = orchestrator.dispatch_auction(&request, &context).await; + + // Assert: no dispatch and no provider request launched. + assert!( + dispatched.is_none(), + "should skip initial-page dispatch on sequential platforms" + ); + assert!( + stub_for_assertion.recorded_backend_names().is_empty(), + "should not launch any provider request on a sequential platform" + ); + }); + } + + #[test] + fn test_apply_floor_prices_drops_bids_with_undecoded_price() { + // Bids that reach apply_floor_prices with `price=None` cannot have a + // floor compared against them — and they would not survive downstream + // (build_bid_map filters them) — so apply_floor_prices drops them so + // the count it reports matches what eventually ships to the client. + // Both production paths (select_winning_bids and the mediator filter) + // already decode/skip None prices before calling this function; this + // test pins the contract. let orchestrator = AuctionOrchestrator::new(AuctionConfig::default()); let mut floor_prices = HashMap::new(); floor_prices.insert("slot-1".to_string(), 1.00); @@ -1350,7 +2071,7 @@ mod tests { "slot-1".to_string(), Bid { slot_id: "slot-1".to_string(), - price: None, // APS bid with encoded price + price: None, currency: "USD".to_string(), creative: Some("
Ad
".to_string()), adomain: None, @@ -1359,6 +2080,10 @@ mod tests { height: 250, nurl: None, burl: None, + ad_id: None, + cache_id: None, + cache_host: None, + cache_path: None, metadata: { let mut m = HashMap::new(); m.insert( @@ -1370,25 +2095,96 @@ mod tests { }, ); - // Apply floor pricing - should pass through with None price let filtered = orchestrator.apply_floor_prices(winning_bids, &floor_prices); - assert_eq!( - filtered.len(), - 1, - "APS bid with None price should pass through floor check" + assert!( + filtered.is_empty(), + "bid with None price should be dropped by apply_floor_prices" ); assert!( - filtered.contains_key("slot-1"), - "Slot-1 should still be present" + !filtered.contains_key("slot-1"), + "slot-1 should not survive when its bid has no decoded price" ); + } + + #[test] + fn test_apply_floor_prices_drops_decoded_aps_bid_below_floor() { + // After mediation decodes an APS bid, apply_floor_prices must enforce the + // slot floor on the resulting price=Some(x) value. This test simulates the + // state of a bid after mediator decoding: price is Some, amznbid is gone. + let orchestrator = AuctionOrchestrator::new(AuctionConfig::default()); + let mut floor_prices = HashMap::new(); + floor_prices.insert("atf".to_string(), 0.50); + + let mut winning_bids = HashMap::new(); + winning_bids.insert( + "atf".to_string(), + Bid { + slot_id: "atf".to_string(), + price: Some(0.30), // decoded APS price — below $0.50 floor + currency: "USD".to_string(), + creative: Some("
APS Ad
".to_string()), + adomain: None, + bidder: "aps".to_string(), + width: 300, + height: 250, + nurl: None, + burl: None, + ad_id: None, + cache_id: None, + cache_host: None, + cache_path: None, + metadata: HashMap::new(), + }, + ); + + let filtered = orchestrator.apply_floor_prices(winning_bids, &floor_prices); + assert!( - filtered - .get("slot-1") - .expect("slot-1 should be present") - .price - .is_none(), - "Price should still be None (not decoded yet)" + filtered.is_empty(), + "Decoded APS bid below slot floor should be dropped" + ); + } + + #[test] + fn test_apply_floor_prices_keeps_decoded_aps_bid_at_or_above_floor() { + let orchestrator = AuctionOrchestrator::new(AuctionConfig::default()); + let mut floor_prices = HashMap::new(); + floor_prices.insert("atf".to_string(), 0.50); + + let mut winning_bids = HashMap::new(); + winning_bids.insert( + "atf".to_string(), + Bid { + slot_id: "atf".to_string(), + price: Some(0.75), // decoded APS price — above floor + currency: "USD".to_string(), + creative: Some("
APS Ad
".to_string()), + adomain: None, + bidder: "aps".to_string(), + width: 300, + height: 250, + nurl: None, + burl: None, + ad_id: None, + cache_id: None, + cache_host: None, + cache_path: None, + metadata: HashMap::new(), + }, + ); + + let filtered = orchestrator.apply_floor_prices(winning_bids, &floor_prices); + + assert_eq!( + filtered.len(), + 1, + "Decoded APS bid at or above floor should be kept" + ); + assert_eq!( + filtered.get("atf").expect("atf should be present").price, + Some(0.75), + "Price should be preserved" ); } } diff --git a/crates/trusted-server-core/src/auction/provider.rs b/crates/trusted-server-core/src/auction/provider.rs index f5d235aa0..86305bf1b 100644 --- a/crates/trusted-server-core/src/auction/provider.rs +++ b/crates/trusted-server-core/src/auction/provider.rs @@ -49,6 +49,28 @@ pub trait AuctionProvider: Send + Sync { response_time_ms: u64, ) -> Result>; + /// Parse the response with access to the original auction request and context. + /// + /// Providers that need request-local metadata while transforming responses + /// can override this method. `request` is the [`AuctionRequest`] the + /// orchestrator dispatched, so request-scoped data (e.g. slot ID mappings) + /// can be derived here instead of stored on the shared provider instance. + /// The default preserves the existing response-only provider contract. + /// + /// # Errors + /// + /// Returns an error if the response cannot be parsed into a valid [`AuctionResponse`]. + async fn parse_response_with_context( + &self, + response: PlatformResponse, + response_time_ms: u64, + request: &AuctionRequest, + context: &AuctionContext<'_>, + ) -> Result> { + let _ = (request, context); + self.parse_response(response, response_time_ms).await + } + /// Check if this provider supports a specific media type. fn supports_media_type(&self, media_type: &super::types::MediaType) -> bool { // By default, support banner ads diff --git a/crates/trusted-server-core/src/auction/types.rs b/crates/trusted-server-core/src/auction/types.rs index 80314318e..14c7713f8 100644 --- a/crates/trusted-server-core/src/auction/types.rs +++ b/crates/trusted-server-core/src/auction/types.rs @@ -53,9 +53,15 @@ pub struct AdFormat { } /// Media type enumeration. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +/// +/// `Default` is `Banner` for programmatic construction only. Do **not** add +/// `#[serde(default)]` to any field of this type: it would coerce an +/// unknown/missing media type to `Banner` rather than failing, silently +/// mis-typing video/native slots. Deserialization must stay strict. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "lowercase")] pub enum MediaType { + #[default] Banner, Video, Native, @@ -83,11 +89,13 @@ pub struct UserInfo { /// cookies/headers, not from stored data. #[serde(skip)] pub consent: Option, - /// Consent-gated Extended User IDs resolved from the KV identity graph. + /// Extended User IDs parsed from the [`crate::constants::COOKIE_TS_EIDS`] cookie. /// - /// Populated by the auction handler from partner data when the user has - /// a valid EC and consent permits EID transmission. `None` when no EIDs - /// are available (no EC, consent denied, or KV read failure). + /// Raw (un-gated) values from the browser; consent gating via + /// [`crate::consent::gate_eids_by_consent`] is applied centrally in the + /// endpoint handlers (the auction and page-bids paths) before any EID + /// reaches a bid request — the provider layer just forwards already-gated + /// EIDs. #[serde(skip)] pub eids: Option>, } @@ -108,6 +116,29 @@ pub struct SiteInfo { } /// Context passed to auction providers. +/// +/// # The `request` field is path-dependent +/// +/// `request` carries the **real downstream client request** in the dispatch +/// path ([`AuctionOrchestrator::run_auction`][run] and +/// [`dispatch_auction`][dispatch]). Providers there can read client headers +/// (DNT, User-Agent, cookies, X-* customs) directly off it. +/// +/// In the **collect path** ([`collect_dispatched_auction`][collect]) the +/// mediator is invoked with a synthetic placeholder request +/// (`https://placeholder.invalid/`), because the real client request has +/// already been consumed by `send_async` during dispatch and the host pipeline +/// can't lend it across the `.await`. **Mediators must not depend on reading +/// client state from `context.request`** — the placeholder has none of the +/// real headers. If a future mediator needs that data, snapshot it into a new +/// field on this struct at dispatch time and stash it on the +/// [`DispatchedAuction`] token so collect can attach it to the mediator's +/// context. See +/// (P2-1) for the open follow-up. +/// +/// [run]: crate::auction::AuctionOrchestrator::run_auction +/// [dispatch]: crate::auction::AuctionOrchestrator::dispatch_auction +/// [collect]: crate::auction::AuctionOrchestrator::collect_dispatched_auction pub struct AuctionContext<'a> { pub settings: &'a Settings, pub request: &'a Request, @@ -119,6 +150,12 @@ pub struct AuctionContext<'a> { pub services: &'a RuntimeServices, } +/// URL used by the orchestrator when invoking a mediator from the collect +/// path. Providers can `debug_assert` against this value to catch a mediator +/// that has accidentally started depending on `context.request` carrying real +/// client headers. +pub const MEDIATOR_PLACEHOLDER_URL: &str = "https://placeholder.invalid/"; + /// Response from a single auction provider. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AuctionResponse { @@ -159,6 +196,24 @@ pub struct Bid { pub nurl: Option, /// Billing notification URL pub burl: Option, + /// Ad ID from the bidder + pub ad_id: Option, + /// Prebid Cache UUID for this bid. + /// + /// Populated from `ext.prebid.cache.bids.cacheId` in the PBS response. + /// Used as `hb_adid` targeting value in `window.tsjs.bids`. `None` for + /// non-PBS providers (e.g., APS) and PBS bids without Prebid Cache enabled. + pub cache_id: Option, + /// Prebid Cache host (e.g., `"openads.adsrvr.org"`). + /// + /// Populated from the host of `ext.prebid.cache.bids.url`. Used as + /// `hb_cache_host` targeting value. `None` when cache is absent. + pub cache_host: Option, + /// Prebid Cache path (e.g., `"/cache"`). + /// + /// Populated from the path of `ext.prebid.cache.bids.url`. Used as + /// `hb_cache_path` targeting value. `None` when cache is absent. + pub cache_path: Option, /// Provider-specific bid metadata /// For APS bids, contains encoded price in "amznbid" field pub metadata: HashMap, @@ -283,6 +338,10 @@ mod tests { height: 250, nurl: None, burl: None, + ad_id: None, + cache_id: None, + cache_host: None, + cache_path: None, metadata: HashMap::new(), } } @@ -394,4 +453,73 @@ mod tests { "should omit metadata field when empty" ); } + + #[test] + fn bid_with_cache_fields_round_trips_through_json() { + let bid = Bid { + slot_id: "atf".to_string(), + price: Some(1.50), + currency: "USD".to_string(), + creative: None, + adomain: None, + bidder: "thetradedesk".to_string(), + width: 300, + height: 250, + nurl: None, + burl: None, + ad_id: Some("bid-id".to_string()), + cache_id: Some("cache-uuid".to_string()), + cache_host: Some("cache.example.com".to_string()), + cache_path: Some("/pbc/v1/cache".to_string()), + metadata: HashMap::new(), + }; + let json = serde_json::to_string(&bid).expect("should serialize Bid"); + let restored: Bid = serde_json::from_str(&json).expect("should deserialize Bid"); + assert_eq!( + restored.cache_id.as_deref(), + Some("cache-uuid"), + "should round-trip cache_id" + ); + assert_eq!( + restored.cache_host.as_deref(), + Some("cache.example.com"), + "should round-trip cache_host" + ); + assert_eq!( + restored.cache_path.as_deref(), + Some("/pbc/v1/cache"), + "should round-trip cache_path" + ); + } + + #[test] + fn media_type_defaults_to_banner() { + assert_eq!( + MediaType::default(), + MediaType::Banner, + "should default to Banner for serde field defaults" + ); + } + + #[test] + fn bid_has_ad_id_field() { + let bid = Bid { + slot_id: "s".to_string(), + price: Some(1.0), + currency: "USD".to_string(), + creative: None, + adomain: None, + bidder: "kargo".to_string(), + width: 300, + height: 250, + nurl: None, + burl: None, + ad_id: Some("prebid-ad-id-abc".to_string()), + cache_id: None, + cache_host: None, + cache_path: None, + metadata: Default::default(), + }; + assert_eq!(bid.ad_id.as_deref(), Some("prebid-ad-id-abc")); + } } diff --git a/crates/trusted-server-core/src/cache_policy.rs b/crates/trusted-server-core/src/cache_policy.rs new file mode 100644 index 000000000..5b2b09a3e --- /dev/null +++ b/crates/trusted-server-core/src/cache_policy.rs @@ -0,0 +1,565 @@ +//! Structured cache-policy rendering helpers. +//! +//! Cache policy is expressed once as typed data and then rendered into the +//! runtime-specific headers used by each edge platform. The helpers in this +//! module only write cache-control headers; response privacy hardening still +//! runs later so personalized or cookie-bearing responses cannot be made +//! shared-cacheable by accident. + +use std::time::Duration; + +use http::header::{self, HeaderName}; +use http::{HeaderMap, HeaderValue}; + +/// String name Fastly uses for shared-cache control. +pub const HEADER_SURROGATE_CONTROL_NAME: &str = "surrogate-control"; +/// String name Fastly may use for shared-cache control in some configurations. +pub const HEADER_FASTLY_SURROGATE_CONTROL_NAME: &str = "fastly-surrogate-control"; +/// String name for the standards-track CDN-only shared-cache control header. +pub const HEADER_CDN_CACHE_CONTROL_NAME: &str = "cdn-cache-control"; +/// String name for Cloudflare-specific CDN-only shared-cache control. +pub const HEADER_CLOUDFLARE_CDN_CACHE_CONTROL_NAME: &str = "cloudflare-cdn-cache-control"; + +/// Runtime edge-cache header names owned by this crate. +pub const EDGE_CACHE_HEADER_NAMES: &[&str] = &[ + HEADER_SURROGATE_CONTROL_NAME, + HEADER_FASTLY_SURROGATE_CONTROL_NAME, + HEADER_CDN_CACHE_CONTROL_NAME, + HEADER_CLOUDFLARE_CDN_CACHE_CONTROL_NAME, +]; + +/// Header name Fastly uses for shared-cache control. +pub const HEADER_SURROGATE_CONTROL: HeaderName = + HeaderName::from_static(HEADER_SURROGATE_CONTROL_NAME); +/// Header name Fastly may use for shared-cache control in some configurations. +pub const HEADER_FASTLY_SURROGATE_CONTROL: HeaderName = + HeaderName::from_static(HEADER_FASTLY_SURROGATE_CONTROL_NAME); +/// Standards-track header name for CDN-only shared-cache control. +pub const HEADER_CDN_CACHE_CONTROL: HeaderName = + HeaderName::from_static(HEADER_CDN_CACHE_CONTROL_NAME); +/// Cloudflare-specific header name for CDN-only shared-cache control. +pub const HEADER_CLOUDFLARE_CDN_CACHE_CONTROL: HeaderName = + HeaderName::from_static(HEADER_CLOUDFLARE_CDN_CACHE_CONTROL_NAME); + +/// Cache-control value used when a response must not be stored. +pub const NO_STORE_PRIVATE_CACHE_CONTROL: &str = "no-store, private"; + +/// Shared-cache header family emitted for the current runtime. +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub enum EdgeCacheHeader { + /// Emit Fastly's `Surrogate-Control` header. + SurrogateControl, + /// Emit the standards-track `CDN-Cache-Control` header. + CdnCacheControl, + /// Emit Cloudflare's `Cloudflare-CDN-Cache-Control` header. + CloudflareCdnCacheControl, + /// Put `s-maxage` into `Cache-Control` instead of emitting a separate edge header. + SMaxageFallback, + /// Do not emit edge-cache directives. + None, +} + +/// Cache visibility for the browser-facing `Cache-Control` header. +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub enum CacheVisibility { + /// Response may be stored by shared caches when edge directives allow it. + Public, + /// Response is private to the requesting browser. + Private, +} + +impl CacheVisibility { + fn directive(self) -> &'static str { + match self { + Self::Public => "public", + Self::Private => "private", + } + } +} + +impl EdgeCacheHeader { + fn header_name(self) -> Option { + match self { + Self::SurrogateControl => Some(HEADER_SURROGATE_CONTROL), + Self::CdnCacheControl => Some(HEADER_CDN_CACHE_CONTROL), + Self::CloudflareCdnCacheControl => Some(HEADER_CLOUDFLARE_CDN_CACHE_CONTROL), + Self::SMaxageFallback | Self::None => None, + } + } +} + +/// Structured browser/edge cache policy. +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub struct CachePolicy { + /// Whether the browser-facing response is public or private. + pub visibility: CacheVisibility, + /// Browser cache TTL rendered as `max-age`. + pub browser_ttl: Option, + /// Shared edge cache TTL rendered as an edge header or `s-maxage` fallback. + pub edge_ttl: Option, + /// Optional `stale-while-revalidate` duration. + pub stale_while_revalidate: Option, + /// Optional `stale-if-error` duration. + pub stale_if_error: Option, + /// Whether to render `immutable` for browser caches. + pub immutable: bool, +} + +impl CachePolicy { + /// Create a public immutable policy for content-addressed static assets. + #[must_use] + pub const fn public_immutable(ttl: Duration) -> Self { + Self { + visibility: CacheVisibility::Public, + browser_ttl: Some(ttl), + edge_ttl: Some(ttl), + stale_while_revalidate: None, + stale_if_error: None, + immutable: true, + } + } + + /// Create the current short TSJS fallback policy for unversioned/mismatched requests. + #[must_use] + pub const fn public_short_with_stale( + ttl: Duration, + stale_while_revalidate: Duration, + stale_if_error: Duration, + ) -> Self { + Self { + visibility: CacheVisibility::Public, + browser_ttl: Some(ttl), + edge_ttl: Some(ttl), + stale_while_revalidate: Some(stale_while_revalidate), + stale_if_error: Some(stale_if_error), + immutable: false, + } + } + + /// Create a private revalidation policy for personalized browser responses. + #[must_use] + pub const fn private_revalidate() -> Self { + Self { + visibility: CacheVisibility::Private, + browser_ttl: Some(Duration::from_secs(0)), + edge_ttl: None, + stale_while_revalidate: None, + stale_if_error: None, + immutable: false, + } + } + + /// Render the browser-facing `Cache-Control` value. + #[must_use] + pub fn cache_control_value(self, edge_header: EdgeCacheHeader) -> String { + let mut directives = Vec::new(); + directives.push(self.visibility.directive().to_string()); + + if let Some(ttl) = self.browser_ttl { + directives.push(format!("max-age={}", ttl.as_secs())); + } + + if edge_header == EdgeCacheHeader::SMaxageFallback { + if let Some(ttl) = self + .edge_ttl + .filter(|_| self.visibility == CacheVisibility::Public) + { + directives.push(format!("s-maxage={}", ttl.as_secs())); + } + } + + if let Some(ttl) = self.stale_while_revalidate { + directives.push(format!("stale-while-revalidate={}", ttl.as_secs())); + } + + if let Some(ttl) = self.stale_if_error { + directives.push(format!("stale-if-error={}", ttl.as_secs())); + } + + if self.immutable && self.browser_ttl.is_some_and(|ttl| ttl.as_secs() > 0) { + directives.push("immutable".to_string()); + } + + directives.join(", ") + } + + /// Render the separate edge-cache header value, if this policy should emit one. + #[must_use] + pub fn edge_header_value(self, edge_header: EdgeCacheHeader) -> Option { + if self.visibility != CacheVisibility::Public { + return None; + } + if matches!( + edge_header, + EdgeCacheHeader::None | EdgeCacheHeader::SMaxageFallback + ) { + return None; + } + + let edge_ttl = self.edge_ttl?; + let mut directives = vec![format!("max-age={}", edge_ttl.as_secs())]; + + if let Some(ttl) = self.stale_while_revalidate { + directives.push(format!("stale-while-revalidate={}", ttl.as_secs())); + } + + if let Some(ttl) = self.stale_if_error { + directives.push(format!("stale-if-error={}", ttl.as_secs())); + } + + Some(directives.join(", ")) + } + + /// Apply the policy to response headers for the selected runtime edge header. + /// + /// # Panics + /// + /// Panics if the internally-rendered cache header values are not valid HTTP + /// header values. This should not happen because values are generated from + /// fixed directive names and numeric durations. + pub fn apply_to_headers(self, headers: &mut HeaderMap, edge_header: EdgeCacheHeader) { + let cache_control = self.cache_control_value(edge_header); + headers.insert( + header::CACHE_CONTROL, + HeaderValue::from_str(&cache_control) + .expect("should render a valid cache-control header"), + ); + + remove_edge_cache_headers(headers); + if let Some(header_name) = edge_header.header_name() { + if let Some(value) = self.edge_header_value(edge_header) { + headers.insert( + header_name, + HeaderValue::from_str(&value) + .expect("should render a valid edge cache-control header"), + ); + } + } + } +} + +/// Cache-control mode, including explicitly uncacheable responses. +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub enum CacheControlPolicy { + /// Apply a regular TTL-based cache policy. + Store(CachePolicy), + /// Apply `Cache-Control: no-store, private` and strip shared-cache headers. + NoStorePrivate, +} + +impl CacheControlPolicy { + /// Apply this cache-control mode to response headers. + /// + /// # Panics + /// + /// Panics if an internally-rendered cache header value is not valid. This + /// should not happen because values are generated from fixed directive names + /// and numeric durations. + pub fn apply_to_headers(self, headers: &mut HeaderMap, edge_header: EdgeCacheHeader) { + match self { + Self::Store(policy) => policy.apply_to_headers(headers, edge_header), + Self::NoStorePrivate => apply_no_store_private_to_headers(headers), + } + } +} + +impl From for CacheControlPolicy { + fn from(policy: CachePolicy) -> Self { + Self::Store(policy) + } +} + +/// Remove every runtime-specific shared-cache header owned by this crate. +pub fn remove_edge_cache_headers(headers: &mut HeaderMap) { + for name in EDGE_CACHE_HEADER_NAMES { + headers.remove(*name); + } +} + +/// Return true when `name` is an edge-cache header owned by this crate. +#[must_use] +pub fn is_edge_cache_header_name(name: &str) -> bool { + EDGE_CACHE_HEADER_NAMES + .iter() + .any(|candidate| name.eq_ignore_ascii_case(candidate)) +} + +/// Return true when a `Cache-Control` field value contains `directive`. +/// +/// Matching is directive-name exact and case-insensitive. Pseudo-directives such +/// as `not-private` or `no-storey` do not match `private` / `no-store`. +#[must_use] +pub fn cache_control_value_has_directive(value: &str, directive: &str) -> bool { + value.split(',').any(|part| { + let part = part.trim(); + let directive_name = part + .find(['=', ';']) + .map_or(part, |end| &part[..end]) + .trim(); + directive_name.eq_ignore_ascii_case(directive) + }) +} + +/// Return true when any `Cache-Control` header value contains `directive`. +#[must_use] +pub fn cache_control_headers_have_directive(headers: &HeaderMap, directive: &str) -> bool { + headers + .get_all(header::CACHE_CONTROL) + .iter() + .filter_map(|value| value.to_str().ok()) + .any(|value| cache_control_value_has_directive(value, directive)) +} + +/// Return true when response cache-control contains exact `private` or `no-store`. +#[must_use] +pub fn cache_control_headers_are_private_or_no_store(headers: &HeaderMap) -> bool { + cache_control_headers_have_directive(headers, "private") + || cache_control_headers_have_directive(headers, "no-store") +} + +/// Apply `Cache-Control: no-store, private` and strip all shared-cache headers. +/// +/// # Panics +/// +/// Panics if the fixed no-store cache-control value is not a valid HTTP header +/// value. This should not happen for a static ASCII value. +pub fn apply_no_store_private_to_headers(headers: &mut HeaderMap) { + headers.insert( + header::CACHE_CONTROL, + HeaderValue::from_static(NO_STORE_PRIVATE_CACHE_CONTROL), + ); + remove_edge_cache_headers(headers); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn public_immutable_renders_browser_and_fastly_headers() { + let policy = CachePolicy::public_immutable(Duration::from_secs(31_536_000)); + let mut headers = HeaderMap::new(); + + policy.apply_to_headers(&mut headers, EdgeCacheHeader::SurrogateControl); + + assert_eq!( + headers + .get(header::CACHE_CONTROL) + .and_then(|v| v.to_str().ok()), + Some("public, max-age=31536000, immutable"), + "should render immutable browser policy" + ); + assert_eq!( + headers + .get(HEADER_SURROGATE_CONTROL) + .and_then(|v| v.to_str().ok()), + Some("max-age=31536000"), + "should render Fastly edge TTL" + ); + } + + #[test] + fn s_maxage_fallback_renders_edge_ttl_inside_cache_control() { + let policy = CachePolicy::public_short_with_stale( + Duration::from_secs(300), + Duration::from_secs(60), + Duration::from_secs(86_400), + ); + + assert_eq!( + policy.cache_control_value(EdgeCacheHeader::SMaxageFallback), + "public, max-age=300, s-maxage=300, stale-while-revalidate=60, stale-if-error=86400", + "should render portable two-tier fallback" + ); + } + + #[test] + fn generic_cdn_header_renders_cdn_only_policy() { + let policy = CachePolicy::public_short_with_stale( + Duration::from_secs(300), + Duration::from_secs(60), + Duration::from_secs(86_400), + ); + let mut headers = HeaderMap::new(); + + policy.apply_to_headers(&mut headers, EdgeCacheHeader::CdnCacheControl); + + assert_eq!( + headers + .get(header::CACHE_CONTROL) + .and_then(|v| v.to_str().ok()), + Some("public, max-age=300, stale-while-revalidate=60, stale-if-error=86400"), + "should keep CDN TTL out of browser cache-control when using targeted CDN header" + ); + assert_eq!( + headers + .get(HEADER_CDN_CACHE_CONTROL) + .and_then(|v| v.to_str().ok()), + Some("max-age=300, stale-while-revalidate=60, stale-if-error=86400"), + "should render generic CDN cache policy" + ); + } + + #[test] + fn cloudflare_specific_header_renders_cdn_only_policy() { + let policy = CachePolicy::public_short_with_stale( + Duration::from_secs(300), + Duration::from_secs(60), + Duration::from_secs(86_400), + ); + let mut headers = HeaderMap::new(); + + policy.apply_to_headers(&mut headers, EdgeCacheHeader::CloudflareCdnCacheControl); + + assert_eq!( + headers + .get(header::CACHE_CONTROL) + .and_then(|v| v.to_str().ok()), + Some("public, max-age=300, stale-while-revalidate=60, stale-if-error=86400"), + "should keep CDN TTL out of browser cache-control when using targeted CDN header" + ); + assert_eq!( + headers + .get(HEADER_CLOUDFLARE_CDN_CACHE_CONTROL) + .and_then(|v| v.to_str().ok()), + Some("max-age=300, stale-while-revalidate=60, stale-if-error=86400"), + "should render Cloudflare-specific CDN cache policy" + ); + assert!( + headers.get(HEADER_CDN_CACHE_CONTROL).is_none(), + "should not also emit the generic CDN cache header" + ); + } + + #[test] + fn private_policy_removes_stale_edge_headers() { + let policy = CachePolicy::private_revalidate(); + let mut headers = HeaderMap::new(); + headers.insert( + HEADER_SURROGATE_CONTROL, + HeaderValue::from_static("max-age=60"), + ); + headers.insert( + HEADER_CDN_CACHE_CONTROL, + HeaderValue::from_static("max-age=60"), + ); + headers.insert( + HEADER_CLOUDFLARE_CDN_CACHE_CONTROL, + HeaderValue::from_static("max-age=60"), + ); + + policy.apply_to_headers(&mut headers, EdgeCacheHeader::SurrogateControl); + + assert_eq!( + headers + .get(header::CACHE_CONTROL) + .and_then(|v| v.to_str().ok()), + Some("private, max-age=0"), + "should render private browser policy" + ); + assert!( + headers.get(HEADER_SURROGATE_CONTROL).is_none(), + "should remove Fastly shared-cache headers for private responses" + ); + assert!( + headers.get(HEADER_CDN_CACHE_CONTROL).is_none(), + "should remove generic CDN cache headers for private responses" + ); + assert!( + headers.get(HEADER_CLOUDFLARE_CDN_CACHE_CONTROL).is_none(), + "should remove Cloudflare cache headers for private responses" + ); + } + + #[test] + fn no_store_policy_removes_stale_edge_headers() { + let mut headers = HeaderMap::new(); + headers.insert( + HEADER_SURROGATE_CONTROL, + HeaderValue::from_static("max-age=60"), + ); + headers.insert( + HEADER_FASTLY_SURROGATE_CONTROL, + HeaderValue::from_static("max-age=60"), + ); + headers.insert( + HEADER_CDN_CACHE_CONTROL, + HeaderValue::from_static("max-age=60"), + ); + headers.insert( + HEADER_CLOUDFLARE_CDN_CACHE_CONTROL, + HeaderValue::from_static("max-age=60"), + ); + + CacheControlPolicy::NoStorePrivate.apply_to_headers(&mut headers, EdgeCacheHeader::None); + + assert_eq!( + headers + .get(header::CACHE_CONTROL) + .and_then(|v| v.to_str().ok()), + Some(NO_STORE_PRIVATE_CACHE_CONTROL), + "should render no-store cache policy" + ); + assert!( + headers.get(HEADER_SURROGATE_CONTROL).is_none() + && headers.get(HEADER_FASTLY_SURROGATE_CONTROL).is_none() + && headers.get(HEADER_CDN_CACHE_CONTROL).is_none() + && headers.get(HEADER_CLOUDFLARE_CDN_CACHE_CONTROL).is_none(), + "should remove all shared-cache headers" + ); + } + + #[test] + fn immutable_is_omitted_without_positive_browser_ttl() { + let policy = CachePolicy { + visibility: CacheVisibility::Public, + browser_ttl: Some(Duration::from_secs(0)), + edge_ttl: Some(Duration::from_secs(60)), + stale_while_revalidate: None, + stale_if_error: None, + immutable: true, + }; + + assert_eq!( + policy.cache_control_value(EdgeCacheHeader::None), + "public, max-age=0", + "should not render immutable without a positive browser TTL" + ); + } + + #[test] + fn cache_control_directive_matching_is_exact() { + assert!( + cache_control_value_has_directive("public, max-age=60, No-Store", "no-store"), + "should match real no-store directives case-insensitively" + ); + assert!( + cache_control_value_has_directive("private=\"set-cookie\", max-age=0", "private"), + "should match directives with arguments" + ); + assert!( + !cache_control_value_has_directive("public, no-storey, not-private", "no-store"), + "should not match pseudo-directives by substring" + ); + assert!( + !cache_control_value_has_directive("public, no-storey, not-private", "private"), + "should not match pseudo-private directives by substring" + ); + } + + #[test] + fn cache_control_header_matching_checks_all_values() { + let mut headers = HeaderMap::new(); + headers.append(header::CACHE_CONTROL, HeaderValue::from_static("public")); + headers.append( + header::CACHE_CONTROL, + HeaderValue::from_static("max-age=60"), + ); + headers.append(header::CACHE_CONTROL, HeaderValue::from_static("no-store")); + + assert!( + cache_control_headers_are_private_or_no_store(&headers), + "should inspect every Cache-Control field value" + ); + } +} diff --git a/crates/trusted-server-core/src/consent/mod.rs b/crates/trusted-server-core/src/consent/mod.rs index ebb5e032c..fb49d513c 100644 --- a/crates/trusted-server-core/src/consent/mod.rs +++ b/crates/trusted-server-core/src/consent/mod.rs @@ -330,6 +330,26 @@ fn effective_tcf(ctx: &ConsentContext) -> Option<&types::TcfConsent> { }) } +/// Returns whether a server-side auction may be dispatched for this request. +/// +/// Fails closed for GDPR-relevant traffic: when an EU TCF signal is present +/// (`gdpr_applies`) **or** the request's geo jurisdiction is GDPR or unknown, +/// the effective TCF consent (standalone TC string or GPP EU TCF section) +/// must grant Purpose 1 (storage/access). Only requests from a known +/// non-GDPR jurisdiction with no EU TCF signal are freely allowed. +#[must_use] +pub fn consent_allows_server_side_auction(ctx: &ConsentContext) -> bool { + let requires_tcf_purpose1 = ctx.gdpr_applies + || matches!( + ctx.jurisdiction, + jurisdiction::Jurisdiction::Gdpr | jurisdiction::Jurisdiction::Unknown + ); + if !requires_tcf_purpose1 { + return true; + } + effective_tcf(ctx).is_some_and(|tcf| tcf.has_purpose_consent(1)) +} + /// Returns whether TCF consent allows EID transmission. #[must_use] fn allows_eid_transmission(tcf: &types::TcfConsent) -> bool { @@ -683,8 +703,8 @@ mod tests { use super::{ allows_ec_creation, apply_expiration_check, apply_tcf_conflict_resolution, - build_consent_context, build_context_from_signals, has_explicit_ec_withdrawal, - ConsentPipelineInput, + build_consent_context, build_context_from_signals, consent_allows_server_side_auction, + has_explicit_ec_withdrawal, ConsentPipelineInput, }; use crate::consent::jurisdiction::Jurisdiction; use crate::consent::types::{ @@ -785,6 +805,95 @@ mod tests { } } + #[test] + fn auction_allowed_for_known_non_gdpr_jurisdiction_without_tcf_signal() { + let ctx = ConsentContext { + jurisdiction: Jurisdiction::UsState("CA".to_owned()), + ..ConsentContext::default() + }; + + assert!( + consent_allows_server_side_auction(&ctx), + "known non-GDPR jurisdiction with no EU TCF signal should allow auction" + ); + } + + #[test] + fn auction_fails_closed_for_gdpr_jurisdiction_without_consent() { + let ctx = ConsentContext { + jurisdiction: Jurisdiction::Gdpr, + ..ConsentContext::default() + }; + + assert!( + !consent_allows_server_side_auction(&ctx), + "GDPR jurisdiction without a TCF signal should fail closed" + ); + } + + #[test] + fn auction_fails_closed_for_unknown_jurisdiction_without_consent() { + let ctx = ConsentContext { + jurisdiction: Jurisdiction::Unknown, + ..ConsentContext::default() + }; + + assert!( + !consent_allows_server_side_auction(&ctx), + "unknown jurisdiction without a TCF signal should fail closed" + ); + } + + #[test] + fn auction_fails_closed_when_tcf_signal_present_without_purpose1() { + let ctx = ConsentContext { + jurisdiction: Jurisdiction::UsState("CA".to_owned()), + gdpr_applies: true, + tcf: Some(TcfBuilder::new().with_storage(false).build()), + ..ConsentContext::default() + }; + + assert!( + !consent_allows_server_side_auction(&ctx), + "EU TCF signal without Purpose 1 should block auction even outside GDPR geo" + ); + } + + #[test] + fn auction_allowed_for_gdpr_jurisdiction_with_purpose1_consent() { + let ctx = ConsentContext { + jurisdiction: Jurisdiction::Gdpr, + gdpr_applies: true, + tcf: Some(TcfBuilder::new().with_storage(true).build()), + ..ConsentContext::default() + }; + + assert!( + consent_allows_server_side_auction(&ctx), + "GDPR jurisdiction with Purpose 1 consent should allow auction" + ); + } + + #[test] + fn auction_allowed_with_purpose1_via_gpp_eu_tcf_section() { + let ctx = ConsentContext { + jurisdiction: Jurisdiction::Gdpr, + gdpr_applies: true, + gpp: Some(GppConsent { + version: 1, + section_ids: vec![2], + eu_tcf: Some(TcfBuilder::new().with_storage(true).build()), + us_sale_opt_out: None, + }), + ..ConsentContext::default() + }; + + assert!( + consent_allows_server_side_auction(&ctx), + "Purpose 1 granted via GPP EU TCF section should allow auction" + ); + } + #[test] fn missing_geo_keeps_unknown_jurisdiction_and_blocks_ec_creation() { let req = build_request(); diff --git a/crates/trusted-server-core/src/constants.rs b/crates/trusted-server-core/src/constants.rs index b6750e453..ffcf4f034 100644 --- a/crates/trusted-server-core/src/constants.rs +++ b/crates/trusted-server-core/src/constants.rs @@ -1,6 +1,8 @@ use http::header::HeaderName; pub const COOKIE_TS_EC: &str = "ts-ec"; +/// Cookie written by the Trusted Server JS SDK containing a standard-base64-encoded +/// JSON array of Extended User IDs (`[{ source, uids }]`) from identity providers. pub const COOKIE_TS_EIDS: &str = "ts-eids"; pub const COOKIE_TS_TESTER: &str = "ts-tester"; pub const COOKIE_SHAREDID: &str = "sharedId"; diff --git a/crates/trusted-server-core/src/creative_opportunities.rs b/crates/trusted-server-core/src/creative_opportunities.rs new file mode 100644 index 000000000..0fb076d69 --- /dev/null +++ b/crates/trusted-server-core/src/creative_opportunities.rs @@ -0,0 +1,766 @@ +//! Configuration types and URL matching for creative opportunity slots. +//! +//! A [`CreativeOpportunitySlot`] describes a single ad placement: which pages +//! it appears on (via glob patterns), what ad formats it supports, and how it +//! maps to provider-specific identifiers such as GAM unit paths and APS slot IDs. + +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; + +use glob::Pattern; + +use crate::auction::types::{AdFormat, AdSlot, MediaType}; +use crate::price_bucket::PriceGranularity; +use crate::settings::vec_from_seq_or_map; + +/// Top-level configuration for the creative opportunities system. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct CreativeOpportunitiesConfig { + /// GAM network ID used to build default unit paths. + pub gam_network_id: String, + /// Maximum time in milliseconds to wait for the server-side auction before + /// closing the response body. + /// + /// The auction runs concurrently with HTML body streaming. Body content + /// above `` has already been delivered and painted before the hold + /// begins, so **FCP is not affected**. What this timeout bounds is the slip + /// on `DOMContentLoaded` and `window.load`: third-party scripts that hook + /// those events fire later by at most this duration. + /// + /// The worst case is a cache-hit page where the origin drains in <50 ms + /// but the auction takes the full timeout — the browser sits idle waiting + /// for ``. 500 ms is the recommended default and the hard upper + /// bound on DCL slip the publisher is willing to accept. + /// + /// When absent, falls back to `[auction].timeout_ms` from global config. + #[serde(default)] + pub auction_timeout_ms: Option, + /// Price granularity for header-bidding price bucketing. Defaults to `Dense`. + #[serde(default)] + pub price_granularity: PriceGranularity, + /// Slot templates. Empty vec = feature disabled (no auction fired, no globals injected). + #[serde(default, deserialize_with = "vec_from_seq_or_map")] + pub slot: Vec, +} + +impl CreativeOpportunitiesConfig { + /// Pre-compile glob patterns for all slots. Call once after deserialization. + pub fn compile_slots(&mut self) { + for slot in &mut self.slot { + slot.compile_patterns(); + } + } + + /// Validate all slot definitions after runtime preparation. + /// + /// # Errors + /// + /// Returns an error string when a slot has an invalid identifier, page + /// pattern set, format list, dimensions, or resolved GAM unit path. + pub fn validate_runtime(&self) -> Result<(), String> { + for slot in &self.slot { + slot.validate_runtime(&self.gam_network_id)?; + } + + Ok(()) + } +} + +/// A single ad placement opportunity on the publisher's site. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct CreativeOpportunitySlot { + /// Unique identifier for the slot (e.g., `"atf"`, `"below-fold-sidebar"`). + pub id: String, + /// Override for the GAM ad unit path. + /// + /// When absent, the path is derived as `//`. + pub gam_unit_path: Option, + /// Override for the HTML `div` element ID that will hold the creative. + /// + /// Defaults to [`id`](Self::id) when absent. + pub div_id: Option, + /// Glob patterns for page paths this slot should appear on. + pub page_patterns: Vec, + /// Supported ad formats (size + media type combinations). + pub formats: Vec, + /// Optional floor price in CPM (USD). + pub floor_price: Option, + /// Slot-level targeting key–value pairs forwarded to the auction. + #[serde(default)] + pub targeting: HashMap, + /// Provider-specific slot identifiers. + #[serde(default)] + pub providers: SlotProviders, + /// Pre-compiled [`page_patterns`](Self::page_patterns) for hot-path matching. + /// + /// Populated by [`compile_patterns`](Self::compile_patterns) once at startup + /// via [`CreativeOpportunitiesConfig::compile_slots`]. When this is + /// empty, [`matches_path`](Self::matches_path) falls back to compiling on + /// every call so callers that build slots by hand in tests + /// still work. + /// + /// `pub(crate)` rather than private so cross-module test helpers in this + /// crate can construct slots via struct-literal syntax with an empty cache. + #[serde(skip, default)] + pub(crate) compiled_patterns: Vec, +} + +impl CreativeOpportunitySlot { + /// Validate the slot shape after [`compile_patterns`](Self::compile_patterns) has run. + /// + /// # Errors + /// + /// Returns an error string when required slot fields are empty, invalid, + /// or semantically unusable at runtime. + pub fn validate_runtime(&self, gam_network_id: &str) -> Result<(), String> { + validate_slot_id(&self.id)?; + + if self.page_patterns.is_empty() { + return Err(format!( + "slot `{}` must include at least one page pattern", + self.id + )); + } + + if self.compiled_patterns.is_empty() { + return Err(format!( + "slot `{}` must include at least one valid page pattern", + self.id + )); + } + + if self.formats.is_empty() { + return Err(format!( + "slot `{}` must include at least one format", + self.id + )); + } + + for format in &self.formats { + format.validate_runtime(&self.id)?; + } + + // A negative floor silently disables minimum-price enforcement, and a + // non-finite floor (NaN/infinity) produces surprising all-pass/all-drop + // comparisons and an invalid OpenRTB `bidfloor`. + if let Some(floor_price) = self.floor_price { + if !floor_price.is_finite() || floor_price < 0.0 { + return Err(format!( + "slot `{}` floor_price must be a finite value >= 0.0, got {floor_price}", + self.id + )); + } + } + + // An explicit empty/whitespace `div_id` override is rejected: the + // injected JS resolves slots with `candidate.id.startsWith(slot.div_id)`, + // and every element id starts with the empty string, so an empty override + // would bind the slot to the first id-bearing element in the document. + if self + .div_id + .as_deref() + .is_some_and(|div_id| div_id.trim().is_empty()) + { + return Err(format!( + "slot `{}` div_id override must not be empty", + self.id + )); + } + + if self + .resolved_gam_unit_path(gam_network_id) + .trim() + .is_empty() + { + return Err(format!( + "slot `{}` resolved GAM unit path must not be empty", + self.id + )); + } + + Ok(()) + } + + /// Returns `true` if `path` matches any of this slot's [`page_patterns`](Self::page_patterns). + /// + /// Patterns use glob syntax (e.g., `"/2024/*"` matches any path under `/2024/`, + /// `"/"` matches only the root). A single `*` matches any sequence of characters + /// including path separators because `require_literal_separator` is `false`. + /// When a pattern contains `**` in a position the glob crate considers invalid + /// (e.g., `"/20**"` or `"b**"`), the `**` is normalised to `*` before matching — + /// prefer a valid single-`*` pattern over relying on this fallback. + /// + /// Patterns that cannot be compiled even after normalisation are silently skipped. + #[must_use] + pub fn matches_path(&self, path: &str) -> bool { + // Fast path: use the pre-compiled patterns when available so we don't + // re-run `Pattern::new` on every request. The vec is non-empty iff + // [`compile_patterns`](Self::compile_patterns) succeeded at load time + // and the slot has at least one pattern. + if !self.compiled_patterns.is_empty() { + return self.compiled_patterns.iter().any(|p| p.matches(path)); + } + + // Fallback for slots constructed by hand (tests, legacy callers that + // skip `compile_patterns`). Re-compiles on every call. + self.page_patterns + .iter() + .any(|pattern| match Pattern::new(pattern) { + Ok(p) => p.matches(path), + Err(_) => { + let normalised = pattern.replace("**", "*"); + Pattern::new(&normalised) + .map(|p| p.matches(path)) + .unwrap_or(false) + } + }) + } + + /// Compile [`page_patterns`](Self::page_patterns) into the + /// [`compiled_patterns`](Self::compiled_patterns) cache. + /// + /// Patterns that fail to compile (either directly or after the `**`→`*` + /// normalisation that [`matches_path`](Self::matches_path) does) are + /// silently skipped — the slot just becomes un-matchable, matching the + /// fallback behaviour. + /// + /// Idempotent: calling twice replaces the cache, so a slot list reloaded + /// at runtime won't accumulate stale patterns. + pub fn compile_patterns(&mut self) { + self.compiled_patterns = self + .page_patterns + .iter() + .filter_map(|pattern| { + match Pattern::new(pattern).or_else(|_| Pattern::new(&pattern.replace("**", "*"))) { + Ok(compiled) => Some(compiled), + Err(_) => { + // Build-time validation only requires *one* valid pattern + // per slot, so a mixed valid/invalid set passes the build + // with the bad pattern silently dropped here. Warn so the + // operator can see the slot matches fewer pages than + // configured. + log::warn!( + "slot `{}`: dropping page pattern '{}' — it does not compile as a glob", + self.id, + pattern + ); + None + } + } + }) + .collect(); + } + + /// Returns the GAM ad unit path for this slot. + /// + /// Uses the explicit [`gam_unit_path`](Self::gam_unit_path) override when set, + /// otherwise constructs `//`. + #[must_use] + pub fn resolved_gam_unit_path(&self, gam_network_id: &str) -> String { + self.gam_unit_path + .clone() + .unwrap_or_else(|| format!("/{}/{}", gam_network_id, self.id)) + } + + /// Returns the div element ID for this slot. + /// + /// Returns the [`div_id`](Self::div_id) override when set, otherwise returns [`id`](Self::id). + #[must_use] + pub fn resolved_div_id(&self) -> &str { + self.div_id.as_deref().unwrap_or(&self.id) + } + + /// Converts this slot into an [`AdSlot`] ready for use in an auction request. + /// + /// Provider-specific params (e.g., APS `slotID`, PBS bidder params) are wired + /// into the `bidders` map keyed by provider/bidder name. + /// + /// When [`PrebidSlotParams::bidders`] is empty, a `trustedServer` entry is + /// injected so [`PrebidAuctionProvider`] expands all `config.bidders` + /// automatically. The slot's `targeting.zone` value is forwarded as + /// `trustedServer.zone` so zone-aware bid-param override rules fire correctly. + #[must_use] + pub fn to_ad_slot(&self) -> AdSlot { + let mut bidders: HashMap = HashMap::new(); + if let Some(ref aps) = self.providers.aps { + bidders.insert( + "aps".to_string(), + serde_json::json!({ "slotID": aps.slot_id }), + ); + } + if let Some(ref prebid) = self.providers.prebid { + if prebid.bidders.is_empty() { + // No explicit per-bidder override: let the Prebid provider expand + // all config.bidders. The "trustedServer" key triggers + // expand_trusted_server_bidders in PrebidAuctionProvider, giving + // each bidder an empty params object that the override engine then + // fills with zone-aware rules. + let mut ts = serde_json::json!({ "bidderParams": {} }); + if let Some(zone) = self.targeting.get("zone") { + ts["zone"] = serde_json::Value::String(zone.clone()); + } + bidders.insert("trustedServer".to_string(), ts); + } else { + for (name, params) in &prebid.bidders { + bidders.insert(name.clone(), params.clone()); + } + } + } + AdSlot { + id: self.id.clone(), + formats: self + .formats + .iter() + .map(CreativeOpportunityFormat::to_ad_format) + .collect(), + floor_price: self.floor_price, + targeting: self + .targeting + .iter() + .map(|(k, v)| (k.clone(), serde_json::Value::String(v.clone()))) + .collect(), + bidders, + } + } +} + +/// An ad format combining a media type with pixel dimensions. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct CreativeOpportunityFormat { + /// Creative width in pixels. + pub width: u32, + /// Creative height in pixels. + pub height: u32, + /// Media type for this format. Defaults to `Banner`. + #[serde(default)] + pub media_type: MediaType, +} + +impl CreativeOpportunityFormat { + fn validate_runtime(&self, slot_id: &str) -> Result<(), String> { + if self.width == 0 || self.height == 0 { + return Err(format!( + "slot `{slot_id}` format must have positive width and height" + )); + } + + Ok(()) + } + + fn to_ad_format(&self) -> AdFormat { + AdFormat { + media_type: self.media_type.clone(), + width: self.width, + height: self.height, + } + } +} + +/// Provider-specific slot identifiers for a [`CreativeOpportunitySlot`]. +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct SlotProviders { + /// Amazon Publisher Services (APS/TAM) slot parameters. + pub aps: Option, + /// Prebid Server inline bidder parameters. + /// + /// When present, these are forwarded directly as `ext.prebid.bidder.*` + /// in the `OpenRTB` request, bypassing PBS stored request lookup for this slot. + /// Useful in development environments where stored requests are not available. + pub prebid: Option, +} + +/// APS-specific parameters for a slot. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ApsSlotParams { + /// The APS slot ID string used when making TAM bid requests. + pub slot_id: String, +} + +/// Inline Prebid Server bidder parameters for a slot. +/// +/// When `bidders` is empty, `to_ad_slot` injects a `trustedServer` entry so +/// [`PrebidAuctionProvider`] expands all `config.bidders` automatically. +/// When `bidders` is non-empty the map is forwarded verbatim, bypassing +/// automatic expansion (useful for slots that need explicit per-bidder params). +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct PrebidSlotParams { + /// Per-bidder inline params map. Bidder name → params object. + /// + /// Leave empty (or omit `bidders` in config) to auto-expand all + /// `config.bidders` with zone-aware param overrides. + /// + /// Note: when this map is non-empty it is forwarded verbatim, so a slot's + /// `targeting.zone` is **not** injected for these bidders (the `trustedServer` + /// expansion key that carries it is only added when `bidders` is empty). Set + /// explicit per-bidder params only when you do not need zone-aware overrides. + #[serde(default)] + pub bidders: HashMap, +} + +/// Validates that a slot ID contains only safe characters. +/// +/// Allowed characters: ASCII alphanumerics, underscores (`_`), and hyphens (`-`). +/// +/// # Errors +/// +/// Returns an error string when the ID is empty or contains disallowed characters. +pub fn validate_slot_id(id: &str) -> Result<(), String> { + if id.is_empty() { + return Err("slot id must not be empty".to_string()); + } + if id + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') + { + Ok(()) + } else { + Err(format!( + "slot id '{id}' contains invalid characters; only [A-Za-z0-9_-] allowed" + )) + } +} + +/// Returns all slots whose [`page_patterns`](CreativeOpportunitySlot::page_patterns) match `path`. +#[must_use] +pub fn match_slots<'a>( + slots: &'a [CreativeOpportunitySlot], + path: &str, +) -> Vec<&'a CreativeOpportunitySlot> { + slots.iter().filter(|s| s.matches_path(path)).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_slot(id: &str, patterns: Vec<&str>) -> CreativeOpportunitySlot { + CreativeOpportunitySlot { + id: id.to_string(), + gam_unit_path: None, + div_id: None, + page_patterns: patterns.into_iter().map(String::from).collect(), + formats: vec![CreativeOpportunityFormat { + width: 300, + height: 250, + media_type: crate::auction::types::MediaType::Banner, + }], + floor_price: Some(0.50), + targeting: Default::default(), + providers: Default::default(), + compiled_patterns: Vec::new(), + } + } + + #[test] + fn compile_patterns_populates_cache_and_match_uses_it() { + let mut slot = make_slot("atf", vec!["/20**", "/about"]); + assert!( + slot.compiled_patterns.is_empty(), + "freshly-built slot should have no compiled patterns" + ); + slot.compile_patterns(); + assert_eq!( + slot.compiled_patterns.len(), + 2, + "compile_patterns should populate one entry per page pattern" + ); + assert!( + slot.matches_path("/2024/01/my-article/"), + "matches_path should hit the compiled-pattern fast path" + ); + assert!( + slot.matches_path("/about"), + "matches_path should hit /about via the compiled cache" + ); + assert!( + !slot.matches_path("/contact"), + "matches_path should reject paths that match nothing in the cache" + ); + } + + #[test] + fn compile_slots_populates_every_slot() { + let mut slots = vec![make_slot("a", vec!["/a/*"]), make_slot("b", vec!["/b/*"])]; + for slot in &mut slots { + slot.compile_patterns(); + } + for slot in &slots { + assert_eq!( + slot.compiled_patterns.len(), + 1, + "every slot's patterns should be pre-compiled after compile_patterns()" + ); + } + } + + #[test] + fn glob_matches_article_path() { + let slot = make_slot("atf", vec!["/20**"]); + assert!( + slot.matches_path("/2024/01/my-article/"), + "should match article path" + ); + assert!(!slot.matches_path("/"), "should not match root"); + } + + #[test] + fn exact_match_homepage() { + let slot = make_slot("home", vec!["/"]); + assert!(slot.matches_path("/"), "should match root"); + assert!(!slot.matches_path("/about"), "should not match /about"); + } + + #[test] + fn slot_id_validates_alphanumeric() { + assert!(validate_slot_id("atf_sidebar_ad").is_ok()); + assert!(validate_slot_id("below-content-0").is_ok()); + assert!(validate_slot_id("").is_err(), "empty id should fail"); + assert!( + validate_slot_id("xss`. + /// Injected at `` open. `None` when no slots matched. + pub ad_slots_script: Option, + /// Shared auction result — written by auction task before HTML processing begins. + /// Handler reads this in `el.on_end_tag()` on the body element. + /// `None` means no auction ran; inject empty `tsjs.bids = {}` as fallback. + pub ad_bids_state: std::sync::Arc>>, /// Maximum bytes the post-processing accumulator may buffer before the /// processor aborts. Mirrors `publisher.max_buffered_body_bytes` so the - /// full-document buffering done for post-processors is bounded by the same - /// cap as the final [`crate::publisher::BoundedWriter`] sink. + /// full-document buffering done for post-processors is bounded. pub max_buffered_body_bytes: usize, } @@ -177,12 +189,37 @@ impl HtmlProcessorConfig { request_host: request_host.to_owned(), request_scheme: request_scheme.to_owned(), integrations: integrations.clone(), + 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, } } + + /// Attach the streaming-auction `"# + .to_string(), + ), + ad_bids_state: std::sync::Arc::new(std::sync::Mutex::new(None)), + max_buffered_body_bytes: 16 * 1024 * 1024, + }; + let mut processor = create_html_processor(config); + let output = processor + .process_chunk( + b"Tcontent", + true, + ) + .expect("should process"); + let html = std::str::from_utf8(&output).expect("should be utf8"); + assert!( + html.contains("window.tsjs=window.tsjs||{}"), + "should inject ad slots namespace at head-open" + ); + assert!( + html.contains(".adSlots=JSON.parse"), + "should inject adSlots at head-open" + ); + assert!( + !html.contains("__ts_request_id"), + "must NOT inject request_id" + ); + } + #[test] fn golden_script_tag_injected_at_head_start() { // The trusted-server script tag must be the FIRST child of . @@ -1367,6 +1495,69 @@ mod tests { ); } + #[test] + fn injects_ts_bids_before_body_close() { + let bids_script = r#""#; + let state = std::sync::Arc::new(std::sync::Mutex::new(Some(bids_script.to_string()))); + let config = HtmlProcessorConfig { + origin_host: "origin.example.com".to_string(), + request_host: "example.com".to_string(), + request_scheme: "https".to_string(), + integrations: IntegrationRegistry::empty_for_tests(), + ad_slots_script: Some( + r#""#.to_string(), + ), + ad_bids_state: state, + max_buffered_body_bytes: 16 * 1024 * 1024, + }; + let mut processor = create_html_processor(config); + let output = processor + .process_chunk(b"content", true) + .expect("should process"); + let html = std::str::from_utf8(&output).expect("should be utf8"); + assert!( + html.contains("window.tsjs=window.tsjs||{}"), + "should inject _ts namespace for bids before " + ); + assert!( + html.contains(".bids=JSON.parse"), + "should inject bids before " + ); + let bids_pos = html + .find("window.tsjs=window.tsjs||{}") + .expect("bids namespace should be in output"); + let body_close_pos = html.find("").expect(" should be in output"); + assert!(bids_pos < body_close_pos, "bids must appear before "); + } + + #[test] + fn injects_ts_bids_only_once_with_multiple_body_elements() { + let bids_script = r#""#; + let state = std::sync::Arc::new(std::sync::Mutex::new(Some(bids_script.to_string()))); + let config = HtmlProcessorConfig { + origin_host: "origin.example.com".to_string(), + request_host: "example.com".to_string(), + request_scheme: "https".to_string(), + integrations: IntegrationRegistry::empty_for_tests(), + ad_slots_script: Some( + r#""#.to_string(), + ), + ad_bids_state: state, + max_buffered_body_bytes: 16 * 1024 * 1024, + }; + let mut processor = create_html_processor(config); + // Malformed HTML with two elements (common in CMS template pages) + let output = processor + .process_chunk(b"content", true) + .expect("should process"); + let html = std::str::from_utf8(&output).expect("should be utf8"); + assert_eq!( + html.matches(".bids=JSON.parse").count(), + 1, + "should inject tsjs.bids exactly once even with multiple elements" + ); + } + #[test] fn golden_url_rewriting_replaces_origin_in_href() { // href attributes pointing at origin domain must be rewritten to proxy host. @@ -1384,6 +1575,8 @@ mod tests { request_host: request_host.to_string(), request_scheme: "https".to_string(), integrations: IntegrationRegistry::default(), + ad_slots_script: None, + ad_bids_state: std::sync::Arc::new(std::sync::Mutex::new(None)), max_buffered_body_bytes: 16 * 1024 * 1024, }; let mut processor = create_html_processor(config); @@ -1422,6 +1615,59 @@ mod tests { ); } + #[test] + fn injects_empty_ts_bids_when_slots_matched_but_auction_returned_nothing() { + // Slots matched (ad_slots_script is Some) but auction task never wrote a result + // (state is None) — e.g. auction timed out with zero bids. Fallback to {}. + let state = std::sync::Arc::new(std::sync::Mutex::new(None)); + let config = HtmlProcessorConfig { + origin_host: "origin.example.com".to_string(), + request_host: "example.com".to_string(), + request_scheme: "https".to_string(), + integrations: IntegrationRegistry::empty_for_tests(), + ad_slots_script: Some( + r#""#.to_string(), + ), + ad_bids_state: state, + max_buffered_body_bytes: 16 * 1024 * 1024, + }; + let mut processor = create_html_processor(config); + let output = processor + .process_chunk(b"content", true) + .expect("should process"); + let html = std::str::from_utf8(&output).expect("should be utf8"); + assert!( + html.contains(".bids=JSON.parse(\"{}\")"), + "should inject empty bids fallback when auction produced nothing" + ); + } + + #[test] + fn does_not_inject_ts_bids_when_no_slots_matched() { + // No slots matched this URL — ad_slots_script is None. tsjs.bids must be + // omitted entirely so the publisher's existing client-side GPT flow is + // unmodified (spec §8: "Existing client-side Prebid/GPT flow runs unmodified"). + let state = std::sync::Arc::new(std::sync::Mutex::new(None)); + let config = HtmlProcessorConfig { + origin_host: "origin.example.com".to_string(), + request_host: "example.com".to_string(), + request_scheme: "https".to_string(), + integrations: IntegrationRegistry::empty_for_tests(), + ad_slots_script: None, + ad_bids_state: state, + max_buffered_body_bytes: 16 * 1024 * 1024, + }; + let mut processor = create_html_processor(config); + let output = processor + .process_chunk(b"content", true) + .expect("should process"); + let html = std::str::from_utf8(&output).expect("should be utf8"); + assert!( + !html.contains(".bids=JSON.parse"), + "should NOT inject tsjs.bids when no slots matched" + ); + } + #[test] fn response_size_does_not_grow_disproportionately() { // Processing must not expand HTML by more than 1.1× (accounts for the diff --git a/crates/trusted-server-core/src/http_util.rs b/crates/trusted-server-core/src/http_util.rs index 74830ff9b..96d7a6cbd 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/adserver_mock.rs b/crates/trusted-server-core/src/integrations/adserver_mock.rs index 22a40cc98..d8b2a325f 100644 --- a/crates/trusted-server-core/src/integrations/adserver_mock.rs +++ b/crates/trusted-server-core/src/integrations/adserver_mock.rs @@ -95,6 +95,42 @@ impl IntegrationConfig for AdServerMockConfig { // Provider // ============================================================================ +/// Lookup index built from the original SSP bids, used while parsing the +/// mediation response to restore render/accounting fields that the mock +/// mediator endpoint does not echo back. +/// +/// Keyed by `(provider_name, slot_id, bidder_name)`. +type BidIndex = HashMap<(String, String, String), Bid>; + +/// Builds the SSP-bid lookup index from the orchestrator-provided +/// bidder responses on the auction context. +fn build_bid_index(bidder_responses: &[AuctionResponse]) -> BidIndex { + let mut index = BidIndex::new(); + for response in bidder_responses { + for bid in &response.bids { + let key = ( + response.provider.clone(), + bid.slot_id.clone(), + bid.bidder.clone(), + ); + // OpenRTB permits a seat to return multiple bids per imp. This index + // is last-write-wins, so a collision means an earlier bid's + // nurl/burl/cache_* are dropped and win/billing-URL restoration can + // be mis-attributed during mediation. Low severity for the mock + // mediator, but log it so the collision is visible. + if index.insert(key, bid.clone()).is_some() { + log::debug!( + "adserver_mock: duplicate bid for (provider '{}', slot '{}', bidder '{}'); keeping the last — win/billing URL restoration may be mis-attributed", + response.provider, + bid.slot_id, + bid.bidder + ); + } + } + } + index +} + /// Mock ad server mediator provider. pub struct AdServerMockProvider { config: AdServerMockConfig, @@ -140,36 +176,21 @@ impl AdServerMockProvider { .bids .iter() .map(|bid| { - // Check if this is an APS bid with encoded price (inferred from amznbid in metadata) - let encoded_price = bid - .metadata - .get("amznbid") - .and_then(|v| v.as_str()) - .map(String::from); - - if encoded_price.is_some() { - // APS bid - send encoded price for mediation to decode - json!({ - "imp_id": bid.slot_id, - "encoded_price": encoded_price, - "adm": bid.creative, - "w": bid.width, - "h": bid.height, - "crid": format!("{}-creative", bid.bidder), - "adomain": bid.adomain, - }) - } else { - // Regular bid with decoded price - json!({ - "imp_id": bid.slot_id, - "price": bid.price, - "adm": bid.creative, - "w": bid.width, - "h": bid.height, - "crid": format!("{}-creative", bid.bidder), - "adomain": bid.adomain, - }) - } + // Mocktioneer mediator always requires a numeric `price` field. + // APS bids carry price as an opaque encoded string (`amznbid`) + // that cannot be decoded client-side; use `bid.price` when set + // (a real decoded value) or fall back to a mock floor price for + // test/demo purposes. + let price = bid.price.unwrap_or(1.50); + json!({ + "imp_id": bid.slot_id, + "price": price, + "adm": bid.creative, + "w": bid.width, + "h": bid.height, + "crid": format!("{}-creative", bid.bidder), + "adomain": bid.adomain, + }) }) .collect(); @@ -234,8 +255,18 @@ impl AdServerMockProvider { /// Parse `OpenRTB` response from mediation endpoint. /// Mediation returns decoded prices for all bids (including APS bids that were encoded). - fn parse_mediation_response(&self, json: &Json, response_time_ms: u64) -> AuctionResponse { - // Parse OpenRTB response + /// + /// `bid_index` is the SSP-bid lookup built from the auction context's + /// bidder responses. The mock mediator does not echo render/accounting + /// fields back, so they are restored from the index using + /// `(seat, impid, bidder)` where bidder is recovered from the echoed `crid` + /// field (`"{bidder}-creative"` format set during request construction). + fn parse_mediation_response( + &self, + json: &Json, + response_time_ms: u64, + bid_index: &BidIndex, + ) -> AuctionResponse { let empty_array = vec![]; let seatbid = json["seatbid"].as_array().unwrap_or(&empty_array); @@ -247,22 +278,50 @@ impl AdServerMockProvider { let bids = seat["bid"].as_array().unwrap_or(&empty_bids); for bid in bids { - // Mediation layer returns decoded prices for all bids + let slot_id = bid["impid"].as_str().unwrap_or("").to_string(); + + // Recover bidder name from crid ("{bidder}-creative") to look up the + // original SSP bid and restore render/accounting fields the mediator drops. + let crid = bid["crid"].as_str().unwrap_or(""); + let bidder = crid.strip_suffix("-creative").unwrap_or_else(|| { + log::debug!( + "adserver_mock: crid '{crid}' does not match '-creative'; render/accounting fields may be missing" + ); + "" + }); + let key = (seat_name.to_string(), slot_id.clone(), bidder.to_string()); + let original = bid_index.get(&key); + let restored_bidder = + original.map_or_else(|| seat_name.to_string(), |b| b.bidder.clone()); + + let width = bid["w"].as_u64().unwrap_or(0) as u32; + let height = bid["h"].as_u64().unwrap_or(0) as u32; + if width == 0 || height == 0 { + log::debug!( + "adserver_mock: bid for slot '{slot_id}' has zero dimension ({width}×{height}), skipping" + ); + continue; + } + all_bids.push(Bid { - slot_id: bid["impid"].as_str().unwrap_or("").to_string(), - price: bid["price"].as_f64(), // Now properly decoded by mediation + slot_id, + price: bid["price"].as_f64(), currency: "USD".to_string(), creative: bid["adm"].as_str().map(String::from), - width: bid["w"].as_u64().unwrap_or(0) as u32, - height: bid["h"].as_u64().unwrap_or(0) as u32, - bidder: seat_name.to_string(), + width, + height, + bidder: restored_bidder, adomain: bid["adomain"].as_array().map(|arr| { arr.iter() .filter_map(|v| v.as_str().map(String::from)) .collect() }), - nurl: None, - burl: None, + nurl: original.and_then(|b| b.nurl.clone()), + burl: original.and_then(|b| b.burl.clone()), + ad_id: original.and_then(|b| b.ad_id.clone()), + cache_id: original.and_then(|b| b.cache_id.clone()), + cache_host: original.and_then(|b| b.cache_host.clone()), + cache_path: original.and_then(|b| b.cache_path.clone()), metadata: HashMap::new(), }); } @@ -274,6 +333,53 @@ impl AdServerMockProvider { AuctionResponse::success("adserver_mock", all_bids, response_time_ms) } } + + /// Shared parse body for the context-aware and context-less trait methods. + /// + /// # Errors + /// + /// Returns an error when the mediation response body is not valid JSON. + async fn parse_response_inner( + &self, + response: PlatformResponse, + response_time_ms: u64, + bid_index: &BidIndex, + ) -> Result> { + let response = response.response; + + if !response.status().is_success() { + log::warn!("AdServer Mock returned non-success: {}", response.status()); + return Ok(AuctionResponse::error("adserver_mock", response_time_ms)); + } + + // collect_response_bounded caps memory from misbehaving providers. + let body_bytes = collect_response_bounded( + response.into_body(), + UPSTREAM_RTB_MAX_RESPONSE_BYTES, + "adserver_mock", + ) + .await + .change_context(TrustedServerError::Auction { + message: "Failed to read AdServer Mock response body".to_string(), + })?; + let response_json: Json = + serde_json::from_slice(&body_bytes).change_context(TrustedServerError::Auction { + message: "Failed to parse mediation response".to_string(), + })?; + + log::trace!("AdServer Mock response: {:?}", response_json); + + let auction_response = + self.parse_mediation_response(&response_json, response_time_ms, bid_index); + + log::info!( + "AdServer Mock returned {} bids in {}ms", + auction_response.bids.len(), + response_time_ms + ); + + Ok(auction_response) + } } #[async_trait(?Send)] @@ -378,39 +484,28 @@ impl AuctionProvider for AdServerMockProvider { response: PlatformResponse, response_time_ms: u64, ) -> Result> { - let response = response.response; - - if !response.status().is_success() { - log::warn!("AdServer Mock returned non-success: {}", response.status()); - return Ok(AuctionResponse::error("adserver_mock", response_time_ms)); - } - - // collect_response_bounded caps memory from misbehaving providers. - let body_bytes = collect_response_bounded( - response.into_body(), - UPSTREAM_RTB_MAX_RESPONSE_BYTES, - "adserver_mock", - ) - .await - .change_context(TrustedServerError::Auction { - message: "Failed to read AdServer Mock response body".to_string(), - })?; - let response_json: Json = - serde_json::from_slice(&body_bytes).change_context(TrustedServerError::Auction { - message: "Failed to parse mediation response".to_string(), - })?; - - log::trace!("AdServer Mock response: {:?}", response_json); - - let auction_response = self.parse_mediation_response(&response_json, response_time_ms); - - log::info!( - "AdServer Mock returned {} bids in {}ms", - auction_response.bids.len(), - response_time_ms - ); + // No auction context available — nurl/burl/ad_id restoration from the + // original SSP bids is skipped. The orchestrator always calls + // [`parse_response_with_context`], so this path only serves callers + // outside the orchestration flow. + log::debug!("adserver_mock: parsing without context — SSP bid metadata unavailable"); + self.parse_response_inner(response, response_time_ms, &BidIndex::new()) + .await + } - Ok(auction_response) + async fn parse_response_with_context( + &self, + response: PlatformResponse, + response_time_ms: u64, + _request: &AuctionRequest, + context: &AuctionContext<'_>, + ) -> Result> { + // Rebuild the SSP-bid lookup from the orchestrator-provided bidder + // responses so nurl/burl/ad_id survive mediation. Request-scoped data + // travels on the context instead of provider-instance state. + let bid_index = build_bid_index(context.provider_responses.unwrap_or(&[])); + self.parse_response_inner(response, response_time_ms, &bid_index) + .await } fn supports_media_type(&self, media_type: &MediaType) -> bool { @@ -547,6 +642,10 @@ mod tests { adomain: Some(vec!["amazon.com".to_string()]), nurl: None, burl: None, + ad_id: None, + cache_id: None, + cache_host: None, + cache_path: None, metadata: HashMap::new(), }], response_time_ms: 150, @@ -564,8 +663,12 @@ mod tests { height: 90, bidder: "test-bidder".to_string(), adomain: None, - nurl: None, - burl: None, + nurl: Some("https://ssp.example/win?id=mock-bid-001".to_string()), + burl: Some("https://ssp.example/bill?id=mock-bid-001".to_string()), + ad_id: Some("mock-bid-001".to_string()), + cache_id: None, + cache_host: None, + cache_path: None, metadata: HashMap::new(), }], response_time_ms: 120, @@ -623,7 +726,8 @@ mod tests { "cur": "USD" }); - let auction_response = provider.parse_mediation_response(&mediation_response, 200); + let auction_response = + provider.parse_mediation_response(&mediation_response, 200, &BidIndex::new()); assert_eq!(auction_response.provider, "adserver_mock"); assert_eq!(auction_response.status, BidStatus::Success); @@ -638,6 +742,98 @@ mod tests { assert_eq!(bid.height, 90); } + #[test] + fn parse_mediation_response_restores_original_bid_render_fields() { + let provider = AdServerMockProvider::new(AdServerMockConfig::default()); + let mediation_response = json!({ + "id": "test-auction-123", + "seatbid": [ + { + "seat": "prebid", + "bid": [ + { + "id": "mediated-bid-001", + "impid": "header-banner", + "price": 0.20, + "adm": "
Mediated Ad
", + "w": 728, + "h": 90, + "crid": "mocktioneer-creative", + "adomain": ["example.com"] + } + ] + } + ], + "cur": "USD" + }); + let mut bid_index = BidIndex::new(); + bid_index.insert( + ( + "prebid".to_string(), + "header-banner".to_string(), + "mocktioneer".to_string(), + ), + Bid { + slot_id: "header-banner".to_string(), + price: Some(0.20), + currency: "USD".to_string(), + creative: Some("
Original Ad
".to_string()), + adomain: Some(vec!["example.com".to_string()]), + bidder: "mocktioneer".to_string(), + width: 728, + height: 90, + nurl: Some("https://ssp.example/win".to_string()), + burl: Some("https://ssp.example/bill".to_string()), + ad_id: Some("bid-impression-id".to_string()), + cache_id: Some("cache-uuid".to_string()), + cache_host: Some("cache.example".to_string()), + cache_path: Some("/cache".to_string()), + metadata: HashMap::new(), + }, + ); + + let auction_response = + provider.parse_mediation_response(&mediation_response, 42, &bid_index); + + assert_eq!(auction_response.status, BidStatus::Success); + assert_eq!(auction_response.bids.len(), 1); + let bid = &auction_response.bids[0]; + assert_eq!( + bid.bidder, "mocktioneer", + "should preserve underlying bidder for hb_bidder targeting" + ); + assert_eq!( + bid.nurl.as_deref(), + Some("https://ssp.example/win"), + "should restore nurl" + ); + assert_eq!( + bid.burl.as_deref(), + Some("https://ssp.example/bill"), + "should restore burl" + ); + assert_eq!( + bid.ad_id.as_deref(), + Some("bid-impression-id"), + "should restore ad_id" + ); + assert_eq!( + bid.cache_id.as_deref(), + Some("cache-uuid"), + "should restore PBS cache UUID" + ); + assert_eq!( + bid.cache_host.as_deref(), + Some("cache.example"), + "should restore PBS cache host" + ); + assert_eq!( + bid.cache_path.as_deref(), + Some("/cache"), + "should restore PBS cache path" + ); + } + #[test] fn test_parse_empty_mediation_response() { let config = AdServerMockConfig::default(); @@ -649,7 +845,8 @@ mod tests { "cur": "USD" }); - let auction_response = provider.parse_mediation_response(&mediation_response, 100); + let auction_response = + provider.parse_mediation_response(&mediation_response, 100, &BidIndex::new()); assert_eq!(auction_response.status, BidStatus::NoBid); assert_eq!(auction_response.bids.len(), 0); @@ -706,6 +903,10 @@ mod tests { adomain: Some(vec!["amazon.com".to_string()]), nurl: None, burl: None, + ad_id: None, + cache_id: None, + cache_host: None, + cache_path: None, metadata: aps_metadata, }], response_time_ms: 100, @@ -726,20 +927,15 @@ mod tests { let bid = &bidder_resp["bids"][0]; assert_eq!(bid["imp_id"], "slot-1"); - // Key assertions for APS-style encoded price bids: - // 1. Should NOT have "price" field (or it should be null) - assert!( - bid["price"].is_null(), - "APS bids should not have decoded price, got: {:?}", - bid["price"] - ); - // 2. Should have "encoded_price" field + // APS bids have no decoded price (bid.price == None), so the mock floor + // price (1.50) is used. Mocktioneer requires a numeric price field and + // does not accept an opaque encoded_price string. assert_eq!( - bid["encoded_price"].as_str(), - Some("encoded-price-value"), - "APS bids should have encoded_price from metadata" + bid["price"].as_f64(), + Some(1.50), + "APS bids with no decoded price should fall back to mock floor price 1.50" ); - // 3. adm should be null (not a string) + // adm should be null (not a string) assert!( bid["adm"].is_null(), "Creative-less bids should have null adm, got: {:?}", @@ -847,7 +1043,8 @@ mod tests { "cur": "USD" }); - let auction_response = provider.parse_mediation_response(&mediation_response, 200); + let auction_response = + provider.parse_mediation_response(&mediation_response, 200, &BidIndex::new()); assert_eq!(auction_response.status, BidStatus::Success); assert_eq!(auction_response.bids.len(), 2); diff --git a/crates/trusted-server-core/src/integrations/aps.rs b/crates/trusted-server-core/src/integrations/aps.rs index faf2aa0a6..edee6133b 100644 --- a/crates/trusted-server-core/src/integrations/aps.rs +++ b/crates/trusted-server-core/src/integrations/aps.rs @@ -303,15 +303,61 @@ impl ApsAuctionProvider { Self { config } } + /// Resolve the APS-specific slot ID for a slot. + /// + /// Uses the APS slot ID from `[slot.providers.aps]` if configured; falls + /// back to the creative-opportunity slot ID otherwise. + fn aps_slot_id(slot: &crate::auction::types::AdSlot) -> String { + slot.bidders + .get("aps") + .and_then(|p| p.get("slotID")) + .and_then(|v| v.as_str()) + .unwrap_or(&slot.id) + .to_string() + } + + /// Build the APS slot ID → creative-opportunity slot ID map for `request`. + /// + /// Derived from the [`AuctionRequest`] on every call so the mapping stays + /// request-scoped: the provider instance is shared across concurrent + /// auctions on non-Fastly adapters, and instance state would let + /// overlapping auctions overwrite or consume each other's maps. + fn build_slot_id_map(request: &AuctionRequest) -> HashMap { + let mut slot_id_map: HashMap = HashMap::new(); + for slot in &request.slots { + let aps_slot_id = Self::aps_slot_id(slot); + // Last-write-wins: two slots configuring the same + // `[bidders.aps].slotID` would remap one slot's bids to the + // wrong creative slot. Log the collision so a misconfiguration + // is diagnosable, mirroring the build_bid_index collision log. + if let Some(previous_slot_id) = slot_id_map.insert(aps_slot_id.clone(), slot.id.clone()) + { + log::debug!( + "APS slot ID '{aps_slot_id}' maps to multiple creative slots \ + ('{previous_slot_id}' overwritten by '{}'); bids for this APS \ + slot will resolve to the last one", + slot.id, + ); + } + } + slot_id_map + } + /// Convert unified `AuctionRequest` to APS TAM bid request format. /// /// Populates consent fields (GDPR, US Privacy, GPP) from the /// [`ConsentContext`](crate::consent::ConsentContext) attached to the request. - fn to_aps_request(&self, request: &AuctionRequest) -> ApsBidRequest { + /// + /// `timeout_ms` is the effective auction budget for this provider (already + /// capped by the orchestrator) — advertised to APS so it never expects more + /// time than the edge will actually wait. + fn to_aps_request(&self, request: &AuctionRequest, timeout_ms: u32) -> ApsBidRequest { let slots: Vec = request .slots .iter() .map(|slot| { + let aps_slot_id = Self::aps_slot_id(slot); + // Extract sizes from banner formats let sizes: Vec<[u32; 2]> = slot .formats @@ -321,7 +367,7 @@ impl ApsAuctionProvider { .collect(); ApsSlot { - slot_id: slot.id.clone(), + slot_id: aps_slot_id, sizes, slot_name: Some(slot.id.clone()), } @@ -350,7 +396,7 @@ impl ApsAuctionProvider { slots, page_url: request.publisher.page_url.clone(), user_agent: request.device.as_ref().and_then(|d| d.user_agent.clone()), - timeout: Some(self.config.timeout_ms), + timeout: Some(timeout_ms), gdpr, us_privacy, gpp, @@ -391,7 +437,17 @@ impl ApsAuctionProvider { } // Parse size from "WxH" format - let (width, height) = Self::parse_size(&slot.size).unwrap_or((0, 0)); + let (width, height) = match Self::parse_size(&slot.size) { + Some(dims) => dims, + None => { + log::debug!( + "APS: slot '{}' has malformed size '{}', skipping", + slot.slot_id, + slot.size + ); + return Err(()); + } + }; // Build metadata from targeting keys - includes encoded price for mediation let mut metadata = HashMap::new(); @@ -425,12 +481,24 @@ impl ApsAuctionProvider { height, nurl: None, // Real APS uses client-side event tracking burl: None, + ad_id: None, + cache_id: None, + cache_host: None, + cache_path: None, metadata, }) } /// Parse APS TAM response into unified `AuctionResponse`. - fn parse_aps_response(&self, json: &Json, response_time_ms: u64) -> AuctionResponse { + /// + /// `slot_map` is the request-scoped APS slot ID → creative-opportunity + /// slot ID mapping built by [`Self::build_slot_id_map`]. + fn parse_aps_response( + &self, + json: &Json, + response_time_ms: u64, + slot_map: &HashMap, + ) -> AuctionResponse { let mut bids = Vec::new(); // Try to parse as ApsBidResponse with contextual wrapper @@ -442,7 +510,13 @@ impl ApsAuctionProvider { for slot in aps_response.contextual.slots { match self.parse_aps_slot(&slot) { - Ok(bid) => { + Ok(mut bid) => { + // Remap APS slot ID (e.g. "aps-slot-atf-sidebar") back to the + // creative-opportunity slot ID (e.g. "atf_sidebar_ad") so the + // mediator and bid_map can match by creative slot ID. + if let Some(creative_id) = slot_map.get(&bid.slot_id) { + bid.slot_id = creative_id.clone(); + } let encoded_price = bid .metadata .get("amznbid") @@ -474,6 +548,51 @@ impl ApsAuctionProvider { AuctionResponse::success("aps", bids, response_time_ms) } } + + /// Shared parse path for both trait entry points. + /// + /// # Errors + /// + /// Returns an error when the response body cannot be read or is not valid JSON. + async fn parse_response_inner( + &self, + response: PlatformResponse, + response_time_ms: u64, + slot_map: &HashMap, + ) -> Result> { + let response = response.response; + + // Check status code + if !response.status().is_success() { + log::warn!("APS returned non-success status: {}", response.status()); + return Ok(AuctionResponse::error("aps", response_time_ms)); + } + + // Parse response body — collect_response_bounded caps memory from misbehaving providers. + let body_bytes = + collect_response_bounded(response.into_body(), UPSTREAM_RTB_MAX_RESPONSE_BYTES, "aps") + .await + .change_context(TrustedServerError::Auction { + message: "Failed to read APS response body".to_string(), + })?; + let response_json: Json = + serde_json::from_slice(&body_bytes).change_context(TrustedServerError::Auction { + message: "Failed to parse APS response JSON".to_string(), + })?; + + log::trace!("APS: received response: {:?}", response_json); + + // Transform to unified format + let auction_response = self.parse_aps_response(&response_json, response_time_ms, slot_map); + + log::info!( + "APS returned {} bids in {}ms", + auction_response.bids.len(), + response_time_ms + ); + + Ok(auction_response) + } } #[async_trait(?Send)] @@ -493,8 +612,12 @@ impl AuctionProvider for ApsAuctionProvider { self.config.pub_id ); - // Transform to APS format - let aps_request = self.to_aps_request(request); + // Transform to APS format. `context.timeout_ms` is the effective budget + // the orchestrator granted this provider — the payload must advertise + // the same deadline the edge backend enforces below. The APS-slot-ID → + // creative-slot-ID remapping is rebuilt from the request in + // `parse_response_with_context`, keeping it request-scoped. + let aps_request = self.to_aps_request(request, context.timeout_ms); // Serialize to JSON let aps_json = @@ -551,38 +674,27 @@ impl AuctionProvider for ApsAuctionProvider { response: PlatformResponse, response_time_ms: u64, ) -> Result> { - let response = response.response; - - // Check status code - if !response.status().is_success() { - log::warn!("APS returned non-success status: {}", response.status()); - return Ok(AuctionResponse::error("aps", response_time_ms)); - } - - // Parse response body — collect_response_bounded caps memory from misbehaving providers. - let body_bytes = - collect_response_bounded(response.into_body(), UPSTREAM_RTB_MAX_RESPONSE_BYTES, "aps") - .await - .change_context(TrustedServerError::Auction { - message: "Failed to read APS response body".to_string(), - })?; - let response_json: Json = - serde_json::from_slice(&body_bytes).change_context(TrustedServerError::Auction { - message: "Failed to parse APS response JSON".to_string(), - })?; - - log::trace!("APS: received response: {:?}", response_json); - - // Transform to unified format - let auction_response = self.parse_aps_response(&response_json, response_time_ms); - - log::info!( - "APS returned {} bids in {}ms", - auction_response.bids.len(), - response_time_ms - ); + // No auction request available — bids keep their raw APS slot IDs. The + // orchestrator always calls [`Self::parse_response_with_context`], so + // this path only serves callers outside the orchestration flow. + log::debug!("APS: parsing without request context — slot ID remapping unavailable"); + self.parse_response_inner(response, response_time_ms, &HashMap::new()) + .await + } - Ok(auction_response) + async fn parse_response_with_context( + &self, + response: PlatformResponse, + response_time_ms: u64, + request: &AuctionRequest, + _context: &AuctionContext<'_>, + ) -> Result> { + // Rebuild the APS-slot-ID → creative-slot-ID map from the auction + // request so overlapping auctions on shared provider instances cannot + // overwrite or consume each other's mappings. + let slot_map = Self::build_slot_id_map(request); + self.parse_response_inner(response, response_time_ms, &slot_map) + .await } fn supports_media_type(&self, media_type: &MediaType) -> bool { @@ -729,7 +841,7 @@ mod tests { let provider = ApsAuctionProvider::new(config); let auction_request = create_test_auction_request(); - let aps_request = provider.to_aps_request(&auction_request); + let aps_request = provider.to_aps_request(&auction_request, 800); // Verify basic fields assert_eq!(aps_request.pub_id, "5128"); @@ -755,6 +867,82 @@ mod tests { assert_eq!(slot2.sizes[0], [300, 250]); } + #[test] + fn aps_slot_id_from_bidders_map_used_in_request_and_remapped_in_response() { + use serde_json::json; + + let config = ApsConfig { + enabled: true, + pub_id: "5128".to_string(), + endpoint: default_endpoint(), + timeout_ms: 800, + }; + let provider = ApsAuctionProvider::new(config); + + let mut bidders = HashMap::new(); + bidders.insert( + "aps".to_string(), + json!({ "slotID": "aps-slot-atf-sidebar" }), + ); + let request = AuctionRequest { + id: "test".to_string(), + slots: vec![AdSlot { + id: "atf_sidebar_ad".to_string(), + formats: vec![AdFormat { + media_type: MediaType::Banner, + width: 300, + height: 250, + }], + floor_price: None, + targeting: HashMap::new(), + bidders, + }], + publisher: PublisherInfo { + domain: "example.com".to_string(), + page_url: None, + }, + user: UserInfo { + id: Some("user-1".to_string()), + consent: None, + eids: None, + }, + device: None, + site: None, + context: HashMap::new(), + }; + + let aps_request = provider.to_aps_request(&request, 800); + assert_eq!( + aps_request.slots[0].slot_id, "aps-slot-atf-sidebar", + "should send configured APS slot ID to APS" + ); + let slot_id_map = ApsAuctionProvider::build_slot_id_map(&request); + assert_eq!( + slot_id_map.get("aps-slot-atf-sidebar").map(String::as_str), + Some("atf_sidebar_ad"), + "should build reverse map from APS slot ID to creative slot ID" + ); + + let aps_response = json!({ + "contextual": { + "slots": [{ + "slotID": "aps-slot-atf-sidebar", + "size": "300x250", + "fif": "1", + "amznbid": "1gtm3q", + "meta": ["slotID"] + }] + } + }); + + let response = provider.parse_aps_response(&aps_response, 100, &slot_id_map); + assert_eq!(response.bids.len(), 1, "should parse one bid"); + assert_eq!( + response.bids[0].slot_id, "atf_sidebar_ad", + "bid slot_id should be remapped to creative slot ID" + ); + } + #[test] fn test_aps_response_parsing_success() { let config = ApsConfig { @@ -807,7 +995,7 @@ mod tests { } }); - let auction_response = provider.parse_aps_response(&aps_response, 150); + let auction_response = provider.parse_aps_response(&aps_response, 150, &HashMap::new()); // Verify response assert_eq!(auction_response.provider, "aps"); @@ -855,7 +1043,7 @@ mod tests { } }); - let auction_response = provider.parse_aps_response(&aps_response, 100); + let auction_response = provider.parse_aps_response(&aps_response, 100, &HashMap::new()); assert_eq!(auction_response.provider, "aps"); assert_eq!(auction_response.status, BidStatus::NoBid); @@ -897,7 +1085,7 @@ mod tests { } }); - let auction_response = provider.parse_aps_response(&aps_response, 100); + let auction_response = provider.parse_aps_response(&aps_response, 100, &HashMap::new()); // Should return no-bid since all slots are invalid assert_eq!(auction_response.status, BidStatus::NoBid); @@ -961,6 +1149,28 @@ mod tests { assert!(!provider.supports_media_type(&MediaType::Native)); } + #[test] + fn aps_payload_timeout_uses_effective_auction_budget_not_provider_config() { + // Provider config says 1000ms but the auction budget grants only 500ms — + // the payload must advertise the tighter effective deadline. + let config = ApsConfig { + enabled: true, + pub_id: "5128".to_string(), + endpoint: default_endpoint(), + timeout_ms: 1000, + }; + let provider = ApsAuctionProvider::new(config); + let request = create_test_auction_request(); + + let aps_request = provider.to_aps_request(&request, 500); + + assert_eq!( + aps_request.timeout, + Some(500), + "should advertise the effective auction budget, not the provider config timeout" + ); + } + #[test] fn test_aps_request_includes_consent_fields() { use crate::consent::ConsentContext; @@ -983,7 +1193,7 @@ mod tests { ..Default::default() }); - let aps_request = provider.to_aps_request(&request); + let aps_request = provider.to_aps_request(&request, 800); // Verify GDPR consent let gdpr = aps_request.gdpr.expect("should have gdpr"); @@ -1012,7 +1222,7 @@ mod tests { let provider = ApsAuctionProvider::new(config); let request = create_test_auction_request(); // consent is None - let aps_request = provider.to_aps_request(&request); + let aps_request = provider.to_aps_request(&request, 800); assert!(aps_request.gdpr.is_none()); assert!(aps_request.us_privacy.is_none()); @@ -1039,7 +1249,7 @@ mod tests { ..Default::default() }); - let aps_request = provider.to_aps_request(&request); + let aps_request = provider.to_aps_request(&request, 800); let json = serde_json::to_value(&aps_request).expect("should serialize"); // GDPR fields present @@ -1101,4 +1311,120 @@ mod tests { Some("encoded-price") ); } + + fn single_slot_request( + auction_id: &str, + aps_slot_id: &str, + creative_id: &str, + ) -> AuctionRequest { + let mut bidders = HashMap::new(); + bidders.insert("aps".to_string(), json!({ "slotID": aps_slot_id })); + AuctionRequest { + id: auction_id.to_string(), + slots: vec![AdSlot { + id: creative_id.to_string(), + formats: vec![AdFormat { + media_type: MediaType::Banner, + width: 300, + height: 250, + }], + floor_price: None, + targeting: HashMap::new(), + bidders, + }], + publisher: PublisherInfo { + domain: "example.com".to_string(), + page_url: None, + }, + user: UserInfo { + id: None, + consent: None, + eids: None, + }, + device: None, + site: None, + context: HashMap::new(), + } + } + + fn aps_platform_response(aps_slot_id: &str) -> PlatformResponse { + let body = json!({ + "contextual": { + "slots": [{ + "slotID": aps_slot_id, + "size": "300x250", + "fif": "1", + "amznbid": "1gtm3q", + "meta": ["slotID"] + }] + } + }); + let response = http::Response::builder() + .status(200) + .body(EdgeBody::from( + serde_json::to_vec(&body).expect("should serialize APS response body"), + )) + .expect("should build APS platform response"); + PlatformResponse::new(response) + } + + #[test] + fn concurrent_auctions_parse_out_of_order_with_correct_slot_remapping() { + // Regression: the reverse slot map used to live on the shared provider + // instance, so a second auction's request_bids overwrote the first + // auction's map and out-of-order parses remapped bids to the wrong + // creative slot. The map is now derived from each parse's own request. + let provider = ApsAuctionProvider::new(ApsConfig { + enabled: true, + pub_id: "5128".to_string(), + endpoint: default_endpoint(), + timeout_ms: 800, + }); + + let request_a = single_slot_request("auction-a", "aps-slot-a", "creative_a"); + let request_b = single_slot_request("auction-b", "aps-slot-b", "creative_b"); + + let settings = crate::test_support::tests::create_test_settings(); + let http_request = http::Request::builder() + .uri("https://example.com/") + .body(EdgeBody::empty()) + .expect("should build test request"); + let context = crate::auction::test_support::create_test_auction_context( + &settings, + &http_request, + 800, + ); + + futures::executor::block_on(async { + // Parse auction B's response first, then auction A's — the reverse + // of dispatch order — each against its own request. + let response_b = provider + .parse_response_with_context( + aps_platform_response("aps-slot-b"), + 100, + &request_b, + &context, + ) + .await + .expect("should parse auction B response"); + let response_a = provider + .parse_response_with_context( + aps_platform_response("aps-slot-a"), + 100, + &request_a, + &context, + ) + .await + .expect("should parse auction A response"); + + assert_eq!( + response_b.bids[0].slot_id, "creative_b", + "auction B bid should remap to auction B's creative slot" + ); + assert_eq!( + response_a.bids[0].slot_id, "creative_a", + "auction A bid should remap to auction A's creative slot despite parsing last" + ); + }); + } } diff --git a/crates/trusted-server-core/src/integrations/datadome/protection.rs b/crates/trusted-server-core/src/integrations/datadome/protection.rs index 4ae15c927..717ad46e8 100644 --- a/crates/trusted-server-core/src/integrations/datadome/protection.rs +++ b/crates/trusted-server-core/src/integrations/datadome/protection.rs @@ -159,6 +159,7 @@ impl DataDomeIntegration { host_header_override: None, certificate_check: true, first_byte_timeout: Duration::from_millis(u64::from(self.config.timeout_ms)), + between_bytes_timeout: Duration::from_millis(u64::from(self.config.timeout_ms)), }; services.backend().ensure(&spec).change_context(Self::error( diff --git a/crates/trusted-server-core/src/integrations/gpt.rs b/crates/trusted-server-core/src/integrations/gpt.rs index b560e60f7..f2dbc3eb2 100644 --- a/crates/trusted-server-core/src/integrations/gpt.rs +++ b/crates/trusted-server-core/src/integrations/gpt.rs @@ -81,6 +81,16 @@ pub struct GptConfig { /// Whether to rewrite GPT script URLs in publisher HTML. #[serde(default = "default_rewrite_script")] pub rewrite_script: bool, + + /// URL for the slim-Prebid bundle loaded post-window.load. + /// + /// When set, `installSlimPrebidLoader()` in the GPT bundle will load this + /// script after `window.load`, enabling scroll/refresh client-side auctions + /// and userID module warm-up. Set to the publisher's tsjs-prebid bundle URL. + /// + /// Override via env var: `TRUSTED_SERVER__INTEGRATIONS__GPT__SLIM_PREBID_URL` + #[serde(default, skip_serializing_if = "Option::is_none")] + pub slim_prebid_url: Option, } impl IntegrationConfig for GptConfig { @@ -459,18 +469,57 @@ impl IntegrationHeadInjector for GptIntegration { GPT_INTEGRATION_ID } + /// Injects the `tsjs.adInit` bootstrap script into ``. + /// + /// ## Scroll / refresh handoff contract (Phase 1) + /// + /// `tsjs.adInit` handles **initial render only**: it wires server-side bid + /// targeting into GPT slots and refreshes them. Win/billing beacons fire + /// only from the TS render bridge in the JS bundle, where a matching + /// Prebid Universal Creative request proves the TS creative rendered. + /// It does **not** trigger refresh auctions or handle GPT slot refresh events. + /// + /// Post-`window.load`, slim-Prebid owns scroll and GPT refresh: it listens + /// for GPT refresh events, runs client-side auctions, and sets targeting for + /// subsequent impressions. SPA navigation is handled separately by + /// `installSpaAuctionHook()` in the GPT bundle, which re-runs the server-side + /// auction via `GET /__ts/page-bids` on pushState / replaceState / popstate + /// route changes (see `auction/endpoints.rs`). + /// The `POST /auction` endpoint is not involved in scroll or refresh flows. fn head_inserts(&self, _ctx: &IntegrationHtmlContext<'_>) -> Vec { - // Set the enable flag and best-effort call the activation function - // registered by the GPT shim module. The bundle also auto-installs - // when it sees the pre-set flag, so this works regardless of whether - // the inline bootstrap runs before or after the TSJS bundle. - vec![ - "" + let mut scripts = vec![ + "" .to_string(), - ] + format!("", GPT_BOOTSTRAP_JS), + ]; + + if let Some(ref url) = self.config.slim_prebid_url { + // JSON-encode the URL, then escape `` cannot close this inline tag and + // let trailing markup execute (standard JSON-in-HTML mitigation). + let encoded = serde_json::to_string(url) + .expect("should serialize string") + .replace("window.__tsjs_slim_prebid_url={encoded};" + )); + } + + scripts } } +/// Inline `window.tsjs.adInit` bootstrap injected at `` so the bids +/// script at `` can call it before the TSJS bundle has loaded. +/// +/// The bundle's idempotent implementation in +/// `crates/trusted-server-js/lib/src/integrations/gpt/index.ts` later overwrites this stub. +/// Both implementations guard the one-time-per-page setup with +/// `window.tsjs.servicesEnabled` so neither double-enables services if the +/// publisher's own init code also calls `googletag.enableServices()`. +const GPT_BOOTSTRAP_JS: &str = include_str!("gpt_bootstrap.js"); + // Default value functions fn default_enabled() -> bool { @@ -503,6 +552,7 @@ mod tests { script_url: default_script_url(), cache_ttl_seconds: 3600, rewrite_script: true, + slim_prebid_url: None, } } @@ -1086,7 +1136,7 @@ mod tests { let inserts = integration.head_inserts(&ctx); - assert_eq!(inserts.len(), 1, "should emit exactly one head insert"); + assert_eq!(inserts.len(), 2, "should emit exactly two head inserts"); assert_eq!( inserts[0], "", @@ -1094,6 +1144,132 @@ mod tests { ); } + #[test] + fn head_inserts_includes_ts_ad_init_with_synchronous_bids_read() { + let config = test_config(); + let integration = GptIntegration::new(config); + let doc_state = IntegrationDocumentState::default(); + let ctx = IntegrationHtmlContext { + request_host: "edge.example.com", + request_scheme: "https", + origin_host: "example.com", + document_state: &doc_state, + }; + let inserts = integration.head_inserts(&ctx); + let combined = inserts.join(""); + assert!(combined.contains("ts.adInit"), "should define tsjs.adInit"); + assert!( + combined.contains("ts.bids"), + "should read tsjs.bids synchronously" + ); + assert!( + combined.contains("ts_initial"), + "should set ts_initial sentinel" + ); + assert!( + !combined.contains("addEventListener(\"slotRenderEnded\""), + "inline bootstrap cannot prove TS creative rendering from GPT slotRenderEnded" + ); + assert!( + !combined.contains("sendBeacon"), + "inline bootstrap must not fire win/billing beacons from GPT slotRenderEnded" + ); + assert!( + !combined.contains("getTargeting(\"hb_adid\")"), + "inline bootstrap must not treat GPT targeting as winner proof" + ); + assert!( + !combined.contains("/ts-bids"), + "must NOT fetch /ts-bids — bids are inline on the page" + ); + assert!( + !combined.contains("bidsPromise"), + "must NOT use bidsPromise — bids are synchronous" + ); + assert!( + !combined.contains("__ts_request_id"), + "must NOT reference request_id — no longer used" + ); + } + + #[test] + fn head_inserts_bootstrap_uses_css_safe_div_prefix_lookup() { + let config = test_config(); + let integration = GptIntegration::new(config); + let doc_state = IntegrationDocumentState::default(); + let ctx = IntegrationHtmlContext { + request_host: "edge.example.com", + request_scheme: "https", + origin_host: "example.com", + document_state: &doc_state, + }; + let combined = integration.head_inserts(&ctx).join(""); + assert!( + combined.contains("querySelectorAll(\"[id]\")"), + "bootstrap should scan ID-bearing elements instead of interpolating div_id into CSS" + ); + assert!( + combined.contains(".startsWith(slot.div_id)"), + "bootstrap should match metacharacter-containing div_id prefixes with startsWith" + ); + assert!( + !combined.contains("[id^='\" + slot.div_id"), + "bootstrap must not build a CSS attribute selector from raw div_id" + ); + } + + #[test] + fn head_inserts_bootstrap_guards_enable_services_with_idempotency_flag() { + let config = test_config(); + let integration = GptIntegration::new(config); + let doc_state = IntegrationDocumentState::default(); + let ctx = IntegrationHtmlContext { + request_host: "edge.example.com", + request_scheme: "https", + origin_host: "example.com", + document_state: &doc_state, + }; + let combined = integration.head_inserts(&ctx).join(""); + assert!( + combined.contains("ts.servicesEnabled"), + "should guard enableServices/enableSingleRequest with the tsjs.servicesEnabled flag" + ); + assert!(combined.contains("ts.adInit"), "should install tsjs.adInit"); + assert!( + !combined.contains("googletag.pubads().refresh()"), + "should never call unbounded refresh() — only refresh(newSlots)" + ); + } + + #[test] + fn head_inserts_bootstrap_refreshes_ts_slots_when_initial_load_disabled() { + // Mirrors the bundle: when the publisher calls disableInitialLoad(), + // display() only registers a TS-defined slot, so the bootstrap must also + // refresh those slots or they render blank. + let config = test_config(); + let integration = GptIntegration::new(config); + let doc_state = IntegrationDocumentState::default(); + let ctx = IntegrationHtmlContext { + request_host: "edge.example.com", + request_scheme: "https", + origin_host: "example.com", + document_state: &doc_state, + }; + let combined = integration.head_inserts(&ctx).join(""); + assert!( + combined.contains("disableInitialLoad"), + "bootstrap should wrap disableInitialLoad() to detect the disabled state" + ); + assert!( + combined.contains("gptInitialLoadDisabled"), + "bootstrap should record the initial-load-disabled state on window.tsjs" + ); + assert!( + combined.contains("slotsNeedingRefresh"), + "bootstrap should refresh TS-defined slots when initial load is disabled" + ); + } + #[test] fn head_injector_integration_id() { let integration = GptIntegration::new(test_config()); @@ -1102,4 +1278,97 @@ mod tests { "gpt" ); } + + #[test] + fn head_inserts_emits_slim_prebid_url_when_configured() { + let config = GptConfig { + slim_prebid_url: Some("https://cdn.example.com/tsjs-prebid.min.js".to_string()), + ..test_config() + }; + let integration = GptIntegration::new(config); + let doc_state = IntegrationDocumentState::default(); + let ctx = IntegrationHtmlContext { + request_host: "edge.example.com", + request_scheme: "https", + origin_host: "example.com", + document_state: &doc_state, + }; + + let inserts = integration.head_inserts(&ctx); + + assert_eq!( + inserts.len(), + 3, + "should emit three head inserts when slim_prebid_url is set" + ); + assert_eq!( + inserts[2], + r#""#, + "should emit the slim-Prebid URL as a JSON-encoded string assignment" + ); + } + + #[test] + fn head_inserts_escapes_script_terminator_in_slim_prebid_url() { + // A configured URL containing `` must not close the inline tag. + let config = GptConfig { + slim_prebid_url: Some("https://cdn.example.com/x".to_string()), + ..test_config() + }; + let integration = GptIntegration::new(config); + let doc_state = IntegrationDocumentState::default(); + let ctx = IntegrationHtmlContext { + request_host: "edge.example.com", + request_scheme: "https", + origin_host: "example.com", + document_state: &doc_state, + }; + + let inserts = integration.head_inserts(&ctx); + + // The injected `` must be neutralised: the only + // `` left is the tag's own legitimate closer. + assert!( + !inserts[2].contains(" terminator, got: {}", + inserts[2] + ); + assert_eq!( + inserts[2].matches("").count(), + 1, + "only the tag's own closing should remain, got: {}", + inserts[2] + ); + assert!( + inserts[2].contains("<\\/script>"), + "should emit the escaped terminator, got: {}", + inserts[2] + ); + } + + #[test] + fn head_inserts_omits_slim_prebid_url_when_not_configured() { + let integration = GptIntegration::new(test_config()); + let doc_state = IntegrationDocumentState::default(); + let ctx = IntegrationHtmlContext { + request_host: "edge.example.com", + request_scheme: "https", + origin_host: "example.com", + document_state: &doc_state, + }; + + let inserts = integration.head_inserts(&ctx); + + assert_eq!( + inserts.len(), + 2, + "should emit exactly two head inserts when slim_prebid_url is absent" + ); + assert!( + inserts + .iter() + .all(|s| !s.contains("__tsjs_slim_prebid_url")), + "should not emit slim-Prebid URL tag when not configured" + ); + } } diff --git a/crates/trusted-server-core/src/integrations/gpt_bootstrap.js b/crates/trusted-server-core/src/integrations/gpt_bootstrap.js new file mode 100644 index 000000000..cc4c5c00c --- /dev/null +++ b/crates/trusted-server-core/src/integrations/gpt_bootstrap.js @@ -0,0 +1,171 @@ +// Edge-injected GPT auction bootstrap. +// +// This is the minimal `window.tsjs.adInit` that runs on first page load +// before the TSJS bundle has had a chance to install its richer +// idempotent implementation. The bundle in +// crates/trusted-server-js/lib/src/integrations/gpt/index.ts overwrites `tsjs.adInit` +// once it loads. +// +// Contract with the bundle: +// - Both implementations must set `window.tsjs.servicesEnabled = true` +// after calling `enableSingleRequest()`/`enableServices()` so a +// subsequent call becomes a no-op. +// - `refresh()` is called only for the slots defined in this pass, +// never the global slot list. +// +// Only installed if `window.tsjs.adInit` isn't already defined. +(function () { + if (typeof window === "undefined") return; + var ts = (window.tsjs = window.tsjs || {}); + if (ts.adInit) return; + + // Track whether the publisher disabled GPT initial load. GPT exposes no + // getter for this, so wrap pubads().disableInitialLoad() to record it. With + // initial load disabled, display() only registers a slot and the ad request + // must come from a later refresh(); adInit() reads this to refresh its own + // freshly defined slots so they are not left blank. Pushed onto the command + // queue so it runs before the publisher's own disableInitialLoad() call. + (window.googletag = window.googletag || { cmd: [] }).cmd.push(function () { + var pubads = googletag.pubads && googletag.pubads(); + if ( + !pubads || + typeof pubads.disableInitialLoad !== "function" || + pubads.__tsInitialLoadHooked + ) { + return; + } + var original = pubads.disableInitialLoad.bind(pubads); + pubads.disableInitialLoad = function () { + ts.gptInitialLoadDisabled = true; + return original(); + }; + pubads.__tsInitialLoadHooked = true; + }); + + ts.adInit = function () { + var slots = ts.adSlots || []; + var bids = ts.bids || {}; + var divToSlotId = {}; + + googletag.cmd.push(function () { + // Slots TS defined itself — tracked for SPA destroy. Publisher-owned + // slots are reused but never destroyed by TS on navigation. + var newSlots = []; + // Publisher-owned slots TS reused — refreshed to pick up server-side + // targeting. The publisher already display()ed these. + var slotsToRefresh = []; + // Element IDs of slots TS defined itself. GPT requires display() to + // register/render a freshly-defined slot; refresh() alone no-ops for a + // slot that was never displayed, so these are display()ed instead. + var slotsToDisplay = []; + slots.forEach(function (slot) { + // Resolve actual div ID: exact match first, then safe prefix scan. + // div_id in config may be a stable prefix (e.g. "ad-header-0-") when + // the suffix is dynamically generated by the framework at render time. + var el = document.getElementById(slot.div_id); + if (!el) { + var idElements = document.querySelectorAll("[id]"); + for (var i = 0; i < idElements.length; i++) { + var candidate = idElements[i]; + if ( + slot.div_id && + candidate.id.startsWith(slot.div_id) && + !candidate.id.endsWith("-container") + ) { + el = candidate; + break; + } + } + } + if (!el) return; + var actualDivId = el.id; + var b = bids[slot.id] || {}; + + var existingSlots = googletag.pubads().getSlots(); + var s = + existingSlots.find(function (gs) { + return gs.getSlotElementId() === actualDivId; + }) || null; + var tsOwned = false; + if (!s) { + // Use outer container div for TS's slot when publisher hasn't defined + // theirs yet — keeps both slots on separate divs so publisher's + // later defineSlot on the inner div doesn't conflict. + var containerEl = document.getElementById(actualDivId + "-container"); + var slotDivId = containerEl ? containerEl.id : actualDivId; + s = googletag.defineSlot(slot.gam_unit_path, slot.formats, slotDivId); + if (!s) return; + s.addService(googletag.pubads()); + tsOwned = true; + } + + Object.entries(slot.targeting || {}).forEach(function (e) { + s.setTargeting(e[0], e[1]); + }); + [ + "hb_pb", + "hb_bidder", + "hb_adid", + "hb_cache_host", + "hb_cache_path", + ].forEach(function (k) { + if (b[k]) s.setTargeting(k, b[k]); + }); + // Keep in sync with TS_INITIAL_TARGETING_KEY in index.ts + s.setTargeting("ts_initial", "1"); + // Map both the inner div and the GPT slot's element ID (the + // "-container" div when TS defined the slot there) into divToSlotId. + // This bootstrap fires no beacons and registers no slotRenderEnded + // listener; the map is consumed by the bundle's render bridge (index.ts) + // once it loads, which reports the GPT slot element ID. + divToSlotId[actualDivId] = slot.id; + var slotElementId = s.getSlotElementId(); + if (slotElementId && slotElementId !== actualDivId) { + divToSlotId[slotElementId] = slot.id; + } + if (tsOwned) { + newSlots.push(s); + var displayId = s.getSlotElementId() || actualDivId; + slotsToDisplay.push(displayId); + } else { + slotsToRefresh.push(s); + } + }); + ts.prevGptSlots = newSlots; + ts.divToSlotId = divToSlotId; + if (!ts.servicesEnabled) { + googletag.pubads().enableSingleRequest(); + googletag.enableServices(); + ts.servicesEnabled = true; + } + // Register and render TS-defined slots. GPT requires display() for a + // freshly-defined slot; without it the slot no-ops and misses its + // impression. Runs after enableServices(); on SPA navigation services are + // already enabled, so this runs unconditionally for new slots. + slotsToDisplay.forEach(function (divId) { + googletag.display(divId); + }); + // Reused publisher-owned slots always need a refresh to pick up the + // server-side targeting. TS-defined slots are fetched by display() above + // unless the publisher disabled initial load, in which case display() only + // registers them and refresh() must request the ad — otherwise they render + // blank. Only add them in that case to avoid double-requesting. + var slotsNeedingRefresh = ts.gptInitialLoadDisabled + ? slotsToRefresh.concat(newSlots) + : slotsToRefresh; + if (slotsNeedingRefresh.length > 0) { + // One-shot bypass: this internal refresh delivers the just-applied + // server-side targeting to GAM. If slim-Prebid has already wrapped + // refresh(), it must pass this call straight through — not clear the + // targeting and run a duplicate client-side auction. Mirrors the + // bundle's adInit() in crates/trusted-server-js/lib/src/integrations/gpt/index.ts. + ts.adInitRefreshInProgress = true; + try { + googletag.pubads().refresh(slotsNeedingRefresh); + } finally { + ts.adInitRefreshInProgress = false; + } + } + }); + }; +})(); diff --git a/crates/trusted-server-core/src/integrations/mod.rs b/crates/trusted-server-core/src/integrations/mod.rs index 3678f949c..7777b79d4 100644 --- a/crates/trusted-server-core/src/integrations/mod.rs +++ b/crates/trusted-server-core/src/integrations/mod.rs @@ -152,6 +152,7 @@ fn integration_backend_spec( host_header_override: None, certificate_check, first_byte_timeout, + between_bytes_timeout: first_byte_timeout, }) } diff --git a/crates/trusted-server-core/src/integrations/prebid.rs b/crates/trusted-server-core/src/integrations/prebid.rs index d2bceb43a..19364ef69 100644 --- a/crates/trusted-server-core/src/integrations/prebid.rs +++ b/crates/trusted-server-core/src/integrations/prebid.rs @@ -9,13 +9,14 @@ use http::header::HeaderValue; use http::{header, Method, StatusCode}; use serde::{Deserialize, Serialize}; use serde_json::Value as Json; -use url::Url; +use url::Url as ParsedUrl; use validator::Validate; 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; @@ -29,8 +30,8 @@ use crate::integrations::{ }; use crate::openrtb::{ to_openrtb_i32, Banner, ConsentedProvidersSettings, Device, Format, Geo, Imp, ImpExt, - OpenRtbRequest, PrebidExt, PrebidImpExt, Publisher, Regs, RegsExt, RequestExt, Site, ToExt, - TrustedServerExt, User, UserExt, + ImpStoredRequest, OpenRtbRequest, PrebidExt, PrebidImpExt, Publisher, Regs, RegsExt, + RequestExt, Site, ToExt, TrustedServerExt, User, UserExt, }; use crate::platform::{ PlatformHttpRequest, PlatformPendingRequest, PlatformResponse, RuntimeServices, @@ -184,6 +185,21 @@ pub struct PrebidIntegrationConfig { /// - `both` — consent in both cookies and body (default) #[serde(default)] pub consent_forwarding: ConsentForwardingMode, + /// Strip `nurl` and `burl` from PBS bids before they reach `window.tsjs.bids`. + /// + /// Set to `true` when the PBS deployment is configured to fire win/billing + /// notifications server-side (e.g. `ext.prebid.events.enabled`), so the + /// client does not double-fire them via `sendBeacon`. Default: `false`. + #[serde(default)] + pub suppress_nurl: bool, + /// Bidder seats whose `nurl` and `burl` should be stripped before they reach + /// `window.tsjs.bids`. + /// + /// Use this when only specific PBS seats fire win/billing notifications + /// internally. The global [`suppress_nurl`](Self::suppress_nurl) switch still + /// suppresses every bidder when set. + #[serde(default, deserialize_with = "crate::settings::vec_from_seq_or_map")] + pub suppress_nurl_bidders: Vec, } impl IntegrationConfig for PrebidIntegrationConfig { @@ -308,9 +324,9 @@ impl PrebidIntegration { } let parsed = if without_query.starts_with("//") { - Url::parse(&format!("https:{without_query}")) + ParsedUrl::parse(&format!("https:{without_query}")) } else { - Url::parse(without_query) + ParsedUrl::parse(without_query) }; parsed @@ -360,17 +376,19 @@ 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, "application/javascript; charset=utf-8", ) - .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) } } @@ -856,6 +874,12 @@ fn non_empty_override_object( /// Copies browser headers to the outgoing Prebid Server request. /// +/// The inbound `X-Forwarded-For` header is never copied: on the server-side +/// publisher auction path `from` is the browser navigation request, so its +/// XFF value is client-supplied and spoofable. Instead the header is +/// synthesized from `client_ip` — the platform-attested client address — +/// matching the trusted IP already sent in `OpenRTB` `device.ip`. +/// /// In [`ConsentForwardingMode::OpenrtbOnly`] mode, consent cookies are /// stripped from the `Cookie` header since consent travels exclusively /// through the `OpenRTB` body. @@ -863,13 +887,9 @@ fn copy_request_headers( from: &http::Request, to: &mut http::Request, consent_forwarding: ConsentForwardingMode, + client_ip: Option, ) { - let headers_to_copy = [ - header::USER_AGENT, - header::HeaderName::from_static("x-forwarded-for"), - header::REFERER, - header::ACCEPT_LANGUAGE, - ]; + let headers_to_copy = [header::USER_AGENT, header::REFERER, header::ACCEPT_LANGUAGE]; for header_name in &headers_to_copy { if let Some(value) = from.headers().get(header_name) { @@ -877,6 +897,13 @@ fn copy_request_headers( } } + if let Some(ip) = client_ip { + if let Ok(value) = HeaderValue::from_str(&ip.to_string()) { + to.headers_mut() + .insert(header::HeaderName::from_static("x-forwarded-for"), value); + } + } + let Some(cookie_value) = from.headers().get(header::COOKIE) else { return; }; @@ -946,6 +973,21 @@ impl PrebidAuctionProvider { }) } + /// Returns the full Prebid Server `OpenRTB2` auction endpoint URL. + /// + /// Backward-compatible normalization: `server_url` may be configured as + /// either the PBS origin (path is appended here) or the full endpoint + /// already ending in `/openrtb2/auction` (used as-is, ignoring a trailing + /// slash) — both shapes produce the same request URL. + fn auction_endpoint_url(&self) -> String { + let base = self.config.server_url.trim_end_matches('/'); + if base.ends_with("/openrtb2/auction") { + base.to_string() + } else { + format!("{base}/openrtb2/auction") + } + } + /// Convert auction request to `OpenRTB` format with all enrichments. fn to_openrtb( &self, @@ -997,22 +1039,45 @@ impl PrebidAuctionProvider { // Build the bidder map for PBS. // The JS adapter sends "trustedServer" as the bidder (our orchestrator // adapter name). Replace it with the real PBS bidders from config. - // Pass through any other bidders with their params as-is. + // Only pass through keys that are known PBS bidders — skip provider-specific + // keys like "aps" which belong to their own separate auction provider. let mut bidder: HashMap = HashMap::new(); for (name, params) in &slot.bidders { if name == TRUSTED_SERVER_BIDDER { bidder.extend(expand_trusted_server_bidders(&self.config.bidders, params)); - } else { + } else if self.config.bidders.iter().any(|b| b == name) { bidder.insert(name.clone(), params.clone()); + } else if name != "aps" { + // `aps` is intentionally handled by its own provider. Any + // other unrecognized key is likely a misconfiguration (a + // slot bidder absent from `config.bidders`) that silently + // yields an empty bidder map and a stored-request no-bid — + // log it so the drop is diagnosable. + log::debug!( + "prebid: dropping slot '{}' bidder '{}' — not in config.bidders and not a known provider key", + slot.id, + name + ); } } - // Fallback to config bidders if none provided - if bidder.is_empty() { - for b in &self.config.bidders { - bidder.insert(b.clone(), Json::Object(serde_json::Map::new())); - } - } + // When no inline PBS bidder params exist (e.g. creative-opportunity slots + // whose PBS params live in stored requests), tell PBS to resolve bidder + // config from the stored request keyed by this slot ID. + // + // This cannot fire for the client /auction path: the JS adapter + // injects a `trustedServer` entry into every ad unit, so `bidder` + // is only empty for server-side creative-opportunity slots with + // no inline provider params (or when `config.bidders` is empty, + // where PBS previously received an empty bidder map and returned + // no bids — a stored-request miss is the same no-bid outcome). + let storedrequest = if bidder.is_empty() { + Some(ImpStoredRequest { + id: slot.id.clone(), + }) + } else { + None + }; // Apply canonical and compatibility-derived rules in normalized order. for (name, params) in &mut bidder { @@ -1034,7 +1099,10 @@ impl PrebidAuctionProvider { secure: Some(true), // require HTTPS creatives tagid: Some(slot.id.clone()), ext: ImpExt { - prebid: PrebidImpExt { bidder }, + prebid: PrebidImpExt { + bidder, + storedrequest, + }, } .to_ext(), ..Default::default() @@ -1053,13 +1121,13 @@ impl PrebidAuctionProvider { // Build user object — populate consent at both OpenRTB 2.6 top-level // and Prebid ext-based locations (dual placement). - // In cookies_only mode, body consent fields are omitted — consent - // travels exclusively through the forwarded Cookie header. - let consent_ctx = if self.config.consent_forwarding.includes_body_consent() { - request.user.consent.as_ref() - } else { - None - }; + // In cookies_only mode, cookie-sourced consent travels through the + // forwarded Cookie header. KV/policy-sourced consent has no inbound + // cookie to forward, so carry it in the OpenRTB body instead. + let consent_ctx = request.user.consent.as_ref().filter(|ctx| { + self.config.consent_forwarding.includes_body_consent() + || !matches!(ctx.source, crate::consent::ConsentSource::Cookie) + }); let raw_tc = consent_ctx.and_then(|c| c.raw_tc_string.clone()); let user = Some(User { id: request.user.id.clone(), @@ -1209,7 +1277,12 @@ impl PrebidAuctionProvider { .and_then(|value| value.to_str().ok()) .map(std::string::ToString::to_string); - let tmax = to_openrtb_i32(self.config.timeout_ms, "tmax", "request"); + // Advertise the effective auction budget, not the raw provider config: + // the orchestrator caps `context.timeout_ms` to the remaining auction + // budget, and the edge backend stops waiting after that long. Telling + // PBS it has more time than the edge will wait turns partial bids into + // edge timeouts. + let tmax = to_openrtb_i32(context.timeout_ms, "tmax", "request"); OpenRtbRequest { id: Some(request.id.clone()), @@ -1390,6 +1463,15 @@ impl PrebidAuctionProvider { } } + fn should_suppress_bid_notifications(&self, bidder: &str) -> bool { + self.config.suppress_nurl + || self + .config + .suppress_nurl_bidders + .iter() + .any(|suppressed_bidder| suppressed_bidder == bidder) + } + /// Parse a single bid from `OpenRTB` response. fn parse_bid(&self, bid_obj: &Json, seat: &str) -> Result { let slot_id = bid_obj @@ -1419,15 +1501,33 @@ impl PrebidAuctionProvider { .and_then(|v| u32::try_from(v).ok()) .unwrap_or(0); - let nurl = bid_obj - .get("nurl") - .and_then(|v| v.as_str()) - .map(std::string::ToString::to_string); + let suppress_bid_notifications = self.should_suppress_bid_notifications(seat); + let nurl = if suppress_bid_notifications { + None + } else { + bid_obj + .get("nurl") + .and_then(|v| v.as_str()) + .map(std::string::ToString::to_string) + }; - let burl = bid_obj - .get("burl") + let burl = if suppress_bid_notifications { + None + } else { + bid_obj + .get("burl") + .and_then(|v| v.as_str()) + .map(std::string::ToString::to_string) + }; + + // `adid` is the creative/ad identifier. The OpenRTB `id` is the bid ID, + // not an ad ID, so it is not used as a fallback: surfacing it as `ad_id` + // (which is exposed raw in the debug bid) would mislead any consumer that + // treats `ad_id` as a creative identifier. Absent `adid`, `ad_id` is None. + let ad_id = bid_obj + .get("adid") .and_then(|v| v.as_str()) - .map(std::string::ToString::to_string); + .map(String::from); let adomain = bid_obj .get("adomain") @@ -1438,6 +1538,49 @@ impl PrebidAuctionProvider { .collect() }); + // Extract PBS Cache coordinates from ext.prebid.cache.bids + let cache_entry = bid_obj + .get("ext") + .and_then(|e| e.get("prebid")) + .and_then(|p| p.get("cache")) + .and_then(|c| c.get("bids")); + + let cache_id = cache_entry + .and_then(|c| c.get("cacheId")) + .and_then(|v| v.as_str()) + .map(String::from); + + let (cache_host, cache_path) = cache_entry + .and_then(|c| c.get("url")) + .and_then(|v| v.as_str()) + .and_then(|url_str| { + ParsedUrl::parse(url_str) + .map_err(|e| log::debug!("PBS cache URL parse failed: {}", e)) + .ok() + }) + .map(|u| { + let host = u.host_str().map(String::from); + // path() returns "/" for root — only use if non-trivial + let path = u.path().to_string(); + let path = if path.is_empty() || path == "/" { + None + } else { + Some(path) + }; + (host, path) + }) + .unwrap_or((None, None)); + + // Guard: if we extracted a cache UUID but couldn't extract the host, + // the bid will have hb_adid set but no endpoint to fetch from — creative will fail. + if cache_id.is_some() && cache_host.is_none() { + log::warn!( + "PBS bid has cache UUID but cache URL could not be parsed — \ + creative will fail to render for slot '{}'", + slot_id + ); + } + Ok(AuctionBid { slot_id, price: Some(price), // Prebid provides decoded prices @@ -1449,6 +1592,10 @@ impl PrebidAuctionProvider { height, nurl, burl, + ad_id, + cache_id, + cache_host, + cache_path, metadata: std::collections::HashMap::new(), }) } @@ -1513,8 +1660,8 @@ impl AuctionProvider for PrebidAuctionProvider { if log::log_enabled!(log::Level::Debug) { match serde_json::to_string_pretty(&openrtb) { Ok(json) => log::debug!( - "Prebid OpenRTB request to {}/openrtb2/auction:\n{}", - self.config.server_url, + "Prebid OpenRTB request to {}:\n{}", + self.auction_endpoint_url(), json ), Err(e) => { @@ -1526,7 +1673,7 @@ impl AuctionProvider for PrebidAuctionProvider { // Create HTTP request let mut pbs_req = http::Request::builder() .method(http::Method::POST) - .uri(format!("{}/openrtb2/auction", self.config.server_url)) + .uri(self.auction_endpoint_url()) .body(EdgeBody::empty()) .change_context(TrustedServerError::Prebid { message: "Failed to build Prebid request".to_string(), @@ -1535,6 +1682,7 @@ impl AuctionProvider for PrebidAuctionProvider { context.request, &mut pbs_req, self.config.consent_forwarding, + context.services.client_info().client_ip, ); let pbs_body = serde_json::to_vec(&openrtb).change_context(TrustedServerError::Prebid { @@ -1706,7 +1854,7 @@ mod tests { AdFormat, AdSlot, AuctionContext, AuctionRequest, DeviceInfo, PublisherInfo, UserInfo, }; - use crate::consent::ConsentContext; + use crate::consent::{ConsentContext, ConsentSource}; use crate::geo::GeoInfo; use crate::html_processor::{create_html_processor, HtmlProcessorConfig}; use crate::integrations::{ @@ -1747,6 +1895,8 @@ mod tests { bid_param_overrides: HashMap::default(), bid_param_override_rules: Vec::new(), consent_forwarding: ConsentForwardingMode::Both, + suppress_nurl: false, + suppress_nurl_bidders: Vec::new(), } } @@ -1758,10 +1908,11 @@ mod tests { spec: &PlatformBackendSpec, ) -> Result> { Ok(format!( - "predicted_{}_{}_{}", + "predicted_{}_{}_{}_{}", spec.scheme, spec.host, - spec.first_byte_timeout.as_millis() + spec.first_byte_timeout.as_millis(), + spec.between_bytes_timeout.as_millis() )) } @@ -1797,8 +1948,8 @@ mod tests { .expect("should predict backend name through platform backend"); assert_eq!( - backend_name, "predicted_https_prebid.example_123", - "should use PlatformBackend::predict_name instead of duplicating the naming scheme" + backend_name, "predicted_https_prebid.example_123_123", + "should cap both first-byte and between-bytes timeouts to the auction budget" ); } @@ -2148,7 +2299,14 @@ server_url = "https://prebid.example" .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 @@ -2608,6 +2766,49 @@ server_url = "https://prebid.example" ); } + #[test] + fn to_openrtb_includes_kv_consent_when_cookies_only_has_no_cookie_to_forward() { + let mut config = base_config(); + config.consent_forwarding = ConsentForwardingMode::CookiesOnly; + let provider = PrebidAuctionProvider::new(config); + let mut auction_request = create_test_auction_request(); + auction_request.user.consent = Some(ConsentContext { + raw_tc_string: Some("BOkv-backed-consent-string".to_string()), + raw_us_privacy: Some("1YNN".to_string()), + gdpr_applies: true, + source: ConsentSource::KvStore, + ..Default::default() + }); + + let settings = make_settings(); + let request = build_test_request(); + assert!( + !request.headers().contains_key(header::COOKIE), + "test request should not carry a consent cookie to forward" + ); + let context = create_test_auction_context(&settings, &request); + + let openrtb = provider.to_openrtb( + &auction_request, + &context, + None, + make_request_info(&context), + ); + + assert_eq!( + openrtb.user.as_ref().and_then(|u| u.consent.as_deref()), + Some("BOkv-backed-consent-string"), + "cookies_only should fall back to body consent when consent came from KV" + ); + let regs = openrtb.regs.as_ref().expect("should include consent regs"); + assert_eq!(regs.gdpr, Some(true), "should carry GDPR applicability"); + assert_eq!( + regs.us_privacy.as_deref(), + Some("1YNN"), + "should carry non-cookie consent strings from KV" + ); + } + #[test] fn to_openrtb_sets_gdpr_true_for_non_eu_country_with_consent() { // When geo says non-GDPR but a consent string is present, the consent @@ -3156,7 +3357,7 @@ server_url = "https://prebid.example" assert_eq!( openrtb.tmax, Some(1000), - "should set tmax from config timeout_ms" + "should set tmax from the effective auction context timeout" ); assert_eq!( openrtb.cur, @@ -3166,15 +3367,72 @@ server_url = "https://prebid.example" } #[test] - fn to_openrtb_omits_tmax_when_timeout_exceeds_i32_max() { + fn auction_endpoint_url_appends_path_to_base_origin() { + let provider = PrebidAuctionProvider::new(base_config()); + assert_eq!( + provider.auction_endpoint_url(), + "https://prebid.example/openrtb2/auction", + "should append /openrtb2/auction to a base origin" + ); + } + + #[test] + fn auction_endpoint_url_does_not_double_append_full_endpoint() { + let mut config = base_config(); + config.server_url = "https://prebid.example/openrtb2/auction".to_string(); + let provider = PrebidAuctionProvider::new(config); + assert_eq!( + provider.auction_endpoint_url(), + "https://prebid.example/openrtb2/auction", + "should use a full endpoint URL as-is" + ); + let mut config = base_config(); - config.timeout_ms = i32::MAX as u32 + 1; + config.server_url = "https://prebid.example/openrtb2/auction/".to_string(); + let provider = PrebidAuctionProvider::new(config); + assert_eq!( + provider.auction_endpoint_url(), + "https://prebid.example/openrtb2/auction", + "should normalize a trailing slash on a full endpoint URL" + ); + } + + #[test] + fn to_openrtb_tmax_uses_effective_context_timeout_not_provider_config() { + // Provider config says 1000ms but the auction budget is only 500ms — + // PBS must be told the tighter effective deadline, otherwise the edge + // gives up before PBS responds. + let config = base_config(); + assert_eq!(config.timeout_ms, 1000, "should start from 1000ms config"); let provider = PrebidAuctionProvider::new(config); let auction_request = create_test_auction_request(); let settings = make_settings(); let request = build_test_request(); - let context = create_test_auction_context(&settings, &request); + let context = shared_test_auction_context(&settings, &request, 500); + + let openrtb = provider.to_openrtb( + &auction_request, + &context, + None, + make_request_info(&context), + ); + + assert_eq!( + openrtb.tmax, + Some(500), + "should set tmax from the effective auction context timeout, not provider config" + ); + } + + #[test] + fn to_openrtb_omits_tmax_when_timeout_exceeds_i32_max() { + let provider = PrebidAuctionProvider::new(base_config()); + let auction_request = create_test_auction_request(); + + let settings = make_settings(); + let request = build_test_request(); + let context = shared_test_auction_context(&settings, &request, i32::MAX as u32 + 1); let openrtb = provider.to_openrtb( &auction_request, @@ -4520,6 +4778,92 @@ set = { placementId = "explicit_header" } assert_eq!(statuses[1]["status"], "timeout"); } + // ======================================================================== + // PBS stored request tests + // ======================================================================== + + #[test] + fn to_openrtb_uses_stored_request_when_slot_has_no_pbs_bidder_params() { + // Slot only has "aps" provider — not a PBS bidder + let slot = make_slot( + "atf_sidebar_ad", + HashMap::from([("aps".to_string(), json!({"slotID": "aps-slot-atf-sidebar"}))]), + ); + let request = make_auction_request(vec![slot]); + + let ortb = call_to_openrtb(base_config(), &request); + let ext = ortb.imp[0].ext.as_ref().expect("should have imp ext"); + let prebid = ext.get("prebid").expect("should have prebid in ext"); + + assert!( + prebid.get("bidder").is_none(), + "should not send inline bidder params when using stored request" + ); + assert_eq!( + prebid["storedrequest"]["id"], "atf_sidebar_ad", + "should use slot id as stored request id" + ); + } + + #[test] + fn to_openrtb_uses_stored_request_when_slot_has_empty_bidders() { + let slot = make_slot("homepage_header_ad", HashMap::new()); + let request = make_auction_request(vec![slot]); + + let ortb = call_to_openrtb(base_config(), &request); + let ext = ortb.imp[0].ext.as_ref().expect("should have imp ext"); + let prebid = ext.get("prebid").expect("should have prebid in ext"); + + assert_eq!( + prebid["storedrequest"]["id"], "homepage_header_ad", + "should use slot id as stored request id for slot with no bidder map" + ); + } + + #[test] + fn to_openrtb_uses_inline_bidder_params_not_stored_request_for_trusted_server_slots() { + let mut config = base_config(); + config.bidders = vec!["kargo".to_string()]; + + let slot = make_ts_slot( + "in_content_ad", + &json!({ "kargo": { "placementId": "client_123" } }), + None, + ); + let request = make_auction_request(vec![slot]); + + let ortb = call_to_openrtb(config, &request); + let ext = ortb.imp[0].ext.as_ref().expect("should have imp ext"); + let prebid = ext.get("prebid").expect("should have prebid in ext"); + + assert!( + prebid.get("storedrequest").is_none(), + "should not use stored request when inline bidder params are present" + ); + assert_eq!( + prebid["bidder"]["kargo"]["placementId"], "client_123", + "should use inline bidder params from trustedServer expansion" + ); + } + + #[test] + fn to_openrtb_skips_aps_key_from_slot_bidders_in_pbs_request() { + let slot = make_slot( + "atf_sidebar_ad", + HashMap::from([("aps".to_string(), json!({"slotID": "aps-slot-atf-sidebar"}))]), + ); + let request = make_auction_request(vec![slot]); + + let ortb = call_to_openrtb(base_config(), &request); + let ext = ortb.imp[0].ext.as_ref().expect("should have imp ext"); + let prebid = ext.get("prebid").expect("should have prebid in ext"); + + assert!( + prebid.get("bidder").is_none(), + "should not forward aps key into PBS imp.ext.prebid.bidder" + ); + } + #[test] fn register_rejects_invalid_bid_param_override_rule() { let toml = format!( @@ -4567,4 +4911,290 @@ set = { networkId = 42 } "should fail fast when a canonical rule has no matcher fields" ); } + + #[test] + fn parse_bid_extracts_cache_id_from_ext_prebid_cache_bids() { + let bid_json = serde_json::json!({ + "id": "bid-id-123", + "impid": "atf_sidebar_ad", + "price": 1.50, + "adm": "
ad
", + "w": 300, + "h": 250, + "ext": { + "prebid": { + "cache": { + "bids": { + "url": "https://openads.adsrvr.org/cache?uuid=f47447a0-b759-4f2f-9887-af458b79b570", + "cacheId": "f47447a0-b759-4f2f-9887-af458b79b570" + } + } + } + } + }); + let provider = PrebidAuctionProvider::new(base_config()); + let bid = provider + .parse_bid(&bid_json, "thetradedesk") + .expect("should parse bid"); + assert_eq!( + bid.cache_id.as_deref(), + Some("f47447a0-b759-4f2f-9887-af458b79b570"), + "should extract cacheId as cache_id" + ); + assert_eq!( + bid.cache_host.as_deref(), + Some("openads.adsrvr.org"), + "should extract host from cache URL" + ); + assert_eq!( + bid.cache_path.as_deref(), + Some("/cache"), + "should extract path from cache URL" + ); + } + + #[test] + fn parse_bid_sets_cache_fields_to_none_when_no_cache_entry() { + let bid_json = serde_json::json!({ + "id": "bid-id-456", + "impid": "atf_sidebar_ad", + "price": 0.50, + "w": 300, + "h": 250 + }); + let provider = PrebidAuctionProvider::new(base_config()); + let bid = provider + .parse_bid(&bid_json, "appnexus") + .expect("should parse bid"); + assert!(bid.cache_id.is_none(), "should be None when cache absent"); + assert!(bid.cache_host.is_none(), "should be None when cache absent"); + assert!(bid.cache_path.is_none(), "should be None when cache absent"); + } + + #[test] + fn parse_bid_handles_malformed_cache_url_gracefully() { + let bid_json = serde_json::json!({ + "id": "bid-id-789", + "impid": "atf_sidebar_ad", + "price": 0.50, + "w": 300, + "h": 250, + "ext": { + "prebid": { + "cache": { + "bids": { + "url": "not-a-valid-url", + "cacheId": "some-uuid" + } + } + } + } + }); + let provider = PrebidAuctionProvider::new(base_config()); + let bid = provider + .parse_bid(&bid_json, "appnexus") + .expect("should parse bid without panicking"); + assert_eq!( + bid.cache_id.as_deref(), + Some("some-uuid"), + "should still extract cacheId even if URL is malformed" + ); + assert!( + bid.cache_host.is_none(), + "should be None when URL parse fails" + ); + assert!( + bid.cache_path.is_none(), + "should be None when URL parse fails" + ); + } + + #[test] + fn parse_bid_includes_nurl_and_burl_by_default() { + let bid_json = serde_json::json!({ + "impid": "atf_sidebar_ad", + "price": 1.50, + "w": 300, + "h": 250, + "nurl": "https://ssp.example/win?id=abc123", + "burl": "https://ssp.example/bill?id=abc123" + }); + let provider = PrebidAuctionProvider::new(base_config()); + let bid = provider + .parse_bid(&bid_json, "appnexus") + .expect("should parse bid"); + assert_eq!( + bid.nurl.as_deref(), + Some("https://ssp.example/win?id=abc123"), + "should include nurl when suppress_nurl is false" + ); + assert_eq!( + bid.burl.as_deref(), + Some("https://ssp.example/bill?id=abc123"), + "should include burl when suppress_nurl is false" + ); + } + + #[test] + fn parse_bid_strips_nurl_and_burl_when_suppress_nurl_enabled() { + let bid_json = serde_json::json!({ + "impid": "atf_sidebar_ad", + "price": 1.50, + "w": 300, + "h": 250, + "nurl": "https://ssp.example/win?id=abc123", + "burl": "https://ssp.example/bill?id=abc123" + }); + let config = PrebidIntegrationConfig { + suppress_nurl: true, + ..base_config() + }; + let provider = PrebidAuctionProvider::new(config); + let bid = provider + .parse_bid(&bid_json, "appnexus") + .expect("should parse bid"); + assert_eq!( + bid.nurl, None, + "should strip nurl when suppress_nurl is true" + ); + assert_eq!( + bid.burl, None, + "should strip burl when suppress_nurl is true" + ); + } + + #[test] + fn parse_bid_strips_nurl_and_burl_for_configured_suppressed_bidder_only() { + let bid_json = serde_json::json!({ + "impid": "atf_sidebar_ad", + "price": 1.50, + "w": 300, + "h": 250, + "nurl": "https://ssp.example/win?id=abc123", + "burl": "https://ssp.example/bill?id=abc123" + }); + let config = PrebidIntegrationConfig { + suppress_nurl_bidders: vec!["appnexus".to_string()], + ..base_config() + }; + let provider = PrebidAuctionProvider::new(config); + + let suppressed_bid = provider + .parse_bid(&bid_json, "appnexus") + .expect("should parse suppressed bidder bid"); + let preserved_bid = provider + .parse_bid(&bid_json, "openx") + .expect("should parse unsuppressed bidder bid"); + + assert_eq!( + suppressed_bid.nurl, None, + "should strip nurl only for the configured bidder" + ); + assert_eq!( + suppressed_bid.burl, None, + "should strip burl only for the configured bidder" + ); + assert_eq!( + preserved_bid.nurl.as_deref(), + Some("https://ssp.example/win?id=abc123"), + "should preserve nurl for bidders not configured for suppression" + ); + assert_eq!( + preserved_bid.burl.as_deref(), + Some("https://ssp.example/bill?id=abc123"), + "should preserve burl for bidders not configured for suppression" + ); + } + + #[test] + fn parse_bid_preserves_ad_id_alongside_cache_id() { + let bid_json = serde_json::json!({ + "id": "bid-impression-id", + "impid": "atf_sidebar_ad", + "adid": "bidder-ad-id-abc", + "price": 1.0, + "w": 300, + "h": 250, + "ext": { + "prebid": { + "cache": { + "bids": { + "url": "https://cache.example.com/cache", + "cacheId": "cache-uuid-xyz" + } + } + } + } + }); + let provider = PrebidAuctionProvider::new(base_config()); + let bid = provider + .parse_bid(&bid_json, "appnexus") + .expect("should parse bid"); + assert_eq!( + bid.ad_id.as_deref(), + Some("bidder-ad-id-abc"), + "should keep ad_id from adid field" + ); + assert_eq!( + bid.cache_id.as_deref(), + Some("cache-uuid-xyz"), + "should extract cache UUID separately" + ); + } + + #[test] + fn copy_request_headers_replaces_client_supplied_xff_with_attested_ip() { + let from = http::Request::builder() + .uri("https://publisher.example.com/") + .header("x-forwarded-for", "6.6.6.6") + .header(header::USER_AGENT, "test-agent") + .body(EdgeBody::empty()) + .expect("should build inbound request"); + let mut to = http::Request::builder() + .uri("https://pbs.example.com/openrtb2/auction") + .body(EdgeBody::empty()) + .expect("should build outbound request"); + + copy_request_headers( + &from, + &mut to, + ConsentForwardingMode::Both, + Some(std::net::IpAddr::from([203, 0, 113, 7])), + ); + + assert_eq!( + to.headers() + .get("x-forwarded-for") + .and_then(|v| v.to_str().ok()), + Some("203.0.113.7"), + "should synthesize XFF from the platform-attested client IP, not the spoofable inbound header" + ); + assert_eq!( + to.headers() + .get(header::USER_AGENT) + .and_then(|v| v.to_str().ok()), + Some("test-agent"), + "should still copy the browser User-Agent" + ); + } + + #[test] + fn copy_request_headers_omits_xff_without_attested_client_ip() { + let from = http::Request::builder() + .uri("https://publisher.example.com/") + .header("x-forwarded-for", "6.6.6.6") + .body(EdgeBody::empty()) + .expect("should build inbound request"); + let mut to = http::Request::builder() + .uri("https://pbs.example.com/openrtb2/auction") + .body(EdgeBody::empty()) + .expect("should build outbound request"); + + copy_request_headers(&from, &mut to, ConsentForwardingMode::Both, None); + + assert!( + !to.headers().contains_key("x-forwarded-for"), + "should not forward the client-supplied XFF when no attested IP exists" + ); + } } diff --git a/crates/trusted-server-core/src/integrations/registry.rs b/crates/trusted-server-core/src/integrations/registry.rs index 103657e3e..22e6f4e7d 100644 --- a/crates/trusted-server-core/src/integrations/registry.rs +++ b/crates/trusted-server-core/src/integrations/registry.rs @@ -1131,6 +1131,14 @@ impl IntegrationRegistry { .collect() } + #[cfg(test)] + #[must_use] + pub fn empty_for_tests() -> Self { + Self { + inner: Arc::new(IntegrationRegistryInner::default()), + } + } + #[cfg(test)] #[must_use] pub fn from_rewriters( diff --git a/crates/trusted-server-core/src/integrations/sourcepoint.rs b/crates/trusted-server-core/src/integrations/sourcepoint.rs index 644d9da5b..9a63b1930 100644 --- a/crates/trusted-server-core/src/integrations/sourcepoint.rs +++ b/crates/trusted-server-core/src/integrations/sourcepoint.rs @@ -1449,9 +1449,9 @@ mod tests { let integration = SourcepointIntegration::new(Arc::new(config(true))); let document_state = IntegrationDocumentState::default(); let ctx = IntegrationHtmlContext { - request_host: "ts.prospecta.com", + request_host: "ts.example.com", request_scheme: "https", - origin_host: "origin.prospecta.com", + origin_host: "origin.example.com", document_state: &document_state, }; diff --git a/crates/trusted-server-core/src/integrations/testlight.rs b/crates/trusted-server-core/src/integrations/testlight.rs index 5e32cc89c..3feda5b62 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 c177544f1..48e92faed 100644 --- a/crates/trusted-server-core/src/lib.rs +++ b/crates/trusted-server-core/src/lib.rs @@ -35,6 +35,7 @@ pub(crate) mod asset_image_optimizer; pub mod auction; pub mod auction_config_types; pub mod auth; +pub mod cache_policy; pub mod config; pub mod config_payload; pub mod consent; @@ -42,6 +43,7 @@ pub mod consent_config; pub mod constants; pub mod cookies; pub mod creative; +pub mod creative_opportunities; pub mod ec; pub(crate) mod edge_cookie; pub mod error; @@ -54,10 +56,12 @@ pub mod integrations; pub mod models; pub mod openrtb; pub mod platform; +pub mod price_bucket; pub mod proxy; pub mod publisher; pub mod redacted; pub mod request_signing; +pub mod response_privacy; pub mod rsc_flight; pub(crate) mod s3_sigv4; pub mod settings; diff --git a/crates/trusted-server-core/src/openrtb.rs b/crates/trusted-server-core/src/openrtb.rs index a9f02adf3..c0fc5b8d7 100644 --- a/crates/trusted-server-core/src/openrtb.rs +++ b/crates/trusted-server-core/src/openrtb.rs @@ -65,7 +65,7 @@ pub struct ConsentedProvidersSettings { } /// An Extended User ID entry from an identity provider. -#[derive(Debug, Clone, Deserialize, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct Eid { /// Identity provider domain (e.g. `"id5-sync.com"`). pub source: String, @@ -74,7 +74,7 @@ pub struct Eid { } /// A single user identifier within an [`Eid`] entry. -#[derive(Debug, Clone, Deserialize, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct Uid { /// The identifier value. pub id: String, @@ -152,9 +152,21 @@ pub struct ImpExt { impl ToExt for ImpExt {} -#[derive(Debug, Serialize)] +#[derive(Debug, Default, Serialize)] pub struct PrebidImpExt { + #[serde(skip_serializing_if = "std::collections::HashMap::is_empty")] pub bidder: std::collections::HashMap, + #[serde(skip_serializing_if = "Option::is_none")] + pub storedrequest: Option, +} + +/// PBS imp-level stored request reference. +/// +/// PBS merges the stored imp JSON (keyed by `id`) into the outgoing request, +/// populating bidder params that are not sent inline. +#[derive(Debug, Serialize)] +pub struct ImpStoredRequest { + pub id: String, } #[derive(Debug, Serialize)] diff --git a/crates/trusted-server-core/src/platform/test_support.rs b/crates/trusted-server-core/src/platform/test_support.rs index 032859b8f..ee7201fb8 100644 --- a/crates/trusted-server-core/src/platform/test_support.rs +++ b/crates/trusted-server-core/src/platform/test_support.rs @@ -1,15 +1,12 @@ use std::collections::{HashMap, VecDeque}; use std::net::IpAddr; use std::sync::{Arc, Mutex}; -use std::time::Duration; use base64::{engine::general_purpose, Engine as _}; use ed25519_dalek::SigningKey; use error_stack::{Report, ResultExt as _}; use rand::rngs::OsRng; -use edgezero_core::key_value_store::{KvError, KvPage, KvStore as PlatformKvStore}; - use super::{ ClientInfo, GeoInfo, PlatformBackend, PlatformBackendSpec, PlatformConfigStore, PlatformError, PlatformGeo, PlatformHttpClient, PlatformHttpRequest, PlatformImageOptimizerOptions, @@ -559,67 +556,6 @@ impl PlatformHttpClient for StubHttpClient { } } -// --------------------------------------------------------------------------- -// RecordingKvStore -// --------------------------------------------------------------------------- - -/// Test stub for [`PlatformKvStore`] that records `delete()` keys for assertion. -/// -/// All other operations are no-ops: reads return `Ok(None)`, writes return `Ok(())`. -pub(crate) struct RecordingKvStore { - deleted: Mutex>, -} - -impl RecordingKvStore { - pub(crate) fn new() -> Self { - Self { - deleted: Mutex::new(Vec::new()), - } - } - - /// Return the keys passed to `delete()`, in call order. - pub(crate) fn deleted_keys(&self) -> Vec { - self.deleted.lock().expect("should lock deleted").clone() - } -} - -#[async_trait::async_trait(?Send)] -impl PlatformKvStore for RecordingKvStore { - async fn get_bytes(&self, _key: &str) -> Result, KvError> { - Ok(None) - } - - async fn put_bytes(&self, _key: &str, _value: bytes::Bytes) -> Result<(), KvError> { - Ok(()) - } - - async fn put_bytes_with_ttl( - &self, - _key: &str, - _value: bytes::Bytes, - _ttl: Duration, - ) -> Result<(), KvError> { - Ok(()) - } - - async fn delete(&self, key: &str) -> Result<(), KvError> { - self.deleted - .lock() - .expect("should lock deleted") - .push(key.to_owned()); - Ok(()) - } - - async fn list_keys_page( - &self, - _prefix: &str, - _cursor: Option<&str>, - _limit: usize, - ) -> Result { - Ok(KvPage::default()) - } -} - pub(crate) struct NoopGeo; impl PlatformGeo for NoopGeo { @@ -919,6 +855,7 @@ mod tests { host_header_override: None, certificate_check: true, first_byte_timeout: DEFAULT_FIRST_BYTE_TIMEOUT, + between_bytes_timeout: DEFAULT_FIRST_BYTE_TIMEOUT, }; let name = stub.ensure(&spec).expect("should return a backend name"); assert_eq!(name, "stub-backend", "should return fixed name"); diff --git a/crates/trusted-server-core/src/platform/types.rs b/crates/trusted-server-core/src/platform/types.rs index 77f7d6c5e..23f57a580 100644 --- a/crates/trusted-server-core/src/platform/types.rs +++ b/crates/trusted-server-core/src/platform/types.rs @@ -139,6 +139,8 @@ pub struct PlatformBackendSpec { pub certificate_check: bool, /// Maximum time to wait for the first response byte. pub first_byte_timeout: Duration, + /// Maximum time to wait between response body bytes. + pub between_bytes_timeout: Duration, } /// Cloneable container of platform services for a single request. diff --git a/crates/trusted-server-core/src/price_bucket.rs b/crates/trusted-server-core/src/price_bucket.rs new file mode 100644 index 000000000..30b7430de --- /dev/null +++ b/crates/trusted-server-core/src/price_bucket.rs @@ -0,0 +1,171 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum PriceGranularity { + Low, + Medium, + #[default] + Dense, + High, + Auto, +} + +/// Convert a CPM in dollars to whole cents, flooring to the cent. +/// +/// Multiplying by 100 and flooring directly under-buckets common CPMs because +/// many two-decimal values are not exactly representable in binary floating +/// point: `0.29 * 100.0` is `28.999…`, which would truncate to `28` ("0.28"). +/// A tiny epsilon corrects values sitting an ULP below a cent boundary without +/// promoting genuinely sub-cent values — `0.015` (`1.4999…`) still floors to +/// `1` ("0.01"), while `0.29` correctly yields `29`. +fn cpm_to_cents(cpm: f64) -> u64 { + const CENT_EPSILON: f64 = 1e-6; + (cpm * 100.0 + CENT_EPSILON).floor() as u64 +} + +#[must_use] +pub fn price_bucket(cpm: f64, granularity: PriceGranularity) -> String { + // Reject NaN / Inf early so the cast in `cpm_to_cents` can never see a + // non-finite value (the cast's behaviour for NaN/Inf is implementation- + // defined in Rust and "saturate to 0" only by convention). + if !cpm.is_finite() || cpm <= 0.0 { + return "0.00".to_string(); + } + match granularity { + PriceGranularity::Low => { + let cents = cpm_to_cents(cpm.min(5.0)); + let bucketed_cents = (cents / 50) * 50; + format!("{:.2}", bucketed_cents as f64 / 100.0) + } + PriceGranularity::Medium => { + let cents = cpm_to_cents(cpm.min(20.0)); + let bucketed_cents = (cents / 10) * 10; + format!("{:.2}", bucketed_cents as f64 / 100.0) + } + PriceGranularity::High => { + let cents = cpm_to_cents(cpm.min(20.0)); + format!("{:.2}", cents as f64 / 100.0) + } + PriceGranularity::Dense | PriceGranularity::Auto => dense_bucket(cpm), + } +} + +fn dense_bucket(cpm: f64) -> String { + if cpm >= 20.0 { + return "20.00".to_string(); + } + if cpm >= 8.0 { + let bucketed_cents = (cpm_to_cents(cpm) / 50) * 50; + return format!("{:.2}", bucketed_cents as f64 / 100.0); + } + if cpm >= 3.0 { + let bucketed_cents = (cpm_to_cents(cpm) / 5) * 5; + return format!("{:.2}", bucketed_cents as f64 / 100.0); + } + format!("{:.2}", cpm_to_cents(cpm) as f64 / 100.0) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn dense_below_3_increments_by_0_01() { + assert_eq!(price_bucket(0.0, PriceGranularity::Dense), "0.00"); + assert_eq!(price_bucket(0.01, PriceGranularity::Dense), "0.01"); + assert_eq!(price_bucket(0.015, PriceGranularity::Dense), "0.01"); + assert_eq!(price_bucket(1.23, PriceGranularity::Dense), "1.23"); + assert_eq!(price_bucket(2.99, PriceGranularity::Dense), "2.99"); + } + + #[test] + fn dense_3_to_8_increments_by_0_05() { + assert_eq!(price_bucket(3.00, PriceGranularity::Dense), "3.00"); + assert_eq!(price_bucket(3.03, PriceGranularity::Dense), "3.00"); + assert_eq!(price_bucket(3.05, PriceGranularity::Dense), "3.05"); + assert_eq!(price_bucket(7.99, PriceGranularity::Dense), "7.95"); + } + + #[test] + fn dense_8_to_20_increments_by_0_50() { + assert_eq!(price_bucket(8.00, PriceGranularity::Dense), "8.00"); + assert_eq!(price_bucket(8.49, PriceGranularity::Dense), "8.00"); + assert_eq!(price_bucket(8.50, PriceGranularity::Dense), "8.50"); + assert_eq!(price_bucket(19.99, PriceGranularity::Dense), "19.50"); + } + + #[test] + fn dense_above_20_caps_at_20() { + assert_eq!(price_bucket(20.00, PriceGranularity::Dense), "20.00"); + assert_eq!(price_bucket(50.00, PriceGranularity::Dense), "20.00"); + } + + #[test] + fn low_increments_by_0_50_capped_at_5() { + assert_eq!(price_bucket(0.49, PriceGranularity::Low), "0.00"); + assert_eq!(price_bucket(0.50, PriceGranularity::Low), "0.50"); + assert_eq!(price_bucket(5.01, PriceGranularity::Low), "5.00"); + } + + #[test] + fn medium_increments_by_0_10_capped_at_20() { + assert_eq!(price_bucket(1.05, PriceGranularity::Medium), "1.00"); + assert_eq!(price_bucket(1.10, PriceGranularity::Medium), "1.10"); + assert_eq!(price_bucket(20.5, PriceGranularity::Medium), "20.00"); + } + + #[test] + fn high_increments_by_0_01_capped_at_20() { + assert_eq!(price_bucket(1.234, PriceGranularity::High), "1.23"); + assert_eq!(price_bucket(20.5, PriceGranularity::High), "20.00"); + } + + #[test] + fn auto_routes_through_dense() { + assert_eq!( + price_bucket(2.53, PriceGranularity::Auto), + price_bucket(2.53, PriceGranularity::Dense) + ); + } + + #[test] + fn float_boundary_cpms_are_not_under_bucketed() { + // These two-decimal CPMs are not exactly representable in binary float + // (`0.29 * 100.0 == 28.999…`); a naive floor truncates them a cent low. + assert_eq!(price_bucket(0.29, PriceGranularity::Dense), "0.29"); + assert_eq!(price_bucket(1.15, PriceGranularity::Dense), "1.15"); + assert_eq!(price_bucket(0.29, PriceGranularity::High), "0.29"); + assert_eq!(price_bucket(1.15, PriceGranularity::High), "1.15"); + // Genuinely sub-cent values must still floor, not round up. + assert_eq!(price_bucket(0.289, PriceGranularity::High), "0.28"); + assert_eq!(price_bucket(0.015, PriceGranularity::Dense), "0.01"); + } + + #[test] + fn non_finite_cpm_returns_zero_bucket() { + for granularity in [ + PriceGranularity::Dense, + PriceGranularity::Low, + PriceGranularity::Medium, + PriceGranularity::High, + PriceGranularity::Auto, + ] { + assert_eq!( + price_bucket(f64::NAN, granularity), + "0.00", + "NaN cpm should bucket to 0.00 for granularity {granularity:?}" + ); + assert_eq!( + price_bucket(f64::INFINITY, granularity), + "0.00", + "+Inf cpm should bucket to 0.00 for granularity {granularity:?}" + ); + assert_eq!( + price_bucket(f64::NEG_INFINITY, granularity), + "0.00", + "-Inf cpm should bucket to 0.00 for granularity {granularity:?}" + ); + } + } +} diff --git a/crates/trusted-server-core/src/proxy.rs b/crates/trusted-server-core/src/proxy.rs index 9bf58e630..b41807d67 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 { @@ -942,10 +960,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( @@ -1071,6 +1086,7 @@ pub async fn handle_asset_proxy_request( host_header_override: None, certificate_check: settings.proxy.certificate_check, first_byte_timeout: DEFAULT_FIRST_BYTE_TIMEOUT, + between_bytes_timeout: DEFAULT_FIRST_BYTE_TIMEOUT, }) .change_context(TrustedServerError::Proxy { message: "asset backend registration failed".to_string(), @@ -1127,6 +1143,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) } @@ -1254,6 +1277,7 @@ async fn proxy_with_redirects( host_header_override: None, certificate_check: settings.proxy.certificate_check, first_byte_timeout: DEFAULT_FIRST_BYTE_TIMEOUT, + between_bytes_timeout: DEFAULT_FIRST_BYTE_TIMEOUT, }) .change_context(TrustedServerError::Proxy { message: "backend registration failed".to_string(), @@ -1985,6 +2009,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, @@ -1995,6 +2020,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}; @@ -2009,9 +2035,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; @@ -3681,6 +3707,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 1e41044e8..fa181fae5 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -4,34 +4,63 @@ //! the API boundary: //! - pass-through for non-processable `2xx` content //! - streamed processing for stream-safe processable responses -//! - buffered responses for unsupported encodings, `204/205`, or HTML routes -//! that require full-document post-processing +//! - buffered responses for unsupported encodings or `204/205` //! //! Unsupported `Content-Encoding` values must bypass rewriting entirely. The //! streaming processor treats unknown encodings as identity, so publisher code //! must gate them out before the body enters the rewrite pipeline. +//! +//! **Note on platform coupling:** The handler boundaries use portable HTTP +//! types: [`handle_publisher_request`] and [`stream_publisher_body`] take and +//! return `http::Request`/`http::Response` over `EdgeBody`, and platform I/O is +//! reached through `RuntimeServices` rather than `fastly::*` directly. The +//! streaming processor itself is generic: `process_response_streaming` writes +//! 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::sync::{Arc, Mutex}; use std::time::Duration; +use cookie::CookieJar; use edgezero_core::body::Body as EdgeBody; use error_stack::{Report, ResultExt}; use http::{header, HeaderValue, Method, Request, Response, StatusCode, Uri}; -use crate::consent::{allows_ec_creation, build_consent_context, ConsentPipelineInput}; -use crate::constants::{COOKIE_TS_EC, HEADER_X_COMPRESS_HINT, HEADER_X_TS_EC}; +use crate::auction::endpoints::{ + merge_auction_eids, resolve_auction_eids, resolve_client_auction_eids, +}; +use crate::auction::orchestrator::{AuctionOrchestrator, DispatchedAuction}; +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; -use crate::edge_cookie::get_or_generate_ec_id_from_http_request; +use crate::ec::kv::KvIdentityGraph; +use crate::ec::registry::PartnerRegistry; +use crate::ec::EcContext; use crate::error::TrustedServerError; -use crate::http_util::{serve_static_with_etag, RequestInfo}; +use crate::http_util::{is_navigation_request, serve_static_with_etag, RequestInfo}; use crate::integrations::IntegrationRegistry; -use crate::platform::{PlatformBackendSpec, PlatformHttpRequest, RuntimeServices}; +use crate::platform::{GeoInfo, PlatformBackendSpec, PlatformHttpRequest, RuntimeServices}; +use crate::price_bucket::{price_bucket, PriceGranularity}; use crate::rsc_flight::RscFlightUrlRewriter; use crate::settings::Settings; use crate::streaming_processor::{Compression, PipelineConfig, StreamProcessor, StreamingPipeline}; use crate::streaming_replacer::create_url_replacer; + const SUPPORTED_ENCODING_VALUES: [&str; 3] = ["gzip", "deflate", "br"]; const DEFAULT_PUBLISHER_FIRST_BYTE_TIMEOUT: Duration = Duration::from_secs(15); +/// Read buffer size for streaming body processing and brotli internal buffers. +/// Both the `Decompressor` and `CompressorWriter` use this value so all +/// brotli I/O layers operate on consistently-sized chunks. +const STREAM_CHUNK_SIZE: usize = 8192; + fn body_as_reader(body: EdgeBody) -> std::io::Cursor { std::io::Cursor::new(body.into_bytes().unwrap_or_default()) } @@ -133,6 +162,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"]; @@ -147,10 +177,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) { @@ -159,18 +187,45 @@ 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()) } +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, @@ -187,7 +242,7 @@ fn parse_deferred_module_filename(filename: &str) -> Option<&'static str> { .find(|&id| id == stem) } -/// Parameters for processing response streaming +/// Parameters for processing response streaming. struct ProcessResponseParams<'a> { content_encoding: &'a str, origin_host: &'a str, @@ -197,6 +252,8 @@ struct ProcessResponseParams<'a> { settings: &'a Settings, content_type: &'a str, integration_registry: &'a IntegrationRegistry, + ad_slots_script: Option<&'a str>, + ad_bids_state: &'a Arc>>, } /// Process response body through the streaming pipeline. @@ -214,8 +271,9 @@ fn process_response_streaming( output: &mut W, params: &ProcessResponseParams, ) -> Result<(), Report> { - let is_html = params.content_type.contains("text/html"); - let is_rsc_flight = params.content_type.contains("text/x-component"); + let is_html = is_html_content_type(params.content_type); + 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={}", params.content_type, @@ -238,9 +296,13 @@ fn process_response_streaming( params.request_scheme, params.settings, params.integration_registry, + params.ad_slots_script.map(str::to_string), + params.ad_bids_state.clone(), )?; StreamingPipeline::new(config, processor).process(body_as_reader(body), output)?; } else if is_rsc_flight { + // RSC Flight responses are length-prefixed (T rows). A naive string replacement will + // corrupt the stream by changing byte lengths without updating the prefixes. let processor = RscFlightUrlRewriter::new( params.origin_host, params.origin_url, @@ -261,13 +323,21 @@ fn process_response_streaming( Ok(()) } -/// Create a unified HTML stream processor +/// Create a unified HTML stream processor. +/// +/// Builds the config via [`HtmlProcessorConfig::from_settings`] and then +/// layers the auction-hold streaming fields on top via +/// [`HtmlProcessorConfig::with_ad_state`], so the canonical builder stays the +/// single source of truth: a future field added to `from_settings` is +/// inherited here automatically. fn create_html_stream_processor( origin_host: &str, request_host: &str, request_scheme: &str, settings: &Settings, integration_registry: &IntegrationRegistry, + ad_slots_script: Option, + ad_bids_state: Arc>>, ) -> Result> { use crate::html_processor::{create_html_processor, HtmlProcessorConfig}; @@ -277,33 +347,39 @@ fn create_html_stream_processor( origin_host, request_host, request_scheme, - ); + ) + .with_ad_state(ad_slots_script, ad_bids_state); Ok(create_html_processor(config)) } -/// Result of publisher request handling, indicating whether the response -/// body should be streamed or has already been buffered. +/// Result of publisher request handling, indicating whether the response body +/// should be streamed or has already been buffered. pub enum PublisherResponse { /// Response is fully buffered and ready to send via `send_to_client()`. Buffered(Response), /// Response headers are ready for a streaming response. Covers processable /// content on any status (2xx or non-2xx — e.g., branded 404/500 HTML and - /// error JSON still get URL rewriting) where the encoding is supported - /// and either the content is non-HTML or no HTML post-processors are - /// registered. The caller must: + /// error JSON still get URL rewriting) where the encoding is supported. + /// Post-processors run inside the streaming processor, so processable HTML + /// is streamed regardless of whether any are registered. The caller must: /// 1. Call `finalize_response()` on the response /// 2. Call `response.stream_to_client()` to get a `StreamingBody` /// 3. Call `stream_publisher_body()` with the body and streaming writer /// 4. Call `StreamingBody::finish()` + /// + /// **Interim (PR 15):** `body` has already been fully materialised into + /// WASM heap by the platform HTTP client. `stream_publisher_body` reads + /// from an in-memory buffer, not a live origin stream. The origin-side + /// peak is bounded by `MAX_PLATFORM_RESPONSE_BODY_BYTES`. Stream { /// Response with all headers set (EC ID, cookies, etc.) /// but body not yet written. `Content-Length` already removed. response: Response, /// Origin body to be piped through the streaming pipeline. body: EdgeBody, - /// Parameters for `process_response_streaming`. - params: OwnedProcessResponseParams, + /// Parameters for [`process_response_streaming`]. + params: Box, }, /// Non-processable 2xx response (images, fonts, video). The adapter must /// reattach the body via setting the body before returning. @@ -311,6 +387,11 @@ pub enum PublisherResponse { /// response-dispatch level, not in this arm. /// /// `Content-Length` is preserved — the body is unmodified. + /// + /// **Interim (PR 15):** `body` has been fully materialised into WASM heap. + /// Previously, binary assets streamed lazily from origin with no WASM + /// buffering. This path is now bounded by `MAX_PLATFORM_RESPONSE_BODY_BYTES`; + /// assets exceeding that limit return an error instead of exhausting heap. PassThrough { /// Response with all headers set but body not yet written. response: Response, @@ -327,45 +408,30 @@ pub enum PublisherResponse { /// exercise the classifier directly so the gate formula lives in one place. #[derive(Debug, PartialEq, Eq)] pub(crate) enum ResponseRoute { - /// 2xx non-processable (images, fonts, video), not 204/205. Origin body - /// is streamed unmodified via [`PublisherResponse::PassThrough`]. + /// `2xx` non-processable content (images, fonts, video), not `204/205`. PassThrough, - /// Processable content with supported encoding and either non-HTML or no - /// HTML post-processors registered. Covers both 2xx and non-2xx (e.g., - /// branded 404/500 pages still get origin URL rewriting). Routed through - /// [`PublisherResponse::Stream`]. + /// Processable content with supported encoding. Stream, - /// Response returned unmodified via [`PublisherResponse::Buffered`] — covers - /// 204/205 (RFC-prohibited bodies), empty request host with non-processable - /// content, and unsupported encodings. + /// Response returned unmodified via [`PublisherResponse::Buffered`]. BufferedUnmodified, - /// HTML with post-processors registered. Runs the full pipeline into a - /// buffer, then returns [`PublisherResponse::Buffered`] with the processed body. - BufferedProcessed, } /// Decide how a proxied response should be routed. /// -/// Pure: no header mutation, no body consumed. All inputs are extracted -/// from the origin response at the call site. +/// Pure: no header mutation, no body consumed. All inputs are extracted from +/// the origin response at the call site. pub(crate) fn classify_response_route( status: StatusCode, content_type: &str, content_encoding: &str, request_host: &str, - has_post_processors: bool, ) -> ResponseRoute { - // 204 No Content (RFC 9110 §15.3.5) and 205 Reset Content (§15.3.6) - // prohibit a message body. Excluded first so no later arm can emit one - // regardless of Content-Type or post-processor registration. if status == StatusCode::NO_CONTENT || status == StatusCode::RESET_CONTENT { return ResponseRoute::BufferedUnmodified; } let should_process = is_processable_content_type(content_type); - // Non-processable content: 2xx streams through unchanged; non-2xx falls - // back to buffered (the origin's error body reaches the client as-is). if !should_process { if status.is_success() { return ResponseRoute::PassThrough; @@ -373,29 +439,46 @@ pub(crate) fn classify_response_route( return ResponseRoute::BufferedUnmodified; } - // Processable content (2xx or non-2xx) still needs URL rewriting against - // a known request host — without one, fall back to unmodified. if request_host.is_empty() { return ResponseRoute::BufferedUnmodified; } - // Unsupported Content-Encoding: we cannot decompress, so processing would - // treat compressed bytes as identity and produce garbled output. if !is_supported_content_encoding(content_encoding) { return ResponseRoute::BufferedUnmodified; } - let is_html = content_type.contains("text/html"); - if is_html && has_post_processors { - // HTML with post-processors: need the full document to inject. - return ResponseRoute::BufferedProcessed; + 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(()); } - ResponseRoute::Stream + 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. +/// [`handle_publisher_request`] without lifetime issues. pub struct OwnedProcessResponseParams { pub(crate) content_encoding: String, pub(crate) origin_host: String, @@ -403,48 +486,81 @@ pub struct OwnedProcessResponseParams { pub(crate) request_host: String, pub(crate) request_scheme: String, pub(crate) content_type: String, + pub(crate) ad_slots_script: Option, + pub(crate) ad_bids_state: Arc>>, + /// In-flight SSP bids dispatched before `pending_origin.wait()`. + /// The streaming phase collects these and writes bids to `ad_bids_state` + /// before processing the last body chunk, so `` injection sees live bids. + pub(crate) dispatched_auction: Option, + /// Price granularity used to bucket bids when building `tsjs.bids`. + pub(crate) price_granularity: PriceGranularity, } -/// Buffer a [`PublisherResponse`] into a single [`Response`]. +/// Buffers a [`PublisherResponse`] into a single [`Response`], collecting the +/// dispatched server-side auction before buffering. /// /// Handles all three variants: returns [`PublisherResponse::Buffered`] unchanged, -/// pipes [`PublisherResponse::Stream`] through the streaming pipeline into memory, -/// and reattaches [`PublisherResponse::PassThrough`] bodies directly. +/// pipes [`PublisherResponse::Stream`] through the streaming pipeline into +/// memory, and reattaches [`PublisherResponse::PassThrough`] bodies directly. /// /// The buffered size is capped by `settings.publisher.max_buffered_body_bytes` -/// (16 MiB by default), so processable origin responses cannot grow the -/// buffer without bound and exhaust the Wasm heap. +/// (16 MiB by default), so processable origin responses cannot grow the buffer +/// without bound and exhaust the Wasm heap. /// -/// `method` is used to preserve metadata for bodiless responses: `HEAD` and -/// bodiless statuses (204, 304) carry no body but may advertise the `GET` -/// representation's length. `handle_publisher_request` already strips the origin -/// `Content-Length` for processable [`PublisherResponse::Stream`] responses, so -/// rewriting it here to the buffered byte count (`0`) would replace it with a -/// misleading length. Those responses skip the buffer, the length rewrite, and -/// the body replacement, mirroring the asset path's bodiless guard. +/// `method` preserves metadata for bodiless responses: `HEAD` and bodiless +/// statuses (204, 304) carry no body but may advertise the `GET` representation's +/// length, so they skip the buffer and length rewrite. +/// +/// Every adapter (Axum, Cloudflare, Spin, and the Fastly `EdgeZero` path) calls +/// this: it drives +/// [`stream_publisher_body_async`], which awaits +/// [`AuctionOrchestrator::collect_dispatched_auction`], writes the winning bids +/// into `ad_bids_state`, and injects them before ``. /// /// # Errors /// /// Returns an error if the streaming pipeline fails to process the response /// body, or if the processed body exceeds the configured buffer cap. -pub fn buffer_publisher_response( +pub async fn buffer_publisher_response_async( publisher_response: PublisherResponse, method: &Method, settings: &Settings, integration_registry: &IntegrationRegistry, + orchestrator: &AuctionOrchestrator, + services: &RuntimeServices, ) -> Result, Report> { match publisher_response { PublisherResponse::Buffered(response) => Ok(response), PublisherResponse::Stream { mut response, body, - params, + mut params, } => { if !response_carries_body(method, response.status()) { + if params.dispatched_auction.is_some() { + // A bodiless response (HEAD navigation, 204/304) has no + // `` to inject bids into, so the dispatched SSP + // requests are wasted — surface it for quota observability, + // matching the pass-through / buffered-unmodified arms. + log::warn!( + "Server-side auction dispatched but response is bodiless (method: {}, status: {}); in-flight SSP bid requests will not be collected", + method, + response.status(), + ); + } return Ok(response); } let mut output = BoundedWriter::new(settings.publisher.max_buffered_body_bytes); - stream_publisher_body(body, &mut output, ¶ms, settings, integration_registry)?; + stream_publisher_body_async( + body, + &mut output, + &mut params, + settings, + integration_registry, + orchestrator, + services, + ) + .await?; let bytes = output.into_inner(); response.headers_mut().insert( http::header::CONTENT_LENGTH, @@ -472,36 +588,6 @@ fn response_carries_body(method: &Method, status: StatusCode) -> bool { && status != StatusCode::NOT_MODIFIED } -/// Stream the publisher response body through the processing pipeline. -/// -/// Called by the adapter after `stream_to_client()` has committed the response -/// headers. Writes processed chunks directly to `output`. -/// -/// # Errors -/// -/// Returns an error if processing fails mid-stream. Since headers are -/// already committed, the caller should log the error and drop the -/// `StreamingBody` (client sees a truncated response). -pub fn stream_publisher_body( - body: EdgeBody, - output: &mut W, - params: &OwnedProcessResponseParams, - settings: &Settings, - integration_registry: &IntegrationRegistry, -) -> Result<(), Report> { - let borrowed = ProcessResponseParams { - content_encoding: ¶ms.content_encoding, - origin_host: ¶ms.origin_host, - origin_url: ¶ms.origin_url, - request_host: ¶ms.request_host, - request_scheme: ¶ms.request_scheme, - settings, - content_type: ¶ms.content_type, - integration_registry, - }; - process_response_streaming(body, output, &borrowed) -} - /// A [`Write`] sink that buffers into a `Vec` but fails once the configured /// byte limit would be exceeded. /// @@ -547,18 +633,602 @@ impl Write for BoundedWriter { } } +/// Stream the publisher response body through the processing pipeline. +/// +/// Called by the adapter after `stream_to_client()` has committed the response +/// headers. Runs synchronously against an already-materialised body; the async +/// I/O happened upstream in [`handle_publisher_request`]. Writes processed +/// chunks directly to `output`. +/// +/// # Errors +/// +/// Returns an error if processing fails mid-stream. Since headers are already +/// committed, the caller should log the error and drop the `StreamingBody` +/// (client sees a truncated response). +pub fn stream_publisher_body( + body: EdgeBody, + output: &mut W, + params: &OwnedProcessResponseParams, + settings: &Settings, + integration_registry: &IntegrationRegistry, +) -> Result<(), Report> { + let borrowed = ProcessResponseParams { + content_encoding: ¶ms.content_encoding, + origin_host: ¶ms.origin_host, + origin_url: ¶ms.origin_url, + request_host: ¶ms.request_host, + request_scheme: ¶ms.request_scheme, + settings, + content_type: ¶ms.content_type, + integration_registry, + ad_slots_script: params.ad_slots_script.as_deref(), + ad_bids_state: ¶ms.ad_bids_state, + }; + process_response_streaming(body, output, &borrowed) +} + +/// Stream publisher body with a `` 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. +/// +/// # Errors +/// +/// Returns an error if processing fails mid-stream. Headers are already +/// committed at that point; the caller logs and drops the `StreamingBody`. +pub async fn stream_publisher_body_async( + body: EdgeBody, + output: &mut W, + params: &mut OwnedProcessResponseParams, + settings: &Settings, + integration_registry: &IntegrationRegistry, + 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 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; + 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 = 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(), + )?; + + let compression = Compression::from_content_encoding(¶ms.content_encoding); + stream_html_with_auction_hold( + body, + output, + &mut processor, + compression, + AuctionCollectCtx { + dispatched, + price_granularity: params.price_granularity, + ad_bids_state: ¶ms.ad_bids_state, + orchestrator, + services, + settings, + }, + ) + .await +} + +/// Builds the canonical mediator placeholder [`Request`] passed to the collect +/// phase via [`make_collect_context`]. +/// +/// The URI is the compile-time constant +/// [`MEDIATOR_PLACEHOLDER_URL`](crate::auction::types::MEDIATOR_PLACEHOLDER_URL), +/// so the builder is infallible; a default-URI fallback would trip +/// [`make_collect_context`]'s `debug_assert_eq!`. +fn mediator_placeholder_request() -> Request { + Request::builder() + .uri(crate::auction::types::MEDIATOR_PLACEHOLDER_URL) + .body(EdgeBody::empty()) + .expect("MEDIATOR_PLACEHOLDER_URL should be a valid URI") +} + +/// Build a minimal [`AuctionContext`] for the collect phase. +/// +/// See [`AuctionContext::request`]: the orchestrator's collect path runs +/// after `send_async` has already consumed the real client request, so this +/// context carries a synthetic placeholder. The orchestrator itself +/// instantiates a fresh placeholder when it actually invokes a mediator — +/// this argument is plumbing for the (presently unused) case where the +/// orchestrator needs the caller's request shape. +fn make_collect_context<'a>( + settings: &'a Settings, + services: &'a RuntimeServices, + placeholder: &'a Request, +) -> AuctionContext<'a> { + debug_assert_eq!( + placeholder.uri().to_string(), + crate::auction::types::MEDIATOR_PLACEHOLDER_URL, + "make_collect_context must be given the canonical placeholder; \ + callers must not forward a real client request through the collect path" + ); + AuctionContext { + settings, + request: placeholder, + timeout_ms: 0, + provider_responses: None, + services, + } +} + +/// Well-known crawler User-Agent fragments. Best-effort: an attacker can +/// trivially spoof their UA, so this is for opt-out signalling to honest +/// crawlers (preventing SSP auctions burning partner quota on their behalf), +/// not security. +pub(crate) const BOT_USER_AGENT_FRAGMENTS: &[&str] = + &["Googlebot", "Bingbot", "AhrefsBot", "SemrushBot", "DotBot"]; + +/// Returns true when the request's User-Agent matches any well-known crawler +/// fragment in [`BOT_USER_AGENT_FRAGMENTS`]. +pub(crate) fn is_bot_user_agent(req: &Request) -> bool { + let ua = req + .headers() + .get("user-agent") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + BOT_USER_AGENT_FRAGMENTS + .iter() + .any(|frag| ua.contains(frag)) +} + +/// Returns true when the request advertises itself as a prefetch via either +/// the standard `Sec-Purpose` or the legacy `Purpose` header. +pub(crate) fn is_prefetch_request(req: &Request) -> bool { + let header = |name: &str| { + req.headers() + .get(name) + .and_then(|v| v.to_str().ok()) + .is_some_and(|v| v.contains("prefetch")) + }; + header("sec-purpose") || header("purpose") +} + +/// Returns true only when the publisher request should run the full +/// server-side ad stack: auction dispatch plus initial ad-slot injection. +/// +/// `auction_enabled` is the global `[auction].enabled` kill switch — when +/// false, no automatic server-side auction or ad-slot injection runs. +pub(crate) fn should_run_server_side_ad_stack( + is_get: bool, + is_navigation: bool, + is_prefetch: bool, + is_bot: bool, + has_matched_slots: bool, + consent_allows_auction: bool, + auction_enabled: bool, +) -> bool { + is_get + && is_navigation + && !is_prefetch + && !is_bot + && has_matched_slots + && consent_allows_auction + && auction_enabled +} + +/// Write winning bids from an auction result into the shared `ad_bids_state` lock. +pub(crate) fn write_bids_to_state( + winning_bids: &std::collections::HashMap, + price_granularity: PriceGranularity, + ad_bids_state: &Arc>>, + inject_adm: bool, +) { + log::debug!( + "write_bids_to_state: {} winning bid(s): [{}]", + winning_bids.len(), + winning_bids.keys().cloned().collect::>().join(", ") + ); + let bid_map = build_bid_map(winning_bids, price_granularity, inject_adm); + let bids_script = build_bids_script(&bid_map); + *ad_bids_state.lock().expect("should lock bid state") = Some(bids_script); +} + +/// Prepend an HTML comment summarising the auction result onto the shared +/// `ad_bids_state` so it lands directly before the injected bids `` injection breaking out of the script context +/// - U+2028, U+2029 — line/paragraph separators that are valid JSON but terminate +/// a JS string literal in some parsers +/// +/// All substitutions use `\uXXXX` form, which is valid inside both JSON strings +/// and JS string literals. The result is always safe to write as `JSON.parse("…")`. +fn html_escape_for_script(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for ch in s.chars() { + match ch { + '\\' => out.push_str("\\\\"), + '"' => out.push_str("\\\""), + '<' => out.push_str("\\u003C"), + '>' => out.push_str("\\u003E"), + '&' => out.push_str("\\u0026"), + '\u{2028}' => out.push_str("\\u2028"), + '\u{2029}' => out.push_str("\\u2029"), + _ => out.push(ch), } } + out +} + +/// Build a price-bucketed bid map from winning bids. +/// +/// Returns a JSON object map of slot ID → bid metadata including the bucketed +/// CPM (`hb_pb`), bidder (`hb_bidder`), and optional ad ID, nurl, and burl. +pub(crate) fn build_bid_map( + winning_bids: &std::collections::HashMap, + granularity: crate::price_bucket::PriceGranularity, + include_adm: bool, +) -> serde_json::Map { + winning_bids + .iter() + .filter_map(|(slot_id, bid)| { + bid.price.map(|cpm| { + let bucket = price_bucket(cpm, granularity); + let mut obj = serde_json::Map::new(); + obj.insert("hb_pb".to_string(), serde_json::Value::String(bucket)); + obj.insert( + "hb_bidder".to_string(), + serde_json::Value::String(bid.bidder.clone()), + ); + // hb_adid: use PBS Cache UUID when present — the Prebid Universal Creative uses + // this as the cache lookup key, NOT the OpenRTB bid ID (bid.ad_id). Fall back to + // bid.ad_id for APS and other non-PBS providers. + let hb_adid = bid.cache_id.as_deref().or(bid.ad_id.as_deref()); + if let Some(id) = hb_adid { + obj.insert( + "hb_adid".to_string(), + serde_json::Value::String(id.to_string()), + ); + } + + // Cache endpoint coordinates — only present for PBS bids with Prebid Cache enabled. + // The Prebid Universal Creative constructs: + // https://?uuid= + if let Some(ref host) = bid.cache_host { + obj.insert( + "hb_cache_host".to_string(), + serde_json::Value::String(host.clone()), + ); + } + if let Some(ref path) = bid.cache_path { + obj.insert( + "hb_cache_path".to_string(), + serde_json::Value::String(path.clone()), + ); + } + if let Some(ref nurl) = bid.nurl { + obj.insert("nurl".to_string(), serde_json::Value::String(nurl.clone())); + } + if let Some(ref burl) = bid.burl { + obj.insert("burl".to_string(), serde_json::Value::String(burl.clone())); + } + // Include raw creative markup only for explicit debug injection. + // The pbRender bridge can use it while PBS Cache is unavailable. + if include_adm { + if let Some(ref adm) = bid.creative { + obj.insert("adm".to_string(), serde_json::Value::String(adm.clone())); + } + obj.insert( + "debug_bid".to_string(), + serde_json::json!({ + "slot_id": bid.slot_id, + "price": bid.price, + "currency": bid.currency, + "creative": bid.creative, + "adomain": bid.adomain, + "bidder": bid.bidder, + "width": bid.width, + "height": bid.height, + "nurl": bid.nurl, + "burl": bid.burl, + "ad_id": bid.ad_id, + "cache_id": bid.cache_id, + "cache_host": bid.cache_host, + "cache_path": bid.cache_path, + "metadata": bid.metadata, + }), + ); + } + (slot_id.clone(), serde_json::Value::Object(obj)) + }) + }) + .collect() +} + +/// Build the `tsjs.bids` `` sequences inside the string. +pub(crate) fn build_bids_script(bid_map: &serde_json::Map) -> String { + let json = serde_json::to_string(bid_map) + .expect("serde_json::to_string of Map should be infallible"); + let escaped = html_escape_for_script(&json); + format!( + "", + escaped + ) +} + +/// Build the empty-bids `", + escaped + ) } /// Whether the content type requires processing (URL rewriting, HTML injection). @@ -829,83 +1924,429 @@ pub async fn handle_publisher_request( /// Text-based and JavaScript/JSON responses are processable; binary types /// (images, fonts, video, etc.) pass through unchanged. fn is_processable_content_type(content_type: &str) -> bool { - content_type.contains("text/") - || content_type.contains("application/javascript") - || content_type.contains("application/json") + let normalized = content_type.to_ascii_lowercase(); + normalized.contains("text/") + || normalized.contains("application/javascript") + || normalized.contains("application/json") +} + +fn is_html_content_type(content_type: &str) -> bool { + content_type_contains_ascii_case_insensitive(content_type, "text/html") +} + +fn content_type_contains_ascii_case_insensitive(content_type: &str, needle: &str) -> bool { + content_type.to_ascii_lowercase().contains(needle) } /// Whether the `Content-Encoding` is one the streaming pipeline can handle. /// /// Unsupported encodings (e.g. `zstd` from a misbehaving origin) bypass the -/// rewrite pipeline entirely and are returned unchanged. Processing such -/// bodies as identity-encoded would produce garbled output. +/// rewrite pipeline entirely and are returned unchanged. Processing such bodies +/// as identity-encoded would produce garbled output. fn is_supported_content_encoding(encoding: &str) -> bool { matches!(encoding, "" | "identity" | "gzip" | "deflate" | "br") } -/// Apply EC ID and cookie headers to the response. +/// Same-origin gate for `/__ts/page-bids`. +/// +/// The endpoint is a side-effecting GET: it dispatches real PBS/APS auctions +/// and forwards request-derived signals (IP, UA, geo, consent) to partners. +/// Without a gate, any third-party page could trigger it from a visitor's +/// browser (it cannot read the JSON, but it burns SSP quota and leaks +/// outbound partner calls). +/// +/// A request is allowed when: +/// - `Sec-Fetch-Site` is `same-origin` (the tsjs SPA hook fetches a relative +/// URL, so a genuine same-origin navigation always reports this). `same-site` +/// is intentionally rejected: it admits sibling origins under the same +/// registrable domain, which are not trusted to spend SSP quota on the +/// visitor's behalf. +/// - `Sec-Fetch-Site` is absent (legacy client predating Fetch Metadata) **and** +/// the request carries the non-simple `X-TSJS-Page-Bids` header set by the +/// tsjs SPA hook — cross-origin callers cannot attach it without a CORS +/// preflight, which this endpoint never grants. +fn page_bids_request_allowed(req: &Request) -> bool { + match req + .headers() + .get("sec-fetch-site") + .and_then(|v| v.to_str().ok()) + { + Some(site) => site == "same-origin", + None => req.headers().contains_key("x-tsjs-page-bids"), + } +} + +/// Builds the `403 Forbidden` returned for a CORS preflight (`OPTIONS`) to the +/// side-effecting `/__ts/page-bids` endpoint. +/// +/// The GET handler's [`page_bids_request_allowed`] gate trusts the +/// `X-TSJS-Page-Bids` header precisely because this endpoint never grants a +/// preflight; letting `OPTIONS` fall through to the publisher origin (which may +/// return permissive CORS) would defeat that, allowing a cross-site page to +/// trigger real PBS/APS auctions from a visitor's browser. Every adapter returns +/// this same response for `OPTIONS /__ts/page-bids`. +pub fn page_bids_preflight_denied() -> Response { + let mut response = Response::new(EdgeBody::from("Forbidden")); + *response.status_mut() = StatusCode::FORBIDDEN; + response.headers_mut().insert( + header::CACHE_CONTROL, + HeaderValue::from_static("private, no-store"), + ); + response +} + +/// Normalizes the client-supplied `path` query parameter before glob matching. +/// +/// The SPA hook sends `location.pathname`, but the parameter is +/// client-controlled: strip any query string or fragment and force a leading +/// `/` so slot `page_patterns` always match against a canonical path shape. +fn normalize_page_bids_path(raw: &str) -> String { + let path = raw.split(['?', '#']).next().unwrap_or(""); + if path.starts_with('/') { + path.to_string() + } else { + format!("/{path}") + } +} + +/// Handle `GET /__ts/page-bids?path=` — server-side auction for SPA navigation. +/// +/// Matches creative opportunity slots for the given path, runs a server-side +/// auction (APS + PBS), and returns the slot definitions and winning bids as JSON. +/// Called by the client-side SPA navigation hook after `pushState` / `popstate`. +/// +/// `kv` enriches the bid request with server-side EIDs from the EC identity +/// graph. Only the Fastly adapter has a KV identity store, so Axum, Cloudflare, +/// and Spin pass `None`; client cookie EIDs are still resolved and consent-gated +/// on every adapter, so no adapter forwards unconsented EIDs. /// -/// Extracted so headers can be set before streaming begins (headers must -/// be finalized before `stream_to_client()` commits them). +/// # Errors /// -/// Consent-gated EC creation: -/// - Consent given → set EC ID header + cookie. -/// - Consent absent + existing cookie → revoke (expire cookie + delete KV entry). -/// - Consent absent + no cookie → do nothing. -fn apply_ec_headers( +/// Returns [`TrustedServerError`] if cookie parsing or EC ID generation fails. +pub async fn handle_page_bids( settings: &Settings, services: &RuntimeServices, - response: &mut Response, - ec_id: &str, - ec_allowed: bool, - existing_ec_cookie: Option<&str>, - consent_context: &crate::consent::ConsentContext, -) { - if ec_allowed { - // HeaderValue::from_str rejects \r, \n, and \0, so the EC ID - // cannot inject additional response headers. - match HeaderValue::from_str(ec_id) { - Ok(v) => { - response.headers_mut().insert(HEADER_X_TS_EC, v); - } - Err(_) => { - log::warn!("Rejecting EC ID response header: value is not a valid header value"); - } - } - // Cookie persistence is skipped if the EC ID contains RFC 6265-illegal - // characters. The header is still emitted when consent allows it. - crate::ec::cookies::set_ec_cookie(settings, response, ec_id); - } else if let Some(cookie_ec_id) = existing_ec_cookie { - log::info!( - "EC revoked for '{}': consent withdrawn (jurisdiction={})", - cookie_ec_id, - consent_context.jurisdiction, + kv: Option<&KvIdentityGraph>, + auction: AuctionDispatch<'_>, + ec_context: &EcContext, + req: Request, +) -> Result, Report> { + let Some(co_config) = &settings.creative_opportunities else { + let mut response = Response::new(EdgeBody::from("Creative opportunities not configured")); + *response.status_mut() = StatusCode::NOT_FOUND; + return Ok(response); + }; + + // CSRF-style gate: refuse cross-site invocations before any auction work. + if !page_bids_request_allowed(&req) { + log::debug!( + "page-bids: rejecting request (sec-fetch-site={:?}, tsjs header present={})", + req.headers() + .get("sec-fetch-site") + .and_then(|v| v.to_str().ok()), + req.headers().contains_key("x-tsjs-page-bids") ); - crate::ec::cookies::expire_ec_cookie(settings, response); - if settings.consent.consent_store.is_some() { - crate::consent::kv::delete_consent_from_kv(services.kv_store(), cookie_ec_id); - } - } else { + let mut response = Response::new(EdgeBody::from("Forbidden")); + *response.status_mut() = StatusCode::FORBIDDEN; + response.headers_mut().insert( + header::CACHE_CONTROL, + HeaderValue::from_static("private, no-store"), + ); + return Ok(response); + } + + let path_param = req + .uri() + .query() + .and_then(|query| { + url::form_urlencoded::parse(query.as_bytes()) + .find(|(k, _)| k == "path") + .map(|(_, v)| normalize_page_bids_path(&v)) + }) + .unwrap_or_else(|| "/".to_string()); + + let matched_slots: Vec<_> = + crate::creative_opportunities::match_slots(auction.slots, &path_param) + .into_iter() + .cloned() + .collect(); + + let request_info = crate::http_util::RequestInfo::from_request(&req, services.client_info()); + let ec_id = ec_context.ec_value().filter(|_| ec_context.ec_allowed()); + let consent_context = ec_context.consent(); + let geo = ec_context.geo_info().cloned(); + let cookie_jar = handle_request_cookies(&req)?; + + // Same fail-closed jurisdiction-aware gate the publisher navigation path + // uses — relies on the adapter's geo-aware EC context. + let consent_allows_auction = consent_allows_server_side_auction(consent_context); + + // Same bot / prefetch guards the publisher path uses — without them this + // endpoint would fire real SSP auctions on Sec-Purpose=prefetch warm-up + // navigations and known crawler UA scans, burning partner request quota. + let is_prefetch = is_prefetch_request(&req); + let is_bot = is_bot_user_agent(&req); + + let auction_enabled = auction.orchestrator.is_enabled(); + if !auction_enabled { + log::debug!("page-bids: [auction].enabled is false — skipping auction"); + } else if matched_slots.is_empty() { + log::debug!( + "No creative opportunity slots matched path '{}' — skipping auction", + path_param + ); + } else if is_bot || is_prefetch { log::debug!( - "EC skipped: no consent and no existing cookie (jurisdiction={})", - consent_context.jurisdiction, + "page-bids: skipping auction for path '{}' (is_bot={}, is_prefetch={})", + path_param, + is_bot, + is_prefetch ); } + + // The [auction].enabled kill switch and a consent denial disable the entire + // server-side ad stack. In those states the endpoint must return no slots, + // so the SPA hook does not assign `ts.adSlots` and call `adInit()` — + // otherwise the kill switch/consent gate would stop SSP calls but still let + // the client create/refresh GPT slots. Bot/prefetch requests, by contrast, + // keep their slot definitions (the placement structure is unchanged) but + // skip the live auction, matching the existing bot/prefetch behaviour. + let ad_stack_enabled = auction_enabled && consent_allows_auction; + + let winning_bids = if ad_stack_enabled && !matched_slots.is_empty() && !is_bot && !is_prefetch { + let slots_ctx = MatchedSlotsContext { + matched_slots: &matched_slots, + request_path: &path_param, + }; + let mut auction_request = build_auction_request( + &slots_ctx, + ec_id, + consent_context, + &request_info, + req.headers() + .get("user-agent") + .and_then(|v| v.to_str().ok()), + ); + apply_auction_eids_and_device( + &mut auction_request, + &AuctionEidTargeting { + cookie_jar: cookie_jar.as_ref(), + ec_id, + kv, + partner_registry: auction.registry, + ec_context, + services, + geo: geo.as_ref(), + path_label: "Page-bids", + }, + ); + let timeout_ms = co_config + .auction_timeout_ms + .unwrap_or(settings.auction.timeout_ms); + let auction_context = AuctionContext { + settings, + request: &req, + timeout_ms, + provider_responses: None, + services, + }; + match auction + .orchestrator + .run_auction(&auction_request, &auction_context) + .await + { + Ok(result) => result.winning_bids, + Err(e) => { + log::warn!("page-bids auction failed: {e:?}"); + std::collections::HashMap::new() + } + } + } else { + std::collections::HashMap::new() + }; + + let bid_map = build_bid_map( + &winning_bids, + co_config.price_granularity, + settings.debug.inject_adm_for_testing, + ); + + // Gate slots on the ad-stack kill switch / consent: when disabled, return no + // slots so the SPA hook does not call `adInit()` / create GPT slots. + let slots_json: Vec = if ad_stack_enabled { + matched_slots + .iter() + .map(|slot| build_slot_json(slot, co_config)) + .collect() + } else { + Vec::new() + }; + + let body = serde_json::json!({ + "slots": slots_json, + "bids": bid_map, + }); + + let json_str = serde_json::to_string(&body).change_context(TrustedServerError::Proxy { + message: "Failed to serialize page-bids response".to_string(), + })?; + + let mut response = Response::new(EdgeBody::from(json_str)); + response.headers_mut().insert( + header::CONTENT_TYPE, + HeaderValue::from_static("application/json"), + ); + response.headers_mut().insert( + header::CACHE_CONTROL, + HeaderValue::from_static("private, no-store"), + ); + + Ok(response) } #[cfg(test)] mod tests { + use std::io::{self, Read as _, Write as _}; + use std::sync::atomic::{AtomicUsize, Ordering}; + + use brotli::enc::writer::CompressorWriter; + use brotli::Decompressor; + use flate2::read::GzDecoder; + use flate2::write::GzEncoder; + use super::*; - use crate::edge_cookie::get_or_generate_ec_id; + use crate::auction::types::{AdFormat, AdSlot, MediaType}; use crate::integrations::IntegrationRegistry; 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; + 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 + .write_all(input) + .expect("should write gzip test input"); + encoder.finish().expect("should finish gzip encoding") + } + + fn gzip_decode(input: &[u8]) -> Vec { + let mut decoder = GzDecoder::new(input); + let mut output = Vec::new(); + decoder + .read_to_end(&mut output) + .expect("should decode gzip test output"); + output + } + + fn brotli_encode(input: &[u8]) -> Vec { + let mut encoder = CompressorWriter::new(Vec::new(), 4096, 5, 22); + encoder + .write_all(input) + .expect("should write brotli test input"); + encoder.into_inner() + } + + fn brotli_decode(input: &[u8]) -> Vec { + let mut decoder = Decompressor::new(input, 4096); + let mut output = Vec::new(); + decoder + .read_to_end(&mut output) + .expect("should decode brotli test output"); + output + } + + fn make_stream_params( + settings: &Settings, + content_encoding: &str, + ) -> OwnedProcessResponseParams { + OwnedProcessResponseParams { + content_encoding: content_encoding.to_owned(), + 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: "application/json".to_owned(), + ad_slots_script: None, + ad_bids_state: std::sync::Arc::new(std::sync::Mutex::new(None)), + dispatched_auction: None, + price_granularity: Default::default(), + } + } + + fn test_auction_request() -> AuctionRequest { + AuctionRequest { + id: "test-auction".to_string(), + slots: vec![AdSlot { + id: "atf".to_string(), + formats: vec![AdFormat { + media_type: MediaType::Banner, + width: 300, + height: 250, + }], + floor_price: None, + targeting: Default::default(), + bidders: Default::default(), + }], + publisher: PublisherInfo { + domain: "test-publisher.com".to_string(), + page_url: Some("https://test-publisher.com/article".to_string()), + }, + user: UserInfo { + id: None, + consent: None, + eids: None, + }, + device: None, + site: None, + context: Default::default(), + } + } + fn build_request(method: Method, uri: &str) -> HttpRequest { HttpRequest::builder() .method(method) @@ -915,13 +2356,351 @@ mod tests { } #[test] - fn response_carries_body_preserves_bodiless_metadata() { - // A processable GET 200 buffers a body and recomputes Content-Length. - assert!( - super::response_carries_body(&Method::GET, StatusCode::OK), - "a GET 200 publisher response should carry a buffered body" - ); - // HEAD carries no body; recomputing Content-Length to 0 would mislead + fn stream_publisher_body_round_trips_gzip() { + let settings = create_test_settings(); + let integration_registry = + IntegrationRegistry::new(&settings).expect("should create integration registry"); + let input = b"{\"asset\":\"https://origin.test-publisher.com/path/file.js\"}"; + let compressed = gzip_encode(input); + let params = make_stream_params(&settings, "gzip"); + let mut output = Vec::new(); + + stream_publisher_body( + EdgeBody::from(compressed), + &mut output, + ¶ms, + &settings, + &integration_registry, + ) + .expect("should stream gzip response through rewrite pipeline"); + + let decoded = gzip_decode(&output); + let decoded = String::from_utf8(decoded).expect("should decode rewritten gzip payload"); + assert!( + decoded.contains("https://test-publisher.com/path/file.js"), + "should rewrite origin URLs to the request host" + ); + assert!( + !decoded.contains("origin.test-publisher.com"), + "should remove the origin hostname from the rewritten payload" + ); + } + + #[test] + fn stream_publisher_body_round_trips_brotli() { + let settings = create_test_settings(); + let integration_registry = + IntegrationRegistry::new(&settings).expect("should create integration registry"); + let input = b"{\"asset\":\"https://origin.test-publisher.com/path/file.css\"}"; + let compressed = brotli_encode(input); + let params = make_stream_params(&settings, "br"); + let mut output = Vec::new(); + + stream_publisher_body( + EdgeBody::from(compressed), + &mut output, + ¶ms, + &settings, + &integration_registry, + ) + .expect("should stream brotli response through rewrite pipeline"); + + let decoded = brotli_decode(&output); + let decoded = String::from_utf8(decoded).expect("should decode rewritten brotli payload"); + assert!( + decoded.contains("https://test-publisher.com/path/file.css"), + "should rewrite origin URLs to the request host" + ); + assert!( + !decoded.contains("origin.test-publisher.com"), + "should remove the origin hostname from the rewritten payload" + ); + } + + #[test] + fn request_ec_uses_cookie_not_header() { + let settings = create_test_settings(); + let header_ec = format!("{}.HdrId1", "a".repeat(64)); + let cookie_ec = format!("{}.CkId01", "b".repeat(64)); + let req = Request::builder() + .method(Method::GET) + .uri("https://test.example.com/page") + .header("x-ts-ec", &header_ec) + .header("cookie", format!("ts-ec={cookie_ec}; other=value")) + .body(EdgeBody::empty()) + .expect("should build test request"); + + let ec_context = EcContext::read_from_request(&settings, &req, &noop_services()) + .expect("should read EC context"); + + assert_eq!( + ec_context.ec_value(), + Some(cookie_ec.as_str()), + "should resolve request EC ID from cookie" + ); + assert!( + ec_context.cookie_was_present(), + "should detect cookie was present" + ); + assert_eq!( + ec_context.existing_cookie_ec_id(), + Some(cookie_ec.as_str()), + "should return cookie EC value for revocation" + ); + } + + /// Drive `handle_publisher_request` with no creative opportunities — a plain + /// proxy with no server-side auction. Hides the auction/EC wiring so callers + /// read like a simple `(settings, services, req)` proxy. + async fn run_publisher_proxy( + settings: &Settings, + services: &RuntimeServices, + req: Request, + ) -> PublisherResponse { + let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); + let mut ec_context = + EcContext::read_from_request(settings, &req, services).expect("should read EC context"); + handle_publisher_request( + settings, + services, + None, + &mut ec_context, + AuctionDispatch { + orchestrator: &orchestrator, + slots: &[], + registry: None, + }, + req, + EdgeCacheHeader::SurrogateControl, + ) + .await + .expect("should proxy publisher request") + } + + #[tokio::test] + async fn publisher_request_uses_platform_http_client_with_http_types() { + let settings = create_test_settings(); + let stub = Arc::new(StubHttpClient::new()); + stub.push_response(200, b"origin response".to_vec()); + let services = build_services_with_http_client( + Arc::clone(&stub) as Arc + ); + let req = HttpRequest::builder() + .method(Method::GET) + .uri("https://publisher.example/page") + .header(header::HOST, "publisher.example") + .body(EdgeBody::empty()) + .expect("should build request"); + + let response = match run_publisher_proxy(&settings, &services, req).await { + PublisherResponse::Buffered(r) => r, + PublisherResponse::PassThrough { mut response, body } => { + *response.body_mut() = body; + response + } + PublisherResponse::Stream { response, .. } => response, + }; + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response_body_string(response), "origin response"); + assert_eq!( + stub.recorded_backend_names(), + vec!["stub-backend".to_string()], + "should proxy through the platform http client" + ); + } + + #[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 + // handler must never mint an EC ID on its own: for a navigation from a + // client the adapter did not pre-generate for (e.g. a non-real browser), + // `ec_value` must stay `None` so no IP-derived identifier reaches the + // auction. Consent allows EC creation and a client IP is present here — + // exactly the conditions under which the old inline call would have + // generated one. + let settings = create_test_settings(); + let stub = Arc::new(StubHttpClient::new()); + stub.push_response(200, b"ok".to_vec()); + 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, Some("203.0.113.7".to_string())); + assert!( + ec_context.ec_allowed(), + "test precondition: consent must allow EC creation" + ); + + let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); + let req = HttpRequest::builder() + .method(Method::GET) + .uri("https://publisher.example/article") + .header(header::HOST, "publisher.example") + .header("sec-fetch-dest", "document") + .body(EdgeBody::empty()) + .expect("should build request"); + + let _ = handle_publisher_request( + &settings, + &services, + None, + &mut ec_context, + AuctionDispatch { + orchestrator: &orchestrator, + slots: &[], + registry: None, + }, + req, + EdgeCacheHeader::SurrogateControl, + ) + .await + .expect("should proxy publisher request"); + + assert_eq!( + ec_context.ec_value(), + None, + "handler must not self-generate an EC ID; generation is the adapter's real-browser-gated responsibility", + ); + } + + #[test] + fn response_carries_body_preserves_bodiless_metadata() { + // A processable GET 200 buffers a body and recomputes Content-Length. + assert!( + super::response_carries_body(&Method::GET, StatusCode::OK), + "a GET 200 publisher response should carry a buffered body" + ); + // HEAD carries no body; recomputing Content-Length to 0 would mislead // clients/caches about the GET representation length. assert!( !super::response_carries_body(&Method::HEAD, StatusCode::OK), @@ -951,15 +2730,18 @@ mod tests { #[test] fn test_content_type_detection() { - // Test which content types should be processed let test_cases = vec![ ("text/html", true), ("text/html; charset=utf-8", true), + ("Text/HTML; Charset=utf-8", true), ("text/css", true), + ("Text/CSS", true), ("text/javascript", true), ("application/javascript", true), + ("Application/JavaScript", true), ("application/json", true), ("application/json; charset=utf-8", true), + ("Application/JSON; Charset=UTF-8", true), ("image/jpeg", false), ("image/png", false), ("application/pdf", false), @@ -1004,18 +2786,138 @@ mod tests { ); } + #[test] + fn server_side_ad_stack_runs_only_when_all_auction_gates_pass() { + assert!( + should_run_server_side_ad_stack(true, true, false, false, true, true, true), + "GET, real navigation, matched slots, and consent should run TS ad stack" + ); + + assert!( + !should_run_server_side_ad_stack(false, true, false, false, true, true, true), + "non-GET requests should skip TS ad stack" + ); + assert!( + !should_run_server_side_ad_stack(true, false, false, false, true, true, true), + "non-document requests should skip TS ad stack" + ); + assert!( + !should_run_server_side_ad_stack(true, true, true, false, true, true, true), + "prefetch requests should skip TS ad stack and injection" + ); + assert!( + !should_run_server_side_ad_stack(true, true, false, true, true, true, true), + "bot requests should skip TS ad stack and injection" + ); + assert!( + !should_run_server_side_ad_stack(true, true, false, false, false, true, true), + "requests with no matching slots should skip TS ad stack" + ); + assert!( + !should_run_server_side_ad_stack(true, true, false, false, true, false, true), + "requests without required consent should skip TS ad stack and injection" + ); + assert!( + !should_run_server_side_ad_stack(true, true, false, false, true, true, false), + "disabled [auction].enabled kill switch should skip TS ad stack and injection" + ); + } + + #[tokio::test] + async fn body_close_hold_loop_processes_close_tail_before_reading_post_body_chunks() { + let settings = create_test_settings(); + let services = noop_services(); + let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); + 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, + price_granularity: PriceGranularity::default(), + ad_bids_state: &ad_bids_state, + orchestrator: &orchestrator, + services: &services, + settings: &settings, + }; + let mut output = Vec::new(); + + body_close_hold_loop(reader, &mut output, &mut processor, ctx) + .await + .expect("should stream body with auction hold"); + + 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" + ); + assert_eq!( + std::str::from_utf8(&output).expect("should be utf8"), + "painted", + "post-body chunks should still stream in order" + ); + } + + #[test] + fn body_close_hold_buffer_holds_close_body_tail_in_single_chunk() { + let mut hold = BodyCloseHoldBuffer::new(); + + let ready = hold.push(b"painted"); + let held = hold.finish(); + + assert_eq!( + std::str::from_utf8(&ready).expect("should be utf8"), + "painted", + "content before should stream before auction collection" + ); + assert_eq!( + std::str::from_utf8(&held).expect("should be utf8"), + "", + "the close-body tag and trailing bytes should be held" + ); + } + + #[test] + fn body_close_hold_buffer_holds_close_body_tail_across_chunks() { + let mut hold = BodyCloseHoldBuffer::new(); + + let first = hold.push(b"painted"); + let held = hold.finish(); + + 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" + ); + assert_eq!( + std::str::from_utf8(&held).expect("should be utf8"), + "", + "split close-body tag should be held intact" + ); + } + #[test] fn unsupported_encoding_response_is_returned_unmodified() { - // Processable (HTML) 2xx with unsupported encoding must route to - // BufferedUnmodified — feeding zstd-compressed bytes to the rewriter - // as identity would produce garbled output. assert_eq!( classify_response_route( StatusCode::OK, "text/html; charset=utf-8", "zstd", - "example.com", - false, + "example.com" ), ResponseRoute::BufferedUnmodified, ); @@ -1027,7 +2929,6 @@ mod tests { let origin_host = settings.publisher.origin_host(); assert_eq!(origin_host, "origin.test-publisher.com"); - // Test with port let mut settings_with_port = create_test_settings(); settings_with_port.publisher.origin_url = "origin.test-publisher.com:8080".to_string(); assert_eq!( @@ -1038,27 +2939,18 @@ mod tests { #[test] fn test_invalid_utf8_handling() { - // Test that invalid UTF-8 bytes are handled gracefully - let invalid_utf8_bytes = vec![0xFF, 0xFE, 0xFD]; // Invalid UTF-8 sequence - - // Verify these bytes cannot be converted to a valid UTF-8 string + let invalid_utf8_bytes = vec![0xFF, 0xFE, 0xFD]; assert!(String::from_utf8(invalid_utf8_bytes.clone()).is_err()); - - // In the actual function, invalid UTF-8 would be passed through unchanged - // This test verifies our approach is sound } #[test] fn test_utf8_conversion_edge_cases() { - // Test various UTF-8 edge cases let test_cases = vec![ - // Valid UTF-8 with special characters - (vec![0xE2, 0x98, 0x83], true), // ☃ (snowman) - (vec![0xF0, 0x9F, 0x98, 0x80], true), // 😀 (emoji) - // Invalid UTF-8 sequences - (vec![0xFF, 0xFE], false), // Invalid start byte - (vec![0xC0, 0x80], false), // Overlong encoding - (vec![0xED, 0xA0, 0x80], false), // Surrogate half + (vec![0xE2, 0x98, 0x83], true), + (vec![0xF0, 0x9F, 0x98, 0x80], true), + (vec![0xFF, 0xFE], false), + (vec![0xC0, 0x80], false), + (vec![0xED, 0xA0, 0x80], false), ]; for (bytes, should_be_valid) in test_cases { @@ -1072,10 +2964,6 @@ mod tests { } } - // Gate tests — exercise `classify_response_route` directly, the same - // function `handle_publisher_request` calls. If the gate formula changes, - // both production and tests are affected identically: no silent drift. - #[test] fn route_streams_2xx_html_without_post_processors() { assert_eq!( @@ -1083,112 +2971,95 @@ mod tests { StatusCode::OK, "text/html; charset=utf-8", "gzip", - "example.com", - false, + "example.com" ), ResponseRoute::Stream, ); } #[test] - fn route_buffers_html_with_post_processors_for_processing() { + fn route_streams_mixed_case_html_content_type() { assert_eq!( classify_response_route( StatusCode::OK, - "text/html; charset=utf-8", + "Text/HTML; Charset=utf-8", "gzip", - "example.com", - true, + "example.com" ), - ResponseRoute::BufferedProcessed, + ResponseRoute::Stream, + "HTML MIME type matching must be case-insensitive", ); } #[test] - fn route_streams_non_html_even_with_post_processors_registered() { - // Post-processors only apply to HTML; JSON/JS can still stream. + fn route_streams_html_with_post_processors() { assert_eq!( classify_response_route( StatusCode::OK, - "application/json", + "text/html; charset=utf-8", "gzip", - "example.com", - true, + "example.com" ), ResponseRoute::Stream, ); } + #[test] + fn route_streams_non_html_even_with_post_processors_registered() { + assert_eq!( + classify_response_route(StatusCode::OK, "application/json", "gzip", "example.com"), + ResponseRoute::Stream, + ); + } + #[test] fn route_buffers_unmodified_on_unsupported_encoding() { - // Unsupported encoding cannot be streamed (would be fed to rewriter - // as identity and produce garbled output). assert_eq!( - classify_response_route(StatusCode::OK, "text/html", "zstd", "example.com", false,), + classify_response_route(StatusCode::OK, "text/html", "zstd", "example.com"), ResponseRoute::BufferedUnmodified, ); } #[test] fn route_passes_through_non_processable_2xx() { - // Binary content (images, fonts) on 2xx streams the origin body direct. assert_eq!( - classify_response_route(StatusCode::OK, "image/png", "", "example.com", false,), + classify_response_route(StatusCode::OK, "image/png", "", "example.com"), ResponseRoute::PassThrough, ); } #[test] fn route_buffers_non_processable_error_responses() { - // Non-2xx never pass through — response needs to reach the client - // as-is (with any error body the origin produced). assert_eq!( - classify_response_route(StatusCode::NOT_FOUND, "image/png", "", "example.com", false,), + classify_response_route(StatusCode::NOT_FOUND, "image/png", "", "example.com"), ResponseRoute::BufferedUnmodified, ); } #[test] fn route_excludes_204_from_pass_through() { - // 204 No Content (RFC 9110 §15.3.5) prohibits a message body. assert_eq!( - classify_response_route( - StatusCode::NO_CONTENT, - "image/png", - "", - "example.com", - false, - ), + classify_response_route(StatusCode::NO_CONTENT, "image/png", "", "example.com"), ResponseRoute::BufferedUnmodified, ); } #[test] fn route_excludes_205_from_pass_through() { - // 205 Reset Content (RFC 9110 §15.3.6) prohibits a message body. assert_eq!( - classify_response_route( - StatusCode::RESET_CONTENT, - "image/png", - "", - "example.com", - false, - ), + classify_response_route(StatusCode::RESET_CONTENT, "image/png", "", "example.com"), ResponseRoute::BufferedUnmodified, ); } #[test] fn route_excludes_204_for_processable_content_types() { - // 204 must stay body-less even when Content-Type would otherwise route - // to Stream or BufferedProcessed. assert_eq!( classify_response_route( StatusCode::NO_CONTENT, "text/html; charset=utf-8", "gzip", - "example.com", - false, + "example.com" ), ResponseRoute::BufferedUnmodified, "204 + HTML must not route to Stream", @@ -1198,11 +3069,10 @@ mod tests { StatusCode::NO_CONTENT, "text/html; charset=utf-8", "gzip", - "example.com", - true, + "example.com" ), ResponseRoute::BufferedUnmodified, - "204 + HTML + post-processors must not route to BufferedProcessed", + "204 + HTML + post-processors must not route to Stream", ); } @@ -1213,8 +3083,7 @@ mod tests { StatusCode::RESET_CONTENT, "application/json", "", - "example.com", - false, + "example.com" ), ResponseRoute::BufferedUnmodified, "205 + JSON must not route to Stream", @@ -1223,15 +3092,12 @@ mod tests { #[test] fn route_streams_non_2xx_processable_content() { - // Branded 404 or 500 HTML with origin URLs must still be rewritten. - // This matches the pre-streaming behavior on main. assert_eq!( classify_response_route( StatusCode::NOT_FOUND, "text/html; charset=utf-8", "gzip", - "example.com", - false, + "example.com" ), ResponseRoute::Stream, ); @@ -1240,53 +3106,43 @@ mod tests { StatusCode::INTERNAL_SERVER_ERROR, "application/json", "gzip", - "example.com", - false, + "example.com" ), ResponseRoute::Stream, ); } #[test] - fn route_processes_non_2xx_html_with_post_processors() { - // Non-2xx HTML with post-processors still needs full-document processing - // for head injection, same as 2xx. + fn route_streams_non_2xx_html_with_post_processors() { assert_eq!( classify_response_route( StatusCode::NOT_FOUND, "text/html; charset=utf-8", "gzip", - "example.com", - true, + "example.com" ), - ResponseRoute::BufferedProcessed, + ResponseRoute::Stream, ); } #[test] fn route_passes_through_non_processable_even_with_empty_request_host() { - // Empty request_host blocks URL rewriting but pass-through does no - // rewriting, so a non-processable 2xx still streams through. assert_eq!( - classify_response_route(StatusCode::OK, "image/png", "", "", false,), + classify_response_route(StatusCode::OK, "image/png", "", ""), ResponseRoute::PassThrough, ); } #[test] fn route_buffers_processable_content_with_empty_request_host() { - // Misconfiguration case — URL rewriting needs a host, so the - // processable response falls back to unmodified pass-through. assert_eq!( - classify_response_route(StatusCode::OK, "text/html", "gzip", "", false,), + classify_response_route(StatusCode::OK, "text/html", "gzip", ""), ResponseRoute::BufferedUnmodified, ); } #[test] fn pass_through_preserves_body_and_content_length() { - // Simulate the PassThrough path: take body, reattach, send. - // Verify byte-for-byte identity and Content-Length preservation. let image_bytes: Vec = (0..=255).cycle().take(4096).collect(); let mut response = Response::builder() @@ -1321,7 +3177,6 @@ mod tests { #[test] fn test_content_encoding_detection() { - // Test that we properly handle responses with various content encodings let test_encodings = vec!["gzip", "deflate", "br", "identity", ""]; for encoding in test_encodings { @@ -1439,99 +3294,6 @@ mod tests { ); } - #[test] - fn revocation_targets_cookie_ec_id_not_header() { - let settings = create_test_settings(); - let mut req = build_request(Method::GET, "https://test.example.com/page"); - req.headers_mut().insert( - crate::constants::HEADER_X_TS_EC, - http::HeaderValue::from_static("header_id"), - ); - req.headers_mut().insert( - header::COOKIE, - http::HeaderValue::from_static("ts-ec=cookie_id; other=value"), - ); - - let cookie_jar = handle_request_cookies(&req).expect("should parse cookies"); - let existing_ec_cookie = cookie_jar - .as_ref() - .and_then(|jar| jar.get(COOKIE_TS_EC)) - .map(|cookie| cookie.value().to_owned()); - - let resolved_ec_id = - get_or_generate_ec_id(&settings, &noop_services(), &req).expect("should resolve EC ID"); - - assert_eq!( - existing_ec_cookie.as_deref(), - Some("cookie_id"), - "should read revocation target from cookie value" - ); - assert_eq!( - resolved_ec_id, "header_id", - "should still resolve request EC ID from header precedence" - ); - } - - #[test] - fn revocation_deletes_kv_entry_for_cookie_ec_id() { - use crate::platform::test_support::RecordingKvStore; - - let mut settings = create_test_settings(); - settings.consent.consent_store = Some("test-consent-store".to_string()); - - let recording = Arc::new(RecordingKvStore::new()); - let services = noop_services() - .with_kv_store(Arc::clone(&recording) as Arc); - - let mut response = Response::new(EdgeBody::empty()); - let consent_ctx = crate::consent::ConsentContext::default(); - - apply_ec_headers( - &settings, - &services, - &mut response, - "new-ec-id", - false, - Some("cookie-ec-id"), - &consent_ctx, - ); - - assert_eq!( - recording.deleted_keys(), - vec!["cookie-ec-id"], - "should delete KV entry for the revoked EC cookie ID" - ); - } - - #[test] - fn revocation_does_not_delete_kv_when_consent_store_absent() { - use crate::platform::test_support::RecordingKvStore; - - let settings = create_test_settings(); - - let recording = Arc::new(RecordingKvStore::new()); - let services = noop_services() - .with_kv_store(Arc::clone(&recording) as Arc); - - let mut response = Response::new(EdgeBody::empty()); - let consent_ctx = crate::consent::ConsentContext::default(); - - apply_ec_headers( - &settings, - &services, - &mut response, - "new-ec-id", - false, - Some("cookie-ec-id"), - &consent_ctx, - ); - - assert!( - recording.deleted_keys().is_empty(), - "should not delete KV entry when no consent_store is configured" - ); - } - #[test] fn tsjs_dynamic_returns_not_found_for_unknown_filename() { let settings = create_test_settings(); @@ -1542,7 +3304,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); } @@ -1556,16 +3319,134 @@ 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 parse_deferred_module_filename_extracts_known_id() { - assert_eq!( - parse_deferred_module_filename("tsjs-prebid.min.js"), - Some("prebid"), - "should extract prebid from minified filename" + 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] + fn parse_deferred_module_filename_extracts_known_id() { + assert_eq!( + parse_deferred_module_filename("tsjs-prebid.min.js"), + Some("prebid"), + "should extract prebid from minified filename" ); assert_eq!( parse_deferred_module_filename("tsjs-prebid.js"), @@ -1600,7 +3481,6 @@ mod tests { #[test] fn tsjs_dynamic_serves_deferred_prebid_when_enabled() { - // Default test settings include prebid enabled let settings = create_test_settings(); let registry = IntegrationRegistry::new(&settings).expect("should create integration registry"); @@ -1609,7 +3489,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::OK, @@ -1617,6 +3498,40 @@ mod tests { ); } + #[test] + fn tsjs_dynamic_uses_immutable_cache_for_matching_deferred_hash() { + let settings = create_test_settings(); + let registry = + IntegrationRegistry::new(&settings).expect("should create integration registry"); + let hash = trusted_server_js::single_module_hash("prebid") + .expect("should have prebid module hash"); + let req = build_request( + Method::GET, + &format!("https://publisher.example/static/tsjs=tsjs-prebid.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 deferred module immutable" + ); + assert_eq!( + response + .headers() + .get("surrogate-control") + .and_then(|value| value.to_str().ok()), + Some("max-age=31536000"), + "should give matching deferred module long edge TTL" + ); + } + #[test] fn tsjs_dynamic_returns_not_found_for_disabled_deferred_module() { let mut settings = create_test_settings(); @@ -1637,7 +3552,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, @@ -1655,7 +3571,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, @@ -1663,123 +3580,38 @@ mod tests { ); } - #[test] - fn publisher_request_uses_platform_http_client_with_http_types() { - futures::executor::block_on(async { - let settings = create_test_settings(); - let registry = - IntegrationRegistry::new(&settings).expect("should create integration registry"); - let stub = Arc::new(StubHttpClient::new()); - stub.push_response(200, b"origin response".to_vec()); - let services = build_services_with_http_client( - Arc::clone(&stub) as Arc - ); - let req = HttpRequest::builder() - .method(Method::GET) - .uri("https://publisher.example/page") - .header(header::HOST, "publisher.example") - .body(EdgeBody::empty()) - .expect("should build request"); - - let pub_response = handle_publisher_request(&settings, ®istry, &services, req) - .await - .expect("should proxy publisher request"); - let response = match pub_response { - PublisherResponse::Buffered(r) => r, - PublisherResponse::PassThrough { mut response, body } => { - *response.body_mut() = body; - response - } - PublisherResponse::Stream { response, .. } => response, - }; - - assert_eq!(response.status(), StatusCode::OK); - assert_eq!(response_body_string(response), "origin response"); - assert_eq!( - stub.recorded_backend_names(), - vec!["stub-backend".to_string()], - "should proxy through the platform http client" - ); - }); - } - - #[test] - fn publisher_request_strips_fastly_ssl_before_forwarding() { - futures::executor::block_on(async { - // The EdgeZero entry point re-injects `fastly-ssl` from trusted TLS - // metadata so in-process scheme detection works. It must not leak to the - // origin: the legacy path never forwarded it. - let settings = create_test_settings(); - let registry = - IntegrationRegistry::new(&settings).expect("should create integration registry"); - let stub = Arc::new(StubHttpClient::new()); - stub.push_response(200, b"origin response".to_vec()); - let services = build_services_with_http_client( - Arc::clone(&stub) as Arc - ); - let req = HttpRequest::builder() - .method(Method::GET) - .uri("https://publisher.example/page") - .header(header::HOST, "publisher.example") - .header("fastly-ssl", "1") - .body(EdgeBody::empty()) - .expect("should build request"); - - handle_publisher_request(&settings, ®istry, &services, req) - .await - .expect("should proxy publisher request"); - - let recorded = stub.recorded_request_headers(); - let outbound = recorded - .first() - .expect("should record one outbound request"); - assert!( - !outbound - .iter() - .any(|(name, _)| name.eq_ignore_ascii_case("fastly-ssl")), - "internal fastly-ssl signal must not be forwarded to the origin, got: {outbound:?}" - ); - }); - } - - #[test] - fn publisher_request_sends_configured_host_header_override() { - futures::executor::block_on(async { - let mut settings = create_test_settings(); - settings.publisher.origin_host_header_override = Some("www.example.com".to_string()); - let registry = - IntegrationRegistry::new(&settings).expect("should create integration registry"); - let stub = Arc::new(StubHttpClient::new()); - stub.push_response(200, b"origin response".to_vec()); - let services = build_services_with_http_client( - Arc::clone(&stub) as Arc - ); - let req = HttpRequest::builder() - .method(Method::GET) - .uri("https://publisher.example/page") - .header(header::HOST, "publisher.example") - .body(EdgeBody::empty()) - .expect("should build request"); + #[tokio::test] + async fn publisher_request_sends_configured_host_header_override() { + let mut settings = create_test_settings(); + settings.publisher.origin_host_header_override = Some("www.example.com".to_string()); + let stub = Arc::new(StubHttpClient::new()); + stub.push_response(200, b"origin response".to_vec()); + let services = build_services_with_http_client( + Arc::clone(&stub) as Arc + ); + let req = HttpRequest::builder() + .method(Method::GET) + .uri("https://publisher.example/page") + .header(header::HOST, "publisher.example") + .body(EdgeBody::empty()) + .expect("should build request"); - let _ = handle_publisher_request(&settings, ®istry, &services, req) - .await - .expect("should proxy publisher request"); + let _ = run_publisher_proxy(&settings, &services, req).await; - let recorded_headers = stub.recorded_request_headers(); - let outbound_headers = recorded_headers - .first() - .expect("should record one outbound request"); - let outbound_host = outbound_headers - .iter() - .find(|(name, _)| name.eq_ignore_ascii_case("host")) - .map(|(_, value)| value.as_str()); + let recorded_headers = stub.recorded_request_headers(); + let outbound_headers = recorded_headers + .first() + .expect("should record one outbound request"); + let outbound_host = outbound_headers + .iter() + .find(|(name, _)| name.eq_ignore_ascii_case("host")) + .map(|(_, value)| value.as_str()); - assert_eq!( - outbound_host, - Some("www.example.com"), - "should send configured host override to outbound request" - ); - }); + assert_eq!( + outbound_host, + Some("www.example.com"), + "should send configured host override to outbound request" + ); } #[test] @@ -1809,6 +3641,10 @@ mod tests { request_host: "proxy.example.com".to_string(), request_scheme: "https".to_string(), content_type: "text/css".to_string(), + ad_slots_script: None, + ad_bids_state: Arc::new(Mutex::new(None)), + dispatched_auction: None, + price_granularity: crate::price_bucket::PriceGranularity::default(), }; let mut output = Vec::new(); @@ -1850,6 +3686,10 @@ mod tests { request_host: "proxy.example.com".to_string(), request_scheme: "https".to_string(), content_type: "text/html; charset=utf-8".to_string(), + ad_slots_script: None, + ad_bids_state: Arc::new(Mutex::new(None)), + dispatched_auction: None, + price_granularity: crate::price_bucket::PriceGranularity::default(), }; let mut output = Vec::new(); @@ -1868,6 +3708,51 @@ mod tests { ); } + #[test] + fn stream_publisher_body_treats_mixed_case_html_as_html() { + let settings = create_test_settings(); + let registry = + IntegrationRegistry::new(&settings).expect("should create integration registry"); + let bids_script = + r#""#; + let state = Arc::new(Mutex::new(Some(bids_script.to_string()))); + let params = OwnedProcessResponseParams { + content_encoding: String::new(), + origin_host: "origin.example.com".to_string(), + origin_url: "https://origin.example.com".to_string(), + request_host: "proxy.example.com".to_string(), + request_scheme: "https".to_string(), + content_type: "Text/HTML; Charset=utf-8".to_string(), + ad_slots_script: Some( + r#""# + .to_string(), + ), + ad_bids_state: state, + dispatched_auction: None, + price_granularity: crate::price_bucket::PriceGranularity::default(), + }; + let mut output = Vec::new(); + + stream_publisher_body( + EdgeBody::from(b"content".to_vec()), + &mut output, + ¶ms, + &settings, + ®istry, + ) + .expect("should process mixed-case HTML content type"); + + let html = String::from_utf8(output).expect("should be valid UTF-8"); + assert!( + html.contains(".adSlots=JSON.parse"), + "mixed-case HTML must use the HTML processor and inject ad slots. Got: {html}" + ); + assert!( + html.contains(".bids=JSON.parse"), + "mixed-case HTML must use the HTML processor and inject bids. Got: {html}" + ); + } + /// Mid-stream decoder failure must surface as an error. The adapter /// relies on this: once headers are committed, it logs and drops the /// `StreamingBody` so the client sees a truncated response. If a decode @@ -1888,6 +3773,10 @@ mod tests { request_host: "proxy.example.com".to_string(), request_scheme: "https".to_string(), content_type: "text/html".to_string(), + ad_slots_script: None, + ad_bids_state: Arc::new(Mutex::new(None)), + dispatched_auction: None, + price_granularity: crate::price_bucket::PriceGranularity::default(), }; let bogus_body = EdgeBody::from(b"not gzip".to_vec()); @@ -1941,12 +3830,11 @@ mod tests { ); } - /// Buffered-processed dispatch contract: HTML with a registered post-processor - /// routes through `BufferedProcessed`, and the handler path sets - /// `Content-Length` from the processed body length. Verify that invariant - /// via the classifier + `process_response_streaming` composition. + /// Streaming dispatch contract: HTML with a registered post-processor still + /// routes through `Stream`, and the shared processor pipeline still applies + /// the post-processor rewrite. #[test] - fn buffered_processed_sets_content_length_from_processed_body() { + fn streaming_html_with_post_processors_rewrites_body() { // Configure nextjs so a post-processor is registered. let mut settings = create_test_settings(); settings @@ -1973,14 +3861,12 @@ mod tests { "text/html; charset=utf-8", "", "proxy.example.com", - registry.has_html_post_processors(), ), - ResponseRoute::BufferedProcessed, - "HTML with post-processors must route to BufferedProcessed" + ResponseRoute::Stream, + "HTML with post-processors must route to Stream" ); - // Feed a small HTML body through the same pipeline the - // BufferedProcessed arm uses (Vec output). + // Feed a small HTML body through the same pipeline the Stream arm uses. let html = b"link"; let body = EdgeBody::from(html.to_vec()); @@ -1992,14 +3878,18 @@ mod tests { request_host: "proxy.example.com".to_string(), request_scheme: "https".to_string(), content_type: "text/html; charset=utf-8".to_string(), + ad_slots_script: None, + ad_bids_state: Arc::new(Mutex::new(None)), + dispatched_auction: None, + price_granularity: crate::price_bucket::PriceGranularity::default(), }; let mut output = Vec::new(); stream_publisher_body(body, &mut output, ¶ms, &settings, ®istry) - .expect("should process buffered HTML"); + .expect("should process streaming HTML"); assert!( !output.is_empty(), - "buffered processed output must not be empty" + "streaming processed output must not be empty" ); let as_str = std::str::from_utf8(&output).expect("output should be valid UTF-8"); assert!( @@ -2012,63 +3902,6 @@ mod tests { ); } - /// `BufferedProcessed` must enforce `publisher.max_buffered_body_bytes` so a - /// post-processed HTML body whose decoded output exceeds the cap fails instead - /// of allocating past the limit. Regression for the `EdgeZero` buffering gap - /// where only the streaming-conversion path applied the cap. - #[test] - fn buffered_processed_enforces_max_buffered_body_bytes() { - futures::executor::block_on(async { - let mut settings = create_test_settings(); - // Register an HTML post-processor so the response routes to BufferedProcessed. - settings - .integrations - .insert_config( - "nextjs", - &serde_json::json!({ - "enabled": true, - "rewrite_attributes": ["href", "link", "url"], - }), - ) - .expect("should update nextjs config"); - // Tiny cap so a modest HTML document exceeds it after processing. - settings.publisher.max_buffered_body_bytes = 64; - - let registry = - IntegrationRegistry::new(&settings).expect("should create integration registry"); - assert!( - registry.has_html_post_processors(), - "nextjs integration must register an HTML post-processor" - ); - - // Identity-encoded HTML well above the 64-byte cap once buffered. - let filler = "

padding

".repeat(64); - let html = format!("{filler}"); - let stub = Arc::new(StubHttpClient::new()); - stub.push_response_with_headers( - 200, - html.into_bytes(), - vec![("content-type", "text/html; charset=utf-8")], - ); - let services = build_services_with_http_client( - Arc::clone(&stub) as Arc - ); - let req = HttpRequest::builder() - .method(Method::GET) - .uri("https://publisher.example/page") - .header(header::HOST, "publisher.example") - .body(EdgeBody::empty()) - .expect("should build request"); - - let result = handle_publisher_request(&settings, ®istry, &services, req).await; - - assert!( - result.is_err(), - "buffered-processed body exceeding max_buffered_body_bytes must error, not allocate past the cap" - ); - }); - } - /// Document-state survives from the streaming pass into the post-processor. /// `NextJsRscPlaceholderRewriter` writes into `IntegrationDocumentState` /// during streaming; `NextJsHtmlPostProcessor` reads it and substitutes. @@ -2099,6 +3932,10 @@ mod tests { request_host: "proxy.example.com".to_string(), request_scheme: "https".to_string(), content_type: "text/html".to_string(), + ad_slots_script: None, + ad_bids_state: Arc::new(Mutex::new(None)), + dispatched_auction: None, + price_granularity: crate::price_bucket::PriceGranularity::default(), }; let mut output = Vec::new(); @@ -2126,6 +3963,993 @@ mod tests { ); } + #[cfg(test)] + mod creative_opportunities_tests { + use super::super::{ + build_ad_slots_script, build_auction_request, build_bid_map, build_bids_script, + html_escape_for_script, MatchedSlotsContext, + }; + use crate::auction::types::{Bid, MediaType}; + use crate::consent::ConsentContext; + use crate::creative_opportunities::{ + CreativeOpportunitiesConfig, CreativeOpportunityFormat, CreativeOpportunitySlot, + }; + use crate::http_util::RequestInfo; + use crate::price_bucket::PriceGranularity; + use std::collections::HashMap; + + fn make_config() -> CreativeOpportunitiesConfig { + CreativeOpportunitiesConfig { + gam_network_id: "21765378893".to_string(), + auction_timeout_ms: Some(500), + price_granularity: PriceGranularity::Dense, + slot: Vec::new(), + } + } + + fn make_slot() -> CreativeOpportunitySlot { + CreativeOpportunitySlot { + id: "atf_sidebar_ad".to_string(), + gam_unit_path: Some("/21765378893/publisher/atf-sidebar".to_string()), + div_id: Some("div-atf-sidebar".to_string()), + page_patterns: vec!["/20**".to_string()], + formats: vec![CreativeOpportunityFormat { + width: 300, + height: 250, + media_type: MediaType::Banner, + }], + floor_price: Some(0.50), + targeting: [("pos".to_string(), "atf".to_string())] + .into_iter() + .collect(), + providers: Default::default(), + compiled_patterns: Vec::new(), + } + } + + fn make_bid( + slot_id: &str, + price: f64, + bidder: &str, + ad_id: &str, + nurl: &str, + burl: &str, + ) -> Bid { + Bid { + slot_id: slot_id.to_string(), + price: Some(price), + currency: "USD".to_string(), + creative: None, + adomain: None, + bidder: bidder.to_string(), + width: 300, + height: 250, + nurl: Some(nurl.to_string()), + burl: Some(burl.to_string()), + ad_id: Some(ad_id.to_string()), + cache_id: None, + cache_host: None, + cache_path: None, + metadata: Default::default(), + } + } + + #[test] + fn ad_slots_script_contains_slot_data() { + let slots = vec![make_slot()]; + let config = make_config(); + let script = build_ad_slots_script(&slots, &config); + assert!( + script.contains("window.tsjs=window.tsjs||{}"), + "should initialise tsjs namespace" + ); + assert!( + script.contains(".adSlots=JSON.parse"), + "should use JSON.parse for adSlots" + ); + assert!(script.contains("atf_sidebar_ad"), "should include slot id"); + assert!(!script.contains("adInit"), "must NOT contain adInit"); + assert!( + !script.contains("__ts_request_id"), + "must NOT contain request_id" + ); + } + + #[test] + fn ad_slots_script_is_xss_safe() { + let slots = vec![make_slot()]; + let config = make_config(); + let script = build_ad_slots_script(&slots, &config); + let inner = script + .trim_start_matches(""); + assert!(!inner.contains('<'), "no unescaped < in script content"); + assert!(!inner.contains('>'), "no unescaped > in script content"); + } + + #[test] + fn bid_map_includes_nurl_and_burl() { + let mut winning_bids = HashMap::new(); + winning_bids.insert( + "atf_sidebar_ad".to_string(), + make_bid( + "atf_sidebar_ad", + 1.50, + "kargo", + "abc123", + "https://ssp/win", + "https://ssp/bill", + ), + ); + let map = build_bid_map(&winning_bids, PriceGranularity::Dense, false); + let entry = map.get("atf_sidebar_ad").expect("should have bid entry"); + let obj = entry.as_object().expect("should be object"); + assert_eq!( + obj.get("hb_pb").and_then(|v| v.as_str()), + Some("1.50"), + "should bucket price with dense granularity" + ); + assert_eq!( + obj.get("hb_bidder").and_then(|v| v.as_str()), + Some("kargo"), + "should include bidder" + ); + assert_eq!( + obj.get("hb_adid").and_then(|v| v.as_str()), + Some("abc123"), + "should fall back to ad_id when no cache_id present" + ); + assert_eq!( + obj.get("nurl").and_then(|v| v.as_str()), + Some("https://ssp/win"), + "should include nurl" + ); + assert_eq!( + obj.get("burl").and_then(|v| v.as_str()), + Some("https://ssp/bill"), + "should include burl" + ); + } + + #[test] + fn client_bid_map_omits_adm_by_default() { + let mut winning_bids = HashMap::new(); + let mut bid = make_bid( + "atf_sidebar_ad", + 1.50, + "kargo", + "abc123", + "https://ssp/win", + "https://ssp/bill", + ); + bid.creative = Some("
Creative
".to_string()); + winning_bids.insert("atf_sidebar_ad".to_string(), bid); + + let map = build_bid_map(&winning_bids, PriceGranularity::Dense, false); + let obj = map + .get("atf_sidebar_ad") + .expect("should have bid entry") + .as_object() + .expect("should be object"); + + assert!( + obj.get("adm").is_none(), + "should omit adm when debug injection is disabled" + ); + assert!( + obj.get("debug_bid").is_none(), + "should omit debug bid when debug injection is disabled" + ); + } + + #[test] + fn client_bid_map_includes_adm_when_debug_injection_enabled() { + let mut winning_bids = HashMap::new(); + let mut bid = make_bid( + "atf_sidebar_ad", + 1.50, + "kargo", + "abc123", + "https://ssp/win", + "https://ssp/bill", + ); + bid.creative = Some("
Creative
".to_string()); + winning_bids.insert("atf_sidebar_ad".to_string(), bid); + + let map = build_bid_map(&winning_bids, PriceGranularity::Dense, true); + let obj = map + .get("atf_sidebar_ad") + .expect("should have bid entry") + .as_object() + .expect("should be object"); + + assert_eq!( + obj.get("adm").and_then(|v| v.as_str()), + Some("
Creative
"), + "should include adm when debug injection is enabled" + ); + } + + #[test] + fn client_bid_map_includes_debug_bid_when_debug_injection_enabled() { + let mut winning_bids = HashMap::new(); + let mut bid = make_bid( + "atf_sidebar_ad", + 1.50, + "mocktioneer", + "bid-ad-id", + "https://ssp/win", + "https://ssp/bill", + ); + bid.creative = Some("
Creative
".to_string()); + bid.adomain = Some(vec!["example.com".to_string()]); + bid.cache_id = Some("cache-uuid".to_string()); + bid.cache_host = Some("cache.example".to_string()); + bid.cache_path = Some("/cache".to_string()); + bid.metadata.insert( + "raw_field".to_string(), + serde_json::Value::String("raw-value".to_string()), + ); + winning_bids.insert("atf_sidebar_ad".to_string(), bid); + + let map = build_bid_map(&winning_bids, PriceGranularity::Dense, true); + let obj = map + .get("atf_sidebar_ad") + .expect("should have bid entry") + .as_object() + .expect("should be object"); + let debug_bid = obj + .get("debug_bid") + .and_then(|v| v.as_object()) + .expect("should include debug bid when debug injection is enabled"); + + assert_eq!( + debug_bid.get("slot_id").and_then(|v| v.as_str()), + Some("atf_sidebar_ad"), + "should expose original slot id" + ); + assert_eq!( + debug_bid.get("bidder").and_then(|v| v.as_str()), + Some("mocktioneer"), + "should expose original bidder" + ); + assert_eq!( + debug_bid.get("ad_id").and_then(|v| v.as_str()), + Some("bid-ad-id"), + "should expose original bid ad id" + ); + assert_eq!( + debug_bid.get("cache_id").and_then(|v| v.as_str()), + Some("cache-uuid"), + "should expose original PBS cache id" + ); + assert_eq!( + debug_bid.get("metadata").and_then(|v| v.get("raw_field")), + Some(&serde_json::Value::String("raw-value".to_string())), + "should expose provider metadata" + ); + } + + #[test] + fn bid_map_uses_cache_id_for_hb_adid_when_present() { + let mut winning_bids = HashMap::new(); + winning_bids.insert( + "atf_sidebar_ad".to_string(), + Bid { + slot_id: "atf_sidebar_ad".to_string(), + price: Some(1.50), + currency: "USD".to_string(), + creative: None, + adomain: None, + bidder: "thetradedesk".to_string(), + width: 300, + height: 250, + nurl: None, + burl: None, + ad_id: Some("bid-impression-id".to_string()), + cache_id: Some("f47447a0-b759-4f2f-9887-af458b79b570".to_string()), + cache_host: Some("openads.adsrvr.org".to_string()), + cache_path: Some("/cache".to_string()), + metadata: Default::default(), + }, + ); + let map = build_bid_map(&winning_bids, PriceGranularity::Dense, false); + let obj = map + .get("atf_sidebar_ad") + .expect("should have bid entry") + .as_object() + .expect("should be object"); + assert_eq!( + obj.get("hb_adid").and_then(|v| v.as_str()), + Some("f47447a0-b759-4f2f-9887-af458b79b570"), + "should use cache_id for hb_adid, not ad_id" + ); + assert_eq!( + obj.get("hb_cache_host").and_then(|v| v.as_str()), + Some("openads.adsrvr.org"), + "should emit hb_cache_host" + ); + assert_eq!( + obj.get("hb_cache_path").and_then(|v| v.as_str()), + Some("/cache"), + "should emit hb_cache_path" + ); + } + + #[test] + fn bid_map_falls_back_to_ad_id_when_cache_id_absent() { + let mut winning_bids = HashMap::new(); + winning_bids.insert( + "atf_sidebar_ad".to_string(), + Bid { + slot_id: "atf_sidebar_ad".to_string(), + price: Some(0.50), + currency: "USD".to_string(), + creative: None, + adomain: None, + bidder: "amazon-aps".to_string(), + width: 300, + height: 250, + nurl: None, + burl: None, + ad_id: Some("aps-bid-token".to_string()), + cache_id: None, + cache_host: None, + cache_path: None, + metadata: Default::default(), + }, + ); + let map = build_bid_map(&winning_bids, PriceGranularity::Dense, false); + let obj = map + .get("atf_sidebar_ad") + .expect("should have bid entry") + .as_object() + .expect("should be object"); + assert_eq!( + obj.get("hb_adid").and_then(|v| v.as_str()), + Some("aps-bid-token"), + "should fall back to ad_id when cache_id absent" + ); + assert!( + obj.get("hb_cache_host").is_none(), + "should not emit hb_cache_host when absent" + ); + assert!( + obj.get("hb_cache_path").is_none(), + "should not emit hb_cache_path when absent" + ); + } + + #[test] + fn bid_map_omits_hb_adid_when_both_cache_id_and_ad_id_absent() { + let mut winning_bids = HashMap::new(); + winning_bids.insert( + "atf_sidebar_ad".to_string(), + Bid { + slot_id: "atf_sidebar_ad".to_string(), + price: Some(0.50), + currency: "USD".to_string(), + creative: None, + adomain: None, + bidder: "amazon-aps".to_string(), + width: 300, + height: 250, + nurl: None, + burl: None, + ad_id: None, + cache_id: None, + cache_host: None, + cache_path: None, + metadata: Default::default(), + }, + ); + let map = build_bid_map(&winning_bids, PriceGranularity::Dense, false); + let obj = map + .get("atf_sidebar_ad") + .expect("should have bid entry") + .as_object() + .expect("should be object"); + assert!( + obj.get("hb_adid").is_none(), + "should omit hb_adid when no cache_id and no ad_id" + ); + } + + #[test] + fn bid_map_excludes_slot_when_price_is_none() { + let mut winning_bids = HashMap::new(); + winning_bids.insert( + "no-price-slot".to_string(), + Bid { + slot_id: "no-price-slot".to_string(), + price: None, + currency: "USD".to_string(), + creative: None, + adomain: None, + bidder: "kargo".to_string(), + width: 300, + height: 250, + nurl: None, + burl: None, + ad_id: None, + cache_id: None, + cache_host: None, + cache_path: None, + metadata: Default::default(), + }, + ); + let map = build_bid_map(&winning_bids, PriceGranularity::Dense, false); + assert!( + map.is_empty(), + "slot with no price should be excluded from bid map" + ); + } + + #[test] + fn bids_script_is_xss_safe() { + let mut map = serde_json::Map::new(); + map.insert("atf".to_string(), serde_json::json!({"hb_pb": "1.00"})); + let script = build_bids_script(&map); + let inner = script + .trim_start_matches(""); + assert!(!inner.contains('<'), "no unescaped < in bids script"); + assert!(!inner.contains('>'), "no unescaped > in bids script"); + } + + #[test] + fn bids_script_calls_ad_init_without_retry_timer() { + let mut map = serde_json::Map::new(); + map.insert("atf".to_string(), serde_json::json!({"hb_pb": "1.00"})); + + let script = build_bids_script(&map); + + assert!( + script.contains("window.tsjs.adInit"), + "should hand off bids to adInit" + ); + assert!( + !script.contains("setTimeout"), + "should not retry adInit on a timer" + ); + assert!( + !script.contains("prevGptSlots"), + "should not use TS-owned slots as adInit success signal" + ); + } + + #[test] + fn auction_request_without_ec_id_omits_user_id_and_uses_non_ec_request_id() { + let slot = make_slot(); + let slots = [slot]; + let slots_ctx = MatchedSlotsContext { + matched_slots: &slots, + request_path: "/2024/01/my-article/", + }; + let request_info = RequestInfo { + host: "publisher.example.com".to_string(), + scheme: "https".to_string(), + }; + + let request = build_auction_request( + &slots_ctx, + None, + &ConsentContext::default(), + &request_info, + Some("Mozilla/5.0"), + ); + + assert_eq!(request.user.id, None, "should not forward an EC user id"); + assert!( + request.id.starts_with("ts-req-"), + "should use a non-EC request id, got {}", + request.id + ); + } + + #[test] + fn auction_request_with_ec_id_sets_user_id_and_ec_request_id() { + let slot = make_slot(); + let slots = [slot]; + let slots_ctx = MatchedSlotsContext { + matched_slots: &slots, + request_path: "/2024/01/my-article/", + }; + let request_info = RequestInfo { + host: "publisher.example.com".to_string(), + scheme: "https".to_string(), + }; + + let request = build_auction_request( + &slots_ctx, + Some("ec-abc"), + &ConsentContext::default(), + &request_info, + Some("Mozilla/5.0"), + ); + + assert_eq!( + request.user.id.as_deref(), + Some("ec-abc"), + "should forward EC id when identity consent allows it" + ); + assert_eq!( + request.id, "ts-ec-abc", + "should preserve existing EC-derived request id when present" + ); + } + + #[test] + fn html_escape_encodes_special_chars() { + assert_eq!( + html_escape_for_script("text\\with\\backslash"), + "text\\\\with\\\\backslash", + "should escape backslashes" + ); + assert_eq!( + html_escape_for_script("string\"with\"quotes"), + "string\\\"with\\\"quotes", + "should escape quotes" + ); + assert_eq!( + html_escape_for_script("simple"), + "simple", + "should not change simple text" + ); + assert_eq!( + html_escape_for_script("both\\\"mixed"), + "both\\\\\\\"mixed", + "should escape both backslashes and quotes" + ); + assert_eq!( + html_escape_for_script(""), + "\\u003Cscript\\u003Ealert(1)\\u003C/script\\u003E", + "should unicode-escape angle brackets to prevent script injection" + ); + assert_eq!( + html_escape_for_script("a&b"), + "a\\u0026b", + "should unicode-escape ampersand" + ); + assert_eq!( + html_escape_for_script("line\u{2028}sep"), + "line\\u2028sep", + "should unicode-escape U+2028 line separator" + ); + assert_eq!( + html_escape_for_script("para\u{2029}sep"), + "para\\u2029sep", + "should unicode-escape U+2029 paragraph separator" + ); + } + } + + mod page_bids_no_match_tests { + use super::super::*; + use crate::auction::AuctionOrchestrator; + use crate::creative_opportunities::{CreativeOpportunityFormat, CreativeOpportunitySlot}; + use crate::platform::test_support::noop_services; + use crate::test_support::tests::crate_test_settings_str; + use http::Method; + + fn settings_with_co() -> Settings { + let toml = format!( + "{}\n[auction]\nenabled = true\n\n[creative_opportunities]\ngam_network_id = \"12345\"\n", + crate_test_settings_str() + ); + Settings::from_toml(&toml).expect("should parse settings with creative_opportunities") + } + + fn settings_with_co_auction_disabled() -> Settings { + let toml = format!( + "{}\n[auction]\nenabled = false\n\n[creative_opportunities]\ngam_network_id = \"12345\"\n", + crate_test_settings_str() + ); + Settings::from_toml(&toml).expect("should parse settings with creative_opportunities") + } + + async fn run_page_bids( + settings: &Settings, + orchestrator: &AuctionOrchestrator, + slots: &[CreativeOpportunitySlot], + req: Request, + ) -> serde_json::Value { + let response = run_page_bids_response(settings, orchestrator, slots, req).await; + serde_json::from_slice(&response.into_body().into_bytes().unwrap_or_default()) + .expect("should be json") + } + + /// `run_page_bids` with an EC context whose jurisdiction allows the + /// server-side auction, so slot-counting tests isolate the variable + /// under test (bot/prefetch) from the consent gate. The default + /// request resolves to `Jurisdiction::Unknown`, which fails the + /// consent gate and now suppresses slots. + async fn run_page_bids_consent_allowed( + settings: &Settings, + orchestrator: &AuctionOrchestrator, + slots: &[CreativeOpportunitySlot], + req: Request, + ) -> serde_json::Value { + let ec_context = consent_allowing_ec_context(); + let response = + run_page_bids_response_with_ec(settings, orchestrator, slots, &ec_context, req) + .await; + serde_json::from_slice(&response.into_body().into_bytes().unwrap_or_default()) + .expect("should be json") + } + + /// Builds an [`EcContext`] whose consent context permits the server-side + /// auction (known non-GDPR jurisdiction, no EU TCF signal). + fn consent_allowing_ec_context() -> EcContext { + let consent = crate::consent::ConsentContext { + jurisdiction: crate::consent::jurisdiction::Jurisdiction::NonRegulated, + ..Default::default() + }; + EcContext::new_for_test(None, consent) + } + + fn article_slot() -> Vec { + vec![CreativeOpportunitySlot { + id: "atf".to_string(), + gam_unit_path: None, + div_id: None, + page_patterns: vec!["/20**".to_string()], + formats: vec![CreativeOpportunityFormat { + width: 300, + height: 250, + media_type: crate::auction::types::MediaType::Banner, + }], + floor_price: Some(0.50), + targeting: Default::default(), + providers: Default::default(), + compiled_patterns: Vec::new(), + }] + } + + fn make_page_bids_request(path: &str) -> Request { + let mut req = Request::builder() + .method(Method::GET) + .uri(format!( + "https://test-publisher.com/_ts/page-bids?path={path}" + )) + .body(EdgeBody::empty()) + .expect("should build test request"); + // Pass the same-origin gate the way a browser fetch from the + // publisher page does. + set_test_header(&mut req, "sec-fetch-site", "same-origin"); + req + } + + fn set_test_header(req: &mut Request, name: &'static str, value: &'static str) { + req.headers_mut().insert( + header::HeaderName::from_static(name), + HeaderValue::from_static(value), + ); + } + + async fn run_page_bids_response( + settings: &Settings, + orchestrator: &AuctionOrchestrator, + slots: &[CreativeOpportunitySlot], + req: Request, + ) -> Response { + let ec_context = EcContext::read_from_request(settings, &req, &noop_services()) + .expect("should read EC context"); + run_page_bids_response_with_ec(settings, orchestrator, slots, &ec_context, req).await + } + + async fn run_page_bids_response_with_ec( + settings: &Settings, + orchestrator: &AuctionOrchestrator, + slots: &[CreativeOpportunitySlot], + ec_context: &EcContext, + req: Request, + ) -> Response { + let services = noop_services(); + handle_page_bids( + settings, + &services, + None, + AuctionDispatch { + orchestrator, + slots, + registry: None, + }, + ec_context, + req, + ) + .await + .expect("should return ok response") + } + + #[tokio::test] + async fn cross_site_fetch_metadata_is_rejected() { + let settings = settings_with_co(); + let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); + let mut req = make_page_bids_request("/2024/01/my-article/"); + set_test_header(&mut req, "sec-fetch-site", "cross-site"); + + let response = + run_page_bids_response(&settings, &orchestrator, &article_slot(), req).await; + + assert_eq!( + response.status(), + StatusCode::FORBIDDEN, + "cross-site request should be rejected before any auction work" + ); + } + + #[tokio::test] + async fn missing_fetch_metadata_without_tsjs_header_is_rejected() { + let settings = settings_with_co(); + let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); + let mut req = make_page_bids_request("/2024/01/my-article/"); + req.headers_mut().remove("sec-fetch-site"); + + let response = + run_page_bids_response(&settings, &orchestrator, &article_slot(), req).await; + + assert_eq!( + response.status(), + StatusCode::FORBIDDEN, + "request with neither fetch metadata nor tsjs header should be rejected" + ); + } + + #[tokio::test] + async fn missing_fetch_metadata_with_tsjs_header_is_allowed() { + let settings = settings_with_co(); + let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); + let mut req = make_page_bids_request("/2024/01/my-article/"); + req.headers_mut().remove("sec-fetch-site"); + set_test_header(&mut req, "x-tsjs-page-bids", "1"); + + let response = + run_page_bids_response(&settings, &orchestrator, &article_slot(), req).await; + + assert_eq!( + response.status(), + StatusCode::OK, + "legacy client carrying the tsjs header should pass the gate" + ); + } + + #[tokio::test] + async fn same_site_fetch_metadata_is_rejected() { + let settings = settings_with_co(); + let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); + let mut req = make_page_bids_request("/2024/01/my-article/"); + // `same-site` admits sibling origins under the same registrable + // domain — not trusted to spend SSP quota. + set_test_header(&mut req, "sec-fetch-site", "same-site"); + + let response = + run_page_bids_response(&settings, &orchestrator, &article_slot(), req).await; + + assert_eq!( + response.status(), + StatusCode::FORBIDDEN, + "same-site request should be rejected; only same-origin is trusted" + ); + } + + #[tokio::test] + async fn empty_slots_file_returns_empty_slots_and_bids() { + // Spec §8 kill-switch: creative-opportunities.toml with zero slots disables + // all server-side auction activity and injection. + let settings = settings_with_co(); + let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); + let req = make_page_bids_request("/2024/01/my-article/"); + + let body = run_page_bids(&settings, &orchestrator, &[], req).await; + + assert_eq!( + body["slots"] + .as_array() + .expect("slots should be array") + .len(), + 0, + "empty slots should produce zero injected slots" + ); + assert_eq!( + body["bids"] + .as_object() + .expect("bids should be object") + .len(), + 0, + "empty slots should produce zero bids" + ); + } + + #[tokio::test] + async fn bot_user_agent_returns_slots_but_no_bids() { + // Crawlers should get slot definitions (so HTML structure is unchanged) + // but the server must not burn SSP request quota running a real auction + // for them. Same gate the publisher path applies. + let settings = settings_with_co(); + let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); + let slots = article_slot(); + let mut req = make_page_bids_request("/2024/01/my-article/"); + set_test_header( + &mut req, + "user-agent", + "Mozilla/5.0 (compatible; Googlebot/2.1)", + ); + + let body = run_page_bids_consent_allowed(&settings, &orchestrator, &slots, req).await; + + assert_eq!( + body["slots"] + .as_array() + .expect("slots should be array") + .len(), + 1, + "bot request should still get slot definitions" + ); + assert_eq!( + body["bids"] + .as_object() + .expect("bids should be object") + .len(), + 0, + "bot request must not run an auction (no SSP cost burned for crawlers)" + ); + } + + #[tokio::test] + async fn prefetch_request_returns_slots_but_no_bids() { + // Navigations triggered by Sec-Purpose=prefetch should not fire real + // SSP auctions — the user has not yet visited the page. + let settings = settings_with_co(); + let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); + let slots = article_slot(); + let mut req = make_page_bids_request("/2024/01/my-article/"); + set_test_header(&mut req, "sec-purpose", "prefetch"); + + let body = run_page_bids_consent_allowed(&settings, &orchestrator, &slots, req).await; + + assert_eq!( + body["slots"] + .as_array() + .expect("slots should be array") + .len(), + 1, + "prefetch request should still get slot definitions" + ); + assert_eq!( + body["bids"] + .as_object() + .expect("bids should be object") + .len(), + 0, + "prefetch request must not run an auction" + ); + } + + #[tokio::test] + async fn url_not_matching_any_pattern_returns_empty_response() { + // Slots exist but request path does not match — no auction, no injection. + let settings = settings_with_co(); + let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); + let slots = article_slot(); // slot matches /20** only + let req = make_page_bids_request("/about"); // does not match + + let body = run_page_bids(&settings, &orchestrator, &slots, req).await; + + assert_eq!( + body["slots"] + .as_array() + .expect("slots should be array") + .len(), + 0, + "non-matching URL should produce zero injected slots" + ); + assert_eq!( + body["bids"] + .as_object() + .expect("bids should be object") + .len(), + 0, + "non-matching URL should produce zero bids" + ); + } + + #[test] + fn normalize_page_bids_path_strips_query_fragment_and_forces_leading_slash() { + assert_eq!( + normalize_page_bids_path("/2024/01/article/"), + "/2024/01/article/", + "canonical path should pass through unchanged" + ); + assert_eq!( + normalize_page_bids_path("/2024/01/article/?utm_source=x"), + "/2024/01/article/", + "query string should be stripped before glob matching" + ); + assert_eq!( + normalize_page_bids_path("/2024/01/article/#section"), + "/2024/01/article/", + "fragment should be stripped before glob matching" + ); + assert_eq!( + normalize_page_bids_path("2024/01/article/"), + "/2024/01/article/", + "missing leading slash should be added" + ); + assert_eq!( + normalize_page_bids_path(""), + "/", + "empty path should normalize to root" + ); + } + + #[tokio::test] + async fn disabled_auction_returns_no_slots_or_bids() { + // [auction].enabled = false is a global kill switch: it must disable + // the entire server-side ad stack, not just SSP calls. Returning slot + // definitions would let the SPA hook assign `ts.adSlots` and call + // `adInit()`, creating/refreshing GPT slots client-side even though + // the auction is off. Consent is allowed here so the test isolates + // the kill switch. + let settings = settings_with_co_auction_disabled(); + let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); + let slots = article_slot(); + let req = make_page_bids_request("/2024/01/my-article/"); + + let body = run_page_bids_consent_allowed(&settings, &orchestrator, &slots, req).await; + + assert_eq!( + body["slots"] + .as_array() + .expect("slots should be array") + .len(), + 0, + "disabled auction must not return slot definitions (kill switch stops the ad stack)" + ); + assert_eq!( + body["bids"] + .as_object() + .expect("bids should be object") + .len(), + 0, + "disabled auction must not produce bids" + ); + } + + #[tokio::test] + async fn consent_denied_returns_no_slots_or_bids() { + // When consent denies the server-side auction (here: Jurisdiction + // Unknown fails closed), the endpoint must return no slots so the SPA + // hook does not create GPT slots client-side — matching the publisher + // navigation path's `should_run_server_side_ad_stack` gate. + let settings = settings_with_co(); + let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); + let slots = article_slot(); + let req = make_page_bids_request("/2024/01/my-article/"); + + // run_page_bids uses the default EC context, which resolves to + // Jurisdiction::Unknown (consent denied). + let body = run_page_bids(&settings, &orchestrator, &slots, req).await; + + assert_eq!( + body["slots"] + .as_array() + .expect("slots should be array") + .len(), + 0, + "consent denial must suppress slot definitions" + ); + assert_eq!( + body["bids"] + .as_object() + .expect("bids should be object") + .len(), + 0, + "consent denial must produce no bids" + ); + } + } + #[test] fn bounded_writer_accepts_writes_within_limit() { let mut writer = BoundedWriter::new(10); diff --git a/crates/trusted-server-core/src/response_privacy.rs b/crates/trusted-server-core/src/response_privacy.rs new file mode 100644 index 000000000..f9d564b6e --- /dev/null +++ b/crates/trusted-server-core/src/response_privacy.rs @@ -0,0 +1,288 @@ +//! Shared response cache-privacy hardening for every platform adapter. +//! +//! The server-side ad stack and EC identity lifecycle emit per-user responses +//! (assembled HTML, `page-bids` JSON, cookie-bearing navigations) that must +//! never reach a shared cache. Each adapter's `apply_finalize_headers` applies +//! operator-configured `settings.response_headers`, so the cookie-privacy +//! downgrade and the uncacheable-operator-header guard have to live in one place +//! and run byte-identically on Fastly, Cloudflare, Axum, and Spin — a shared +//! cache such as Cloudflare would otherwise serve an operator/origin +//! `Cache-Control: public` on a cookie-bearing response as-is. + +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; + +/// 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. +/// +/// Any response that sets a per-user cookie (notably the EC identity cookie) +/// must never be shared-cached, or a shared cache could replay one user's +/// `Set-Cookie` to others. +/// +/// Idempotent: a response already marked `private`/`no-store` keeps its stricter +/// `Cache-Control`, but the surrogate cache headers are stripped regardless so a +/// `no-store` cookie response can never retain shared cacheability. +pub fn enforce_set_cookie_cache_privacy(response: &mut Response) { + if !response.headers().contains_key(header::SET_COOKIE) { + return; + } + // 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. + 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 = cache_control_is_private_or_no_store(response); + if !already_uncacheable { + response.headers_mut().insert( + header::CACHE_CONTROL, + HeaderValue::from_static("private, max-age=0"), + ); + } +} + +/// Applies operator-configured `settings.response_headers` with cookie-privacy +/// hardening. +/// +/// 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 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 edge-cache headers cannot +/// produce a shared-cacheable cookie-bearing response. +/// +/// Invalid header names/values are logged and skipped rather than panicking, so +/// a misconfigured operator header can never take down a request. +pub fn apply_response_headers_with_cache_privacy(settings: &Settings, response: &mut Response) { + enforce_set_cookie_cache_privacy(response); + + let response_is_uncacheable = cache_control_is_private_or_no_store(response); + if response_is_uncacheable { + remove_edge_cache_headers(response.headers_mut()); + } + + for (key, value) in &settings.response_headers { + if response_is_uncacheable + && (key.eq_ignore_ascii_case(header::CACHE_CONTROL.as_str()) + || is_edge_cache_header_name(key)) + { + continue; + } + let header_name = match HeaderName::from_bytes(key.as_bytes()) { + Ok(name) => name, + Err(_) => { + log::warn!("Skipping invalid configured response header name {key}"); + continue; + } + }; + let header_value = match HeaderValue::from_str(value) { + Ok(value) => value, + Err(_) => { + log::warn!("Skipping invalid configured response header value for {key}"); + continue; + } + }; + 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 + // 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); +} + +#[cfg(test)] +mod tests { + use super::*; + + use edgezero_core::http::response_builder; + + fn settings_with_response_headers(headers: &[(&str, &str)]) -> Settings { + let mut s = Settings::from_toml( + r#" + [[handlers]] + path = "^/_ts/admin" + username = "admin" + password = "admin-pass" + + [publisher] + domain = "test-publisher.example.com" + cookie_domain = ".test-publisher.example.com" + origin_url = "https://origin.test-publisher.example.com" + proxy_secret = "unit-test-proxy-secret" + + [ec] + passphrase = "test-secret-key-32-bytes-minimum" + "#, + ) + .expect("should load test settings"); + s.response_headers = headers + .iter() + .map(|(k, v)| ((*k).to_string(), (*v).to_string())) + .collect(); + s + } + + #[test] + fn downgrades_public_cache_control_on_cookie_response() { + let settings = settings_with_response_headers(&[("cache-control", "public, max-age=600")]); + 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"); + + 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"), + "operator public Cache-Control must not override cookie privacy downgrade" + ); + assert!( + !response.headers().contains_key("surrogate-control") + && !response.headers().contains_key("cdn-cache-control") + && !response + .headers() + .contains_key("cloudflare-cdn-cache-control"), + "edge cache headers must be stripped on cookie responses" + ); + } + + #[test] + fn downgrades_operator_configured_set_cookie_with_public_cache_headers() { + // Operator headers that add Set-Cookie plus shared-cache directives to + // a cookieless response must be re-downgraded after they are applied. + let settings = settings_with_response_headers(&[ + ("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()) + .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"), + "operator Set-Cookie plus public Cache-Control must be re-downgraded to private" + ); + assert!( + !response.headers().contains_key("surrogate-control") + && !response.headers().contains_key("cdn-cache-control") + && !response + .headers() + .contains_key("cloudflare-cdn-cache-control"), + "edge cache headers must be stripped when operator headers add Set-Cookie" + ); + assert!( + response.headers().contains_key(header::SET_COOKIE), + "the operator Set-Cookie itself should still be applied" + ); + } + + #[test] + fn cookie_privacy_does_not_treat_pseudo_directives_as_uncacheable() { + let settings = settings_with_response_headers(&[]); + let mut response = response_builder() + .header(header::SET_COOKIE, "id=abc") + .header( + header::CACHE_CONTROL, + "public, max-age=600, no-storey, not-private", + ) + .header("surrogate-control", "max-age=600") + .body(edgezero_core::body::Body::empty()) + .expect("should build response"); + + apply_response_headers_with_cache_privacy(&settings, &mut response); + + assert_eq!( + response + .headers() + .get(header::CACHE_CONTROL) + .and_then(|v| v.to_str().ok()), + Some("private, max-age=0"), + "pseudo-directives must not prevent the cookie privacy downgrade" + ); + assert!( + !response.headers().contains_key("surrogate-control"), + "cookie privacy downgrade should still strip edge-cache headers" + ); + } + + #[test] + fn 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")]); + let mut response = response_builder() + .body(edgezero_core::body::Body::empty()) + .expect("should build response"); + + apply_response_headers_with_cache_privacy(&settings, &mut response); + + assert_eq!( + response + .headers() + .get("x-operator") + .and_then(|v| v.to_str().ok()), + Some("value"), + "operator headers should still apply to cacheable responses" + ); + } +} diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index 5075569b0..044b87c11 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,11 +9,14 @@ 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; use crate::host_header::validate_host_header_override_value; use crate::platform::PlatformImageOptimizerRegion; @@ -1720,6 +1724,322 @@ impl Proxy { } } +/// Cache behavior configuration. +#[derive(Debug, Default, Clone, Deserialize, Serialize, Validate)] +#[serde(deny_unknown_fields)] +pub struct CacheSettings { + /// Ordered static/rehosted asset rules. The first enabled matching rule wins. + #[serde(default)] + pub asset_rules: Vec, +} + +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)] @@ -1731,6 +2051,21 @@ pub struct DebugConfig { /// Fastly-observed TLS details that browser JS cannot normally read. #[serde(default)] pub ja4_endpoint_enabled: bool, + + /// Inject a `` HTML comment before `` showing + /// auction pipeline stats (SSP count, mediator status, winning bid count). + /// Never enable in production — visible in page source. + #[serde(default)] + pub auction_html_comment: bool, + + /// Include raw `adm` creative markup in `window.tsjs.bids` for GPT/GAM + /// debug rendering through the Prebid Universal Creative bridge. + /// + /// Use this to validate the server-side auction→GAM targeting→creative + /// rendering pipeline while PBS Cache is unavailable. Never enable in + /// production — injects raw HTML from SSPs. + #[serde(default)] + pub inject_adm_for_testing: bool, } /// Tester-cookie endpoint configuration. @@ -1767,8 +2102,13 @@ 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, + #[serde(default)] pub image_optimizer: ImageOptimizerSettings, #[serde(default)] pub debug: DebugConfig, @@ -1846,6 +2186,7 @@ impl Settings { validation_label: &str, ) -> Result> { settings.integrations.normalize(); + settings.cache.normalize(); settings.proxy.normalize(); settings.image_optimizer.normalize(); settings.consent.validate(); @@ -1868,9 +2209,12 @@ impl Settings { /// /// # Errors /// - /// Returns a configuration error if any handler path regex does not compile. - pub fn prepare_runtime(&self) -> Result<(), Report> { + /// Returns a configuration error if any cached runtime artifact cannot be + /// prepared, if any handler path regex does not compile, or if a creative + /// 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.validate_asset_image_optimizer_profile_sets()?; @@ -1878,6 +2222,18 @@ impl Settings { handler.prepare_runtime()?; } + if let Some(co) = &mut self.creative_opportunities { + co.compile_slots(); + // Slots flow into injected HTML/JS, provider payloads, and GPT + // calls. Env/private config can bypass static review, so validate + // the full runtime shape on every load path. + co.validate_runtime().map_err(|err| { + Report::new(TrustedServerError::Configuration { + message: format!("Invalid creative opportunity slot config: {err}"), + }) + })?; + } + for (name, value) in &self.response_headers { http::header::HeaderName::from_bytes(name.as_bytes()).map_err(|_| { Report::new(TrustedServerError::Configuration { @@ -1894,6 +2250,17 @@ impl Settings { Ok(()) } + /// Returns compiled creative opportunity slots, or empty slice if feature is disabled. + #[must_use] + pub fn creative_opportunity_slots( + &self, + ) -> &[crate::creative_opportunities::CreativeOpportunitySlot] { + self.creative_opportunities + .as_ref() + .map(|co| co.slot.as_slice()) + .unwrap_or(&[]) + } + /// Rejects known placeholder secret values. /// /// # Errors @@ -1955,6 +2322,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> { @@ -2463,6 +2842,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( @@ -5044,6 +5560,210 @@ origin_host_header_overide = "www.example.com""#, /// /// If this test fails, a route was added or removed in the Fastly /// router without updating `ADMIN_ENDPOINTS` (or vice versa). + #[test] + fn settings_parses_creative_opportunities_section() { + let toml = r#" +[[handlers]] +path = "^/_ts/admin" +username = "admin" +password = "unit-test-admin-secret" + +[publisher] +domain = "example.com" +cookie_domain = ".example.com" +origin_url = "https://origin.example.com" +proxy_secret = "secret" + +[ec] +passphrase = "test-secret-key-32-bytes-minimum" + +[creative_opportunities] +gam_network_id = "21765378893" +auction_timeout_ms = 500 +"#; + let settings = Settings::from_toml(toml).expect("should parse"); + let co = settings + .creative_opportunities + .expect("should have creative_opportunities"); + assert_eq!(co.gam_network_id, "21765378893"); + assert_eq!(co.auction_timeout_ms, Some(500)); + } + + #[test] + fn settings_rejects_invalid_creative_opportunity_slot_id() { + let toml = r#" +[[handlers]] +path = "^/_ts/admin" +username = "admin" +password = "unit-test-admin-secret" + +[publisher] +domain = "example.com" +cookie_domain = ".example.com" +origin_url = "https://origin.example.com" +proxy_secret = "secret" + +[ec] +passphrase = "test-secret-key-32-bytes-minimum" + +[creative_opportunities] +gam_network_id = "21765378893" + +[[creative_opportunities.slot]] +id = "xss", + 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" ); } @@ -234,14 +236,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-integration-tests/browser/helpers/infra.ts b/crates/trusted-server-integration-tests/browser/helpers/infra.ts index c868713fc..0402bb266 100644 --- a/crates/trusted-server-integration-tests/browser/helpers/infra.ts +++ b/crates/trusted-server-integration-tests/browser/helpers/infra.ts @@ -114,7 +114,7 @@ export async function startViceroy( const baseUrl = `http://127.0.0.1:${port}`; try { - await waitForReady(baseUrl, "/"); + await waitForReady(baseUrl, "/health"); } catch (err) { // Viceroy spawned but never became ready — kill it before propagating. if (child.pid) await stopViceroy(child.pid); diff --git a/crates/trusted-server-js/Cargo.toml b/crates/trusted-server-js/Cargo.toml index c1e832a58..b1c5ebf71 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 5055e9840..faa1e5791 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/lib/src/core/index.ts b/crates/trusted-server-js/lib/src/core/index.ts index 227b1a351..21cd9d447 100644 --- a/crates/trusted-server-js/lib/src/core/index.ts +++ b/crates/trusted-server-js/lib/src/core/index.ts @@ -29,6 +29,13 @@ api.setConfig = setConfig; api.getConfig = getConfig; // Provide core requestAds API api.requestAds = requestAds; +// Defensive defaults: the edge injects adSlots (head-open) and bids (before +// ) only when the server-side ad stack runs for the request. When it +// is gated off (kill switch, consent fail-closed, bots, prefetch), page code +// reading window.tsjs.bids / window.tsjs.adSlots must still see defined +// values instead of throwing. Injected scripts overwrite these wholesale. +api.adSlots ??= []; +api.bids ??= {}; // Point global tsjs w.tsjs = api; diff --git a/crates/trusted-server-js/lib/src/core/types.ts b/crates/trusted-server-js/lib/src/core/types.ts index 9f726bb96..ec2882efb 100644 --- a/crates/trusted-server-js/lib/src/core/types.ts +++ b/crates/trusted-server-js/lib/src/core/types.ts @@ -20,6 +20,49 @@ export interface AdUnit { bids?: Bid[]; } +/** Minimal shape of a server-side auction slot injected into `window.tsjs.adSlots`. */ +export interface AuctionSlot { + id: string; + gam_unit_path: string; + div_id: string; + formats: Array<[number, number]>; + targeting?: Record; +} + +/** Debug-only copy of server-side bid fields exposed for pipeline inspection. */ +export interface AuctionDebugBidData { + slot_id?: string; + price?: number | null; + currency?: string; + creative?: string | null; + adomain?: string[] | null; + bidder?: string; + width?: number; + height?: number; + nurl?: string | null; + burl?: string | null; + ad_id?: string | null; + cache_id?: string | null; + cache_host?: string | null; + cache_path?: string | null; + metadata?: Record; +} + +/** Bid targeting data from the server-side auction, injected into `window.tsjs.bids`. */ +export interface AuctionBidData { + hb_pb?: string; + hb_bidder?: string; + hb_adid?: string; + hb_cache_host?: string; + hb_cache_path?: string; + nurl?: string; + burl?: string; + /** Raw creative markup. Only present when `[debug] inject_adm_for_testing = true`. */ + adm?: string; + /** Debug-only bid field mirror. Only present when `[debug] inject_adm_for_testing = true`. */ + debug_bid?: AuctionDebugBidData; +} + export interface TsjsApi { version: string; que: Array<() => void>; @@ -41,4 +84,43 @@ export interface TsjsApi { error(...args: unknown[]): void; debug(...args: unknown[]): void; }; + + // ── Server-side auction runtime (populated by TS edge injection) ────────── + /** Ad slot definitions injected at open. */ + adSlots?: AuctionSlot[]; + /** Winning bid targeting data injected before . */ + bids?: Record; + /** Initialises GPT slots with server-side bid targeting and calls refresh(). */ + adInit?: () => void; + /** GPT slot objects TS defined — used to destroy stale slots on SPA navigation. */ + prevGptSlots?: unknown[]; + /** Guards one-time-per-page enableSingleRequest/enableServices calls. */ + servicesEnabled?: boolean; + /** Maps actualDivId → slotId for slotRenderEnded billing lookup. */ + divToSlotId?: Record; + /** + * Win/billing beacons already fired, keyed by `slotId|bidIdentity`. + * Used by the GPT render bridge so a bid's nurl/burl fire at most once even + * across repeated Prebid Universal Creative requests for the same adId. + */ + firedBeacons?: Record; + /** Slot-level GPT targeting keys TS applied on the previous route. */ + prevSlotTargetingKeys?: Record; + /** + * One-shot bypass for the slim-Prebid refresh wrapper: true only while + * adInit() runs its internal refresh of server-side-targeted slots, so the + * wrapper passes that refresh straight to GPT instead of starting a + * client-side auction that would clear the just-applied TS targeting. + */ + adInitRefreshInProgress?: boolean; + /** + * True once the publisher has called `googletag.pubads().disableInitialLoad()`. + * GPT exposes no getter for this state, so it is tracked by wrapping the + * setter. When set, `display()` only registers a slot and the ad request must + * come from a `refresh()`; adInit() uses this to refresh its own freshly + * defined slots so they are not left blank. + */ + gptInitialLoadDisabled?: boolean; + /** Guards SPA pushState hook installation. */ + spaHookInstalled?: boolean; } diff --git a/crates/trusted-server-js/lib/src/integrations/gpt/index.ts b/crates/trusted-server-js/lib/src/integrations/gpt/index.ts index 0b9e30235..8b0b8d529 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt/index.ts @@ -1,4 +1,5 @@ import { log } from '../../core/log'; +import type { AuctionSlot, AuctionBidData, TsjsApi } from '../../core/types'; import { installGptGuard } from './script_guard'; @@ -23,6 +24,16 @@ import { installGptGuard } from './script_guard'; * - Rewrite ad-unit paths for A/B testing. */ +const TS_INITIAL_TARGETING_KEY = 'ts_initial' as const; +const TS_BID_TARGETING_KEYS = [ + 'hb_pb', + 'hb_bidder', + 'hb_adid', + 'hb_cache_host', + 'hb_cache_path', +] as const; +const TS_BASE_TARGETING_KEYS = [...TS_BID_TARGETING_KEYS, TS_INITIAL_TARGETING_KEY] as const; + // ------------------------------------------------------------------ // googletag type stubs (minimal surface needed by the shim) // ------------------------------------------------------------------ @@ -31,12 +42,71 @@ interface GoogleTagSlot { getAdUnitPath(): string; getSlotElementId(): string; setTargeting(key: string, value: string | string[]): GoogleTagSlot; + clearTargeting?(key?: string): GoogleTagSlot; + addService(service: GoogleTagPubAdsService): GoogleTagSlot; + getTargeting?(key: string): string[]; +} + +interface SlotRenderEndedEvent { + isEmpty: boolean; + slot: GoogleTagSlot; +} + +function findSlotElementByDivId(divId: string): HTMLElement | null { + const exact = document.getElementById(divId); + if (exact) return exact; + + return ( + Array.from(document.querySelectorAll('[id]')).find( + (el) => el.id.startsWith(divId) && !el.id.endsWith('-container') + ) ?? null + ); +} + +function candidateSlotRoots(divId: string): HTMLElement[] { + const roots: HTMLElement[] = []; + const slotEl = findSlotElementByDivId(divId); + if (slotEl) { + roots.push(slotEl); + const container = document.getElementById(`${slotEl.id}-container`); + if (container) roots.push(container); + } + + const configuredContainer = document.getElementById(`${divId}-container`); + if (configuredContainer && !roots.includes(configuredContainer)) { + roots.push(configuredContainer); + } + + return roots; +} + +function slotIdForMessageSource(source: MessageEventSource | null): string | undefined { + if (!source) return undefined; + + const slots = window.tsjs?.adSlots ?? []; + return slots.find((slot) => + candidateSlotRoots(slot.div_id).some((root) => + Array.from(root.querySelectorAll('iframe')).some((iframe) => iframe.contentWindow === source) + ) + )?.id; +} + +function clearTargetingKeys(slot: GoogleTagSlot, keys: Iterable): void { + if (typeof slot.clearTargeting !== 'function') return; + + for (const key of new Set(keys)) { + slot.clearTargeting(key); + } } interface GoogleTagPubAdsService { setTargeting(key: string, value: string | string[]): GoogleTagPubAdsService; getTargeting(key: string): string[]; enableSingleRequest(): void; + addEventListener(event: string, fn: (e: SlotRenderEndedEvent) => void): void; + refresh(slots?: GoogleTagSlot[]): void; + getSlots?(): GoogleTagSlot[]; + disableInitialLoad?(): void; } interface GoogleTag { @@ -47,6 +117,7 @@ interface GoogleTag { size: Array, elementId: string ): GoogleTagSlot | null; + destroySlots(slots?: GoogleTagSlot[]): boolean; enableServices(): void; display(elementId: string): void; _loaded_?: boolean; @@ -54,6 +125,7 @@ interface GoogleTag { type GptWindow = Window & { googletag?: Partial; + __tsjs_slim_prebid_url?: string; }; // ------------------------------------------------------------------ @@ -105,7 +177,8 @@ function wrapCommand(fn: () => void): () => void { */ function patchCommandQueue(tag: Partial): void { // Ensure the queue exists. - if (!Array.isArray(tag.cmd)) { + if (!tag.cmd) { + // Cast through unknown so an array satisfies the { push } type. tag.cmd = []; } @@ -121,7 +194,9 @@ function patchCommandQueue(tag: Partial): void { // Override push on the *existing* array — preserves object identity so // GPT (if already loaded) keeps its reference. - queue.push = function (...callbacks: Array<() => void>): number { + (queue as { push: (...cbs: Array<() => void>) => unknown }).push = function ( + ...callbacks: Array<() => void> + ): unknown { const wrapped = callbacks.map(wrapCommand); return originalPush(...wrapped); }; @@ -130,11 +205,15 @@ function patchCommandQueue(tag: Partial): void { (queue as { __tsPushed?: boolean }).__tsPushed = true; // Re-wrap any callbacks that were queued before we patched. - for (let i = 0; i < queue.length; i++) { - queue[i] = wrapCommand(queue[i]); + // Only applicable when cmd is an array (pre-GPT-load case). + if (Array.isArray(queue)) { + for (let i = 0; i < queue.length; i++) { + queue[i] = wrapCommand(queue[i]); + } + log.debug('GPT shim: command queue patched', { pendingCommands: queue.length }); + } else { + log.debug('GPT shim: command queue patched'); } - - log.debug('GPT shim: command queue patched', { pendingCommands: queue.length }); } /** @@ -161,6 +240,702 @@ export function installGptShim(): boolean { return true; } +// ------------------------------------------------------------------ +// GAM interceptor (testing only) +// ------------------------------------------------------------------ + +/** + * Sandbox token list for debug ADM fallback iframes. + * + * Deliberately excludes `allow-same-origin`: combined with `allow-scripts` on + * srcdoc (or first-party src) content, that pair effectively removes the + * sandbox's origin isolation and would let SSP-provided markup run with the + * publisher origin's privileges. + */ +export const ADM_IFRAME_SANDBOX = 'allow-scripts allow-popups allow-forms'; + +/** + * Resolve an ADM-extracted iframe src to a safe, loadable URL. + * + * Protocol-relative URLs are upgraded to https. Only http(s) URLs (including + * page-relative paths, which resolve against the page origin) are accepted — + * anything else (javascript:, data:, blob:, …) is rejected so SSP-provided + * markup cannot smuggle a script-executing URL into the unsandboxed GAM + * iframe. + */ +export function safeAdmIframeSrc(src: string): string | undefined { + const resolved = src.startsWith('//') ? `https:${src}` : src; + try { + const parsed = new URL(resolved, window.location.href); + if (parsed.protocol === 'https:' || parsed.protocol === 'http:') { + return resolved; + } + } catch { + // Unparseable URL — treat as unsafe. + } + return undefined; +} + +/** + * Replace the GAM-rendered creative with the server-side auction adm. + * + * Adapted from PR #241 (github.com/IABTechLab/trusted-server/pull/241). + * Instead of reading from pbjs, reads adm directly from window.tsjs.bids. + * Only active when inject_adm_for_testing injects adm server-side. + * + * Strategy: + * 1. If adm contains an