diff --git a/CHANGELOG.md b/CHANGELOG.md index 083d6e746..261387a7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- TSJS-generated envelopes now send `trustedServer.params.storedRequest: false`, preventing accidental PBS stored lookups without suppressing eligible non-PBS demand. PBS filters unusable impressions after overrides; explicit `true` and legacy omission retain inline-first stored fallback. Publisher intent survives repeated and refresh auctions. Deploy compatible server admission everywhere before serving the new JS, and retain it during rollback while cached clients remain. See the Prebid deployment guide. - Protocol-relative creative URLs now honor `rewrite.exclude_domains`, so excluded creative assets stay direct and excluded absolute or protocol-relative URLs submitted to `/first-party/sign` are rejected. - Server-side ad template bids now always carry `hb_adid` in `window.tsjs.bids`. Bidders that return neither a Prebid Cache UUID nor an `adid` previously produced no `hb_adid` at all, so no `hb_adid` GPT targeting key was set and the Universal Creative render bridge had nothing to match — the winning creative never rendered. The OpenRTB bid `id`, which is mandatory per spec, is now the last-resort source; `cache_id` and `adid` still take priority where present. Blank `cacheId`/`adid` values no longer win that precedence and emit an unusable empty `hb_adid`, and `hb_cache_host`/`hb_cache_path` are now emitted only alongside a real Prebid Cache UUID — without one they pointed the Universal Creative at a guaranteed cache miss instead of letting it fall through to the inline creative. diff --git a/crates/trusted-server-core/src/auction/formats.rs b/crates/trusted-server-core/src/auction/formats.rs index 571d9d484..255346ee4 100644 --- a/crates/trusted-server-core/src/auction/formats.rs +++ b/crates/trusted-server-core/src/auction/formats.rs @@ -51,10 +51,12 @@ pub struct 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.`. +/// `bids` is optional. Absent or empty bids retain legacy PBS stored fallback +/// keyed by `code` (`imp.ext.prebid.storedrequest.id`). Bidder params route through +/// the server-owned auction plan. The reserved `trustedServer` entry accepts +/// `bidderParams`, `zone`, and boolean `storedRequest` inside its params. False +/// disables stored fallback, true permits it, and omission retains legacy +/// inference. Usable inline PBS params take precedence after provider overrides. #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct AdUnit { @@ -743,6 +745,24 @@ mod tests { .expect("should convert banner request") } + #[test] + fn tsjs_wire_stored_intent_survives_conversion_and_atomic_admission() { + for (intent, expected_inputs, malformed) in [ + (json!(true), 1, 0), + (json!(false), 0, 0), + (json!(null), 0, 1), + ] { + let body: AdRequest = serde_json::from_value(json!({ + "adUnits":[{"code":"example-slot","mediaTypes":{"banner":{"sizes":[[300,250]]}}, + "bids":[{"bidder":"trustedServer","params":{"bidderParams":{},"storedRequest":intent}}]}] + })).expect("should deserialize wire request"); + let request = convert_body_to_auction_request(&body, &make_settings()); + let routed = route_auction(request, &make_request(), &single_prebid_plan(), None); + assert_eq!(routed.inputs().len(), expected_inputs); + assert_eq!(routed.diagnostics().malformed_envelope_count(), malformed); + } + } + #[test] fn canonical_tsjs_request_without_bids_feeds_stored_request_router() { let body = AdRequest { diff --git a/crates/trusted-server-core/src/auction/openrtb.rs b/crates/trusted-server-core/src/auction/openrtb.rs index cf657a1ec..2cc11beb6 100644 --- a/crates/trusted-server-core/src/auction/openrtb.rs +++ b/crates/trusted-server-core/src/auction/openrtb.rs @@ -263,6 +263,9 @@ pub(crate) fn build_request( return Ok(OpenRtbBuildOutcome::NoImpressions); } policy.augment_request(&mut request, input, routed)?; + if request.imp.is_empty() { + return Ok(OpenRtbBuildOutcome::NoImpressions); + } finalize_request(&mut request, policy, finalization)?; Ok(OpenRtbBuildOutcome::Ready(request)) } @@ -482,35 +485,43 @@ fn apply_prebid( input.slots().len(), "should keep one impression per routed slot" ); - for (imp, slot) in request.imp.iter_mut().zip(input.slots()) { - let bidder = slot - .bidder_params() - .iter() - .filter_map(|(bidder, params)| { - let mut params = params.clone(); - plan.override_engine - .apply_routed(bidder.as_str(), slot.prebid_zone(), &mut params); - params - .as_object() - .is_some_and(|params| !params.is_empty()) - .then(|| (bidder.as_str().to_string(), params)) - }) - .collect::>(); - let mut prebid = Map::new(); - if !bidder.is_empty() { - prebid.insert("bidder".to_string(), Value::Object(bidder)); - } else if slot.has_trusted_stored_request() || !slot.bidder_params().is_empty() { - prebid.insert("storedrequest".to_string(), json!({"id": slot.slot().id})); - } - debug_assert!( - !prebid.is_empty(), - "should never route a demandless slot to prebid-server" - ); - imp.ext = Some(Map::from_iter([( - "prebid".to_string(), - Value::Object(prebid), - )])); - } + // Filter paired impressions and slots together so later demand keeps its slot ID. + request.imp = std::mem::take(&mut request.imp) + .into_iter() + .zip(input.slots()) + .filter_map(|(mut imp, slot)| { + let bidder = slot + .bidder_params() + .iter() + .filter_map(|(bidder, params)| { + let mut params = params.clone(); + plan.override_engine.apply_routed( + bidder.as_str(), + slot.prebid_zone(), + &mut params, + ); + params + .as_object() + .is_some_and(|params| !params.is_empty()) + .then(|| (bidder.as_str().to_string(), params)) + }) + .collect::>(); + let mut prebid = Map::new(); + if !bidder.is_empty() { + prebid.insert("bidder".to_string(), Value::Object(bidder)); + } else if slot.allows_stored_fallback() { + prebid.insert("storedrequest".to_string(), json!({"id": slot.slot().id})); + } + if prebid.is_empty() { + return None; + } + imp.ext = Some(Map::from_iter([( + "prebid".to_string(), + Value::Object(prebid), + )])); + Some(imp) + }) + .collect(); let mut prebid_request = Map::new(); if plan.debug { prebid_request.insert("debug".to_string(), Value::Bool(true)); diff --git a/crates/trusted-server-core/src/auction/openrtb/tests.rs b/crates/trusted-server-core/src/auction/openrtb/tests.rs index c2d81088b..56a385045 100644 --- a/crates/trusted-server-core/src/auction/openrtb/tests.rs +++ b/crates/trusted-server-core/src/auction/openrtb/tests.rs @@ -546,6 +546,180 @@ fn pbs_pairs_each_impression_with_its_routed_slot_params() { ); } +#[test] +fn pbs_stored_intent_is_applied_after_overrides_with_inline_first() { + for intent in [None, Some(false), Some(true)] { + for inline in [false, true] { + for fill_override in [false, true] { + let profile = if fill_override { + json!({"bid_param_overrides":{"exampleBidder":{"filled":1}}}) + } else { + json!({}) + }; + let plan = AuctionPlan::compile(config("prebid-server", profile)) + .expect("should compile plan"); + let mut request = canonical_parity_auction_request(); + let mut envelope = json!({"bidderParams":{"exampleBidder":if inline { json!({"original":1}) } else { json!({}) }}}); + if let Some(intent) = intent { + envelope["storedRequest"] = json!(intent); + } + request.slots[0].bidders = HashMap::from([("trustedServer".to_string(), envelope)]); + let inbound = Request::new(EdgeBody::empty()); + let routed = route_auction(request, &inbound, &plan, None); + let result = build_request( + &routed.inputs()[0], + &routed, + &plan.providers()[0], + 321, + &finalization(None), + ) + .expect("should build request"); + if !inline && !fill_override && intent == Some(false) { + assert!(matches!(result, OpenRtbBuildOutcome::NoImpressions)); + continue; + } + let OpenRtbBuildOutcome::Ready(request) = result else { + panic!("should retain demand") + }; + let wire = serde_json::to_value(request).expect("should serialize request"); + let prebid = &wire["imp"][0]["ext"]["prebid"]; + if inline || fill_override { + assert!(prebid.get("storedrequest").is_none()); + assert_eq!( + prebid["bidder"]["exampleBidder"].get("original"), + inline.then_some(&json!(1)) + ); + assert_eq!( + prebid["bidder"]["exampleBidder"].get("filled"), + fill_override.then_some(&json!(1)) + ); + } else { + assert_eq!(prebid["storedrequest"]["id"], "fictional-slot"); + } + } + } + } +} + +#[test] +fn pbs_filtering_keeps_slot_pairs_and_drops_demandless_trusted_routes() { + let plan = + AuctionPlan::compile(config("prebid-server", json!({}))).expect("should compile plan"); + let mut request = canonical_parity_auction_request(); + let template = request.slots[0].clone(); + request.slots = [ + ( + "drop-first", + json!({"storedRequest":false,"bidderParams":{"exampleBidder":{}}}), + ), + ( + "keep-inline", + json!({"storedRequest":false,"bidderParams":{"exampleBidder":{"id":2}}}), + ), + ( + "drop-trusted", + json!({"storedRequest":false,"bidderParams":{}}), + ), + ( + "keep-stored", + json!({"storedRequest":true,"bidderParams":{}}), + ), + ] + .into_iter() + .map(|(id, envelope)| AdSlot { + id: id.to_string(), + bidders: HashMap::from([("trustedServer".to_string(), envelope)]), + ..template.clone() + }) + .collect(); + let inbound = Request::new(EdgeBody::empty()); + let routes = crate::auction::routing::TrustedProviderRoutes::new(vec![ + vec![], + vec![], + vec![plan.providers()[0].id.clone()], + vec![], + ]); + let routed = crate::auction::routing::route_auction_with_trusted_routes( + request, &inbound, &plan, None, &routes, + ); + assert_eq!( + routed.inputs()[0].slots().len(), + 4, + "should preserve server-owned route admission" + ); + let result = build_request( + &routed.inputs()[0], + &routed, + &plan.providers()[0], + 321, + &finalization(None), + ) + .expect("should build request"); + let OpenRtbBuildOutcome::Ready(request) = result else { + panic!("should retain siblings") + }; + let wire = serde_json::to_value(request).expect("should serialize request"); + assert_eq!( + wire["imp"] + .as_array() + .expect("should have impressions") + .len(), + 2 + ); + assert_eq!(wire["imp"][0]["id"], "keep-inline"); + assert_eq!( + wire["imp"][0]["ext"]["prebid"]["bidder"]["exampleBidder"], + json!({"id":2}) + ); + assert_eq!(wire["imp"][1]["id"], "keep-stored"); + assert_eq!( + wire["imp"][1]["ext"]["prebid"]["storedrequest"]["id"], + "keep-stored" + ); +} + +#[test] +fn pbs_disabled_empty_candidate_does_not_become_stored_demand() { + let mut raw = config("prebid-server", json!({})); + raw.providers + .get_mut(&ProviderId::from_str("fictional-provider").expect("should parse provider")) + .expect("should find provider") + .routing = RoutingMode::Explicit; + raw.bidders.insert( + crate::auction::plan::BidderId::from_str("exampleBidder").expect("should parse bidder"), + BidderRouteConfig { + provider: ProviderId::from_str("fictional-provider").expect("should parse provider"), + }, + ); + let plan = AuctionPlan::compile(raw).expect("should compile PBS plan"); + let mut request = canonical_parity_auction_request(); + request.slots[0].bidders = HashMap::from([( + "trustedServer".to_string(), + json!({"bidderParams":{"exampleBidder":{}}, "storedRequest": false}), + )]); + let inbound = Request::builder() + .uri("https://publisher.example.com/auction") + .body(EdgeBody::empty()) + .expect("should build inbound request"); + let routed = route_auction(request, &inbound, &plan, None); + assert_eq!( + routed.inputs().len(), + 1, + "should route candidate for overrides" + ); + assert!(matches!( + build_request( + &routed.inputs()[0], + &routed, + &plan.providers()[0], + 321, + &finalization(None), + ) + .expect("should build request"), + OpenRtbBuildOutcome::NoImpressions + )); +} + #[test] fn pbs_empty_params_without_matching_override_fall_back_to_stored_request() { let mut raw = config("prebid-server", json!({})); diff --git a/crates/trusted-server-core/src/auction/orchestrator.rs b/crates/trusted-server-core/src/auction/orchestrator.rs index 954869e38..389975ea7 100644 --- a/crates/trusted-server-core/src/auction/orchestrator.rs +++ b/crates/trusted-server-core/src/auction/orchestrator.rs @@ -5919,6 +5919,138 @@ mod tests { ); } + #[tokio::test] + async fn planned_prebid_stored_intent_filters_wire_demand_and_skips_empty_transports() { + for inline_providers in 0..=2 { + let http = Arc::new(StubHttpClient::new()); + // Providers launch in ID order: APS, then only PBS instances with usable demand. + http.push_response(204, Vec::new()); + for index in 0..inline_providers { + http.push_response( + 200, + serde_json::to_vec(&serde_json::json!({ + "seatbid":[{"seat":"example-seat","bid":[{ + "id":format!("bid-{index}"), "impid":format!("inline-{index}"), + "price":2.0, "adm":"
example
", "w":300, "h":250 + }]}] + })) + .expect("should serialize PBS response"), + ); + } + let backend = Arc::new(NamingBackend::new(BackendNamingPolicy::Fastly)); + let services = build_services_with_backend_and_http_client( + Arc::clone(&backend) as Arc<_>, + Arc::clone(&http) as Arc<_>, + ); + let mut config = planned_prebid_config(&[ + ( + "pbs-a", + serde_json::json!({}), + NotificationConfig::default(), + ), + ( + "pbs-b", + serde_json::json!({}), + NotificationConfig::default(), + ), + ]); + config.providers.extend(planned_aps_config().providers); + for (bidder, provider) in [("alpha", "pbs-a"), ("beta", "pbs-b")] { + config.bidders.insert( + bidder.parse().expect("should parse bidder"), + crate::auction::plan::BidderRouteConfig { + provider: provider.parse().expect("should parse provider"), + }, + ); + } + let plan = AuctionPlan::compile(config).expect("should compile plan"); + let orchestrator = AuctionOrchestratorHarness::new(plan, None); + let mut request = planned_request(); + let template = request.slots[0].clone(); + // Both PBS instances must evaluate candidates but omit them after overrides. + request.slots[0].bidders.insert( + "trustedServer".to_string(), + serde_json::json!({ + "storedRequest":false, "bidderParams":{"alpha":{},"beta":{}} + }), + ); + request.slots.push(AdSlot { + id: "synthetic-no-pbs".to_string(), + bidders: HashMap::from([( + "trustedServer".to_string(), + serde_json::json!({"storedRequest":false,"bidderParams":{}}), + )]), + ..template.clone() + }); + for index in 0..inline_providers { + let bidder = if index == 0 { "alpha" } else { "beta" }; + request.slots.push(AdSlot { + id:format!("inline-{index}"), + bidders:HashMap::from([("trustedServer".to_string(),serde_json::json!({"storedRequest":false,"bidderParams":{bidder:{"placement":index}}}))]), + ..template.clone() + }); + } + let settings = create_test_settings(); + let inbound = http::Request::builder() + .uri("https://example.com/auction") + .body(edgezero_core::body::Body::empty()) + .expect("should build inbound"); + let context = AuctionContext { + settings: &settings, + request: &inbound, + timeout_ms: 777, + transport_timeout_ms: 777, + provider_responses: None, + services: &services, + }; + let result = orchestrator + .run_auction(&request, &context) + .await + .expect("should run auction"); + let bodies = http.recorded_request_bodies(); + assert_eq!( + bodies.len(), + 1 + inline_providers, + "should never transport an empty PBS request" + ); + let aps: serde_json::Value = + serde_json::from_slice(&bodies[0]).expect("should parse APS wire request"); + assert_eq!( + aps["imp"] + .as_array() + .expect("should have impressions") + .len(), + 2 + inline_providers + ); + assert_eq!(result.winning_bids.len(), inline_providers); + for index in 0..inline_providers { + let wire: serde_json::Value = serde_json::from_slice(&bodies[index + 1]) + .expect("should parse PBS wire request"); + assert_eq!( + wire["imp"] + .as_array() + .expect("should have impressions") + .len(), + 1 + ); + assert_eq!(wire["imp"][0]["id"], format!("inline-{index}")); + let prebid = &wire["imp"][0]["ext"]["prebid"]; + assert!(prebid.get("storedrequest").is_none()); + let bidder = if index == 0 { "alpha" } else { "beta" }; + assert_eq!( + prebid["bidder"], + serde_json::json!({bidder:{"placement":index}}) + ); + assert_eq!( + result.winning_bids[&format!("inline-{index}")] + .bid_id + .as_deref(), + Some(format!("bid-{index}").as_str()) + ); + } + } + } + #[tokio::test] async fn planned_prebid_instances_preserve_headers_metadata_suppression_and_identity() { let http = Arc::new(StubHttpClient::new()); diff --git a/crates/trusted-server-core/src/auction/routing.rs b/crates/trusted-server-core/src/auction/routing.rs index 61b2302cc..1b88c7c7a 100644 --- a/crates/trusted-server-core/src/auction/routing.rs +++ b/crates/trusted-server-core/src/auction/routing.rs @@ -13,6 +13,7 @@ use super::types::{AdSlot, AuctionRequest, MediaType}; const TRUSTED_SERVER_ENVELOPE: &str = "trustedServer"; const BIDDER_PARAMS_FIELD: &str = "bidderParams"; const ZONE_FIELD: &str = "zone"; +const STORED_REQUEST_FIELD: &str = "storedRequest"; /// Maximum bidder entries admitted from one browser `bidderParams` envelope. pub(crate) const MAX_BIDDER_ENTRIES: usize = 128; @@ -161,7 +162,7 @@ pub(crate) struct ProviderSlotInput { slot: AdSlot, bidder_params: BTreeMap, prebid_zone: Option, - trusted_stored_request: bool, + stored_request: StoredRequestIntent, } impl ProviderSlotInput { @@ -178,8 +179,14 @@ impl ProviderSlotInput { self.prebid_zone.as_deref() } + #[cfg(test)] pub(crate) fn has_trusted_stored_request(&self) -> bool { - self.trusted_stored_request + self.bidder_params.is_empty() && self.allows_stored_fallback() + } + + pub(crate) fn allows_stored_fallback(&self) -> bool { + self.stored_request + .allows_fallback(!self.bidder_params.is_empty()) } } @@ -244,10 +251,31 @@ impl TrustedProviderRoutes { } } +/// Stored fallback permission, retaining the original legacy admission shape. +#[derive(Debug, Clone, Copy, Default)] +enum StoredRequestIntent { + #[default] + Disabled, + Explicit, + Legacy { + empty_admission: bool, + }, +} + +impl StoredRequestIntent { + fn allows_fallback(self, has_candidates: bool) -> bool { + match self { + Self::Disabled => false, + Self::Explicit => true, + Self::Legacy { empty_admission } => empty_admission || has_candidates, + } + } +} + #[derive(Debug, Default)] struct NormalizedSlotDemand { bidder_params: BTreeMap, - stored_request: bool, + stored_request: StoredRequestIntent, prebid_zone: Option, } @@ -345,12 +373,13 @@ pub(crate) fn route_auction_with_trusted_routes( for (provider_index, builder) in builders.iter_mut().enumerate() { let bidder_params = std::mem::take(&mut routed_params[provider_index]); let trusted_route = trusted_provider_indices.contains(&provider_index); - let trusted_stored_request = - builder.is_prebid && demand.stored_request && bidder_params.is_empty(); - let include = builder.routing == RoutingMode::AllEligible - || !bidder_params.is_empty() - || trusted_stored_request - || trusted_route; + let include = !bidder_params.is_empty() + || trusted_route + || if builder.is_prebid { + demand.stored_request.allows_fallback(false) + } else { + builder.routing == RoutingMode::AllEligible + }; if !include { continue; } @@ -361,7 +390,7 @@ pub(crate) fn route_auction_with_trusted_routes( .is_prebid .then(|| demand.prebid_zone.clone()) .flatten(), - trusted_stored_request, + stored_request: demand.stored_request, }); } } @@ -422,16 +451,26 @@ fn normalize_slot_demand( ) -> NormalizedSlotDemand { if bidders.is_empty() { return NormalizedSlotDemand { - stored_request: true, + stored_request: StoredRequestIntent::Legacy { + empty_admission: true, + }, ..Default::default() }; } - let mut demand = NormalizedSlotDemand::default(); + let mut demand = NormalizedSlotDemand { + stored_request: StoredRequestIntent::Legacy { + empty_admission: false, + }, + ..Default::default() + }; if let Some(envelope) = bidders.get(TRUSTED_SERVER_ENVELOPE) { match normalize_envelope(envelope) { Some(normalized) => demand = normalized, - None => diagnostics.record_malformed_envelope(), + None => { + diagnostics.record_malformed_envelope(); + demand = NormalizedSlotDemand::default(); + } } } @@ -456,10 +495,12 @@ fn normalize_slot_demand( fn normalize_envelope(envelope: &Value) -> Option { let object = envelope.as_object()?; - if object - .keys() - .any(|key| !matches!(key.as_str(), BIDDER_PARAMS_FIELD | ZONE_FIELD)) - { + if object.keys().any(|key| { + !matches!( + key.as_str(), + BIDDER_PARAMS_FIELD | ZONE_FIELD | STORED_REQUEST_FIELD + ) + }) { return None; } let prebid_zone = match object.get(ZONE_FIELD) { @@ -467,28 +508,25 @@ fn normalize_envelope(envelope: &Value) -> Option { Some(Value::String(zone)) if zone.len() <= MAX_PREBID_ZONE_BYTES => Some(zone.clone()), Some(_) => return None, }; - let Some(raw_params) = object.get(BIDDER_PARAMS_FIELD) else { - return Some(NormalizedSlotDemand { - stored_request: true, - prebid_zone, - ..Default::default() - }); + let params = match object.get(BIDDER_PARAMS_FIELD) { + None | Some(Value::Null) => None, + Some(value) => Some(value.as_object()?), }; - if raw_params.is_null() { - return Some(NormalizedSlotDemand { - stored_request: true, - prebid_zone, - ..Default::default() - }); - } - let params = raw_params.as_object()?; - if params.is_empty() { + let stored_request = match object.get(STORED_REQUEST_FIELD) { + None => StoredRequestIntent::Legacy { + empty_admission: params.is_none_or(serde_json::Map::is_empty), + }, + Some(Value::Bool(false)) => StoredRequestIntent::Disabled, + Some(Value::Bool(true)) => StoredRequestIntent::Explicit, + Some(_) => return None, + }; + let Some(params) = params else { return Some(NormalizedSlotDemand { - stored_request: true, + stored_request, prebid_zone, ..Default::default() }); - } + }; if params.len() > MAX_BIDDER_ENTRIES { return None; } @@ -503,7 +541,7 @@ fn normalize_envelope(envelope: &Value) -> Option { } Some(NormalizedSlotDemand { bidder_params, - stored_request: false, + stored_request, prebid_zone, }) } @@ -673,6 +711,123 @@ mod tests { .expect("should find provider input") } + #[test] + fn pbs_admission_respects_intent_without_changing_aps_eligibility() { + for (params, pbs_ids) in [ + (json!({"bidderParams":{}, "storedRequest":false}), vec![]), + ( + json!({"bidderParams":{"unknown":{"id":1}}, "storedRequest":false}), + vec![], + ), + (json!({"bidderParams":{"unknown":{"id":1}}}), vec![]), + ( + json!({"bidderParams":{"alpha":{}}, "storedRequest":false}), + vec!["pbs-a"], + ), + (json!({"bidderParams":{"alpha":{}}}), vec!["pbs-a"]), + ( + json!({"bidderParams":{"alpha":{"id":1}}, "storedRequest":true}), + vec!["pbs-a", "pbs-b"], + ), + ( + json!({"bidderParams":{}, "storedRequest":true}), + vec!["pbs-a", "pbs-b"], + ), + (json!({"bidderParams":{}}), vec!["pbs-a", "pbs-b"]), + ] { + let routed = route_auction( + request(vec![slot(HashMap::from([( + "trustedServer".to_string(), + params, + )]))]), + &inbound(), + &plan(), + None, + ); + let mut expected = vec!["aps-primary"]; + expected.extend(pbs_ids); + assert_eq!( + routed + .inputs() + .iter() + .map(|input| input.provider_id().as_str()) + .collect::>(), + expected + ); + assert_eq!(routed.diagnostics().malformed_envelope_count(), 0); + } + } + + #[test] + fn malformed_stored_intent_rejects_envelope_atomically_but_preserves_direct_demand() { + for intent in [Value::Null, json!("false"), json!(0), json!([]), json!({})] { + for params in [ + None, + Some(Value::Null), + Some(json!({})), + Some(json!({"alpha":{"id":1}})), + ] { + for direct in [false, true] { + let mut value = json!({"storedRequest":intent, "zone":"example-zone"}); + if let Some(params) = params.clone() { + value["bidderParams"] = params; + } + let mut bidders = HashMap::from([("trustedServer".to_string(), value)]); + if direct { + bidders.insert("alpha".to_string(), json!({"direct":1})); + } + let routed = + route_auction(request(vec![slot(bidders)]), &inbound(), &plan(), None); + assert_eq!(routed.diagnostics().malformed_envelope_count(), 1); + assert_eq!(routed.inputs().len(), if direct { 2 } else { 1 }); + assert_eq!(routed.inputs()[0].provider_id().as_str(), "aps-primary"); + if direct { + let slot = &input(&routed, "pbs-a").slots()[0]; + assert!(!slot.allows_stored_fallback()); + assert_eq!(slot.prebid_zone(), None); + assert_eq!( + slot.bidder_params().values().collect::>(), + vec![&json!({"direct":1})] + ); + } + } + } + } + } + + #[test] + fn explicit_stored_intent_and_disabled_inline_are_admitted() { + for (params, expected) in [ + ( + json!({"bidderParams": {}, "storedRequest": true}), + vec!["aps-primary", "pbs-a", "pbs-b"], + ), + ( + json!({"bidderParams": {"alpha": {"placement": 1}}, "storedRequest": false}), + vec!["aps-primary", "pbs-a"], + ), + ] { + let routed = route_auction( + request(vec![slot(HashMap::from([( + "trustedServer".to_string(), + params, + )]))]), + &inbound(), + &plan(), + None, + ); + assert_eq!( + routed + .inputs() + .iter() + .map(|input| input.provider_id().as_str()) + .collect::>(), + expected + ); + assert_eq!(routed.diagnostics().malformed_envelope_count(), 0); + } + } + #[test] fn missing_null_and_empty_envelope_params_fan_out_stored_routes() { let cases = [ diff --git a/crates/trusted-server-js/lib/src/integrations/prebid/index.ts b/crates/trusted-server-js/lib/src/integrations/prebid/index.ts index 0cca444f3..55fe52b2c 100644 --- a/crates/trusted-server-js/lib/src/integrations/prebid/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/prebid/index.ts @@ -119,6 +119,7 @@ const APS_BID_RESPONSE_LISTENER_SENTINEL = '__tsApsBidResponseListenerInstalled' // Keep this range aligned with the signed 32-bit Rust/OpenRTB representation. const MAX_OPENRTB_ATYPE = 2_147_483_647; const BIDDER_PARAMS_KEY = 'bidderParams'; +const STORED_REQUEST_KEY = 'storedRequest'; const ZONE_KEY = 'zone'; const TS_REFRESH_TARGETING_KEYS = [ 'ts_initial', @@ -372,6 +373,7 @@ type TrustedServerAdUnit = { }; type ClientSideBidSnapshot = { bidder: string; params: Record }; type PublisherAdUnitSnapshot = { + storedRequest?: unknown; bidderParams: Record>; clientSideBids: ClientSideBidSnapshot[]; zone?: string; @@ -668,7 +670,15 @@ function foldedBidderParams( ); } -/** Capture immutable request-scoped bidder and zone data before the shim mutates an ad unit. */ +/** Preserve authored presence and values, including invalid values for server validation. */ +function storedRequestParams(bid: TrustedServerBid | undefined): { storedRequest?: unknown } { + if (!bid) return { storedRequest: false }; + return Object.prototype.hasOwnProperty.call(bid.params ?? {}, STORED_REQUEST_KEY) + ? { storedRequest: copyParamValue(bid.params?.[STORED_REQUEST_KEY]) } + : {}; +} + +/** Capture immutable request-scoped demand and zone data before the shim mutates an ad unit. */ function capturePublisherAdUnitSnapshot( unit: TrustedServerAdUnit, serverSideBidders: Set @@ -700,6 +710,7 @@ function capturePublisherAdUnitSnapshot( const zone = unit.mediaTypes?.banner?.name; return { + ...storedRequestParams(existingTsBid), bidderParams, clientSideBids, ...(zone ? { zone } : {}), @@ -786,6 +797,21 @@ function serverSideBidderParamsForRefresh( : {}; } +/** Use the same live-unit authority and snapshot fallback as refresh bidder params. */ +function storedRequestParamsForRefresh(candidateCodes: Array): { + storedRequest?: unknown; +} { + const match = findRefreshAdUnit(candidateCodes); + if (match) { + const bid = Array.isArray(match.bids) + ? match.bids.find((bid) => bid?.bidder === ADAPTER_CODE) + : undefined; + return storedRequestParams(bid); + } + const snapshot = findRefreshSnapshot(candidateCodes); + return snapshot ? storedRequestParams({ params: snapshot }) : { storedRequest: false }; +} + /** Return a live publisher zone, falling back to a request-scoped snapshot. */ function publisherZoneForRefresh(candidateCodes: Array): string | undefined { const match = findRefreshAdUnit(candidateCodes); @@ -1266,6 +1292,7 @@ export function installPrebidNpm(config?: Partial): typeof pbjs bidder: ADAPTER_CODE, params: { [BIDDER_PARAMS_KEY]: bidderParams, + [STORED_REQUEST_KEY]: false, ...(zone ? { [ZONE_KEY]: zone } : {}), }, }); @@ -1454,7 +1481,10 @@ export function installRefreshHandler(timeoutMs = 1500): void { DEFAULT_REFRESH_SIZES, ...(zone ? { name: zone } : {}), }; - const tsParams: Record = zone ? { [ZONE_KEY]: zone } : {}; + const tsParams: Record = { + ...storedRequestParamsForRefresh(candidateCodes), + ...(zone ? { [ZONE_KEY]: zone } : {}), + }; // Carry the publisher's inline server-side (PBS) bidder params captured // on the initial ad unit so refresh/scroll auctions don't drop them. const serverSideParams = serverSideBidderParamsForRefresh(candidateCodes); diff --git a/crates/trusted-server-js/lib/test/core/auction.test.ts b/crates/trusted-server-js/lib/test/core/auction.test.ts index 55dba7bb6..1542202cd 100644 --- a/crates/trusted-server-js/lib/test/core/auction.test.ts +++ b/crates/trusted-server-js/lib/test/core/auction.test.ts @@ -90,6 +90,20 @@ describe('auction/buildAdRequest', () => { expect(unit2!.bids[0].bidder).toBe('openx'); }); + it.each([{}, { storedRequest: false }, { storedRequest: true }, { storedRequest: null }])( + 'retains stored intent presence in serialized shared requests: %j', + (intent) => { + const params = { bidderParams: {}, ...intent }; + for (const input of [ + [{ code: 'example-slot', bids: [{ bidder: 'trustedServer', params }] }], + [{ adUnitCode: 'example-slot', bidder: 'trustedServer', params }], + ]) { + const wire = JSON.parse(JSON.stringify(buildAdRequest(input))); + expect(wire.adUnits[0].bids[0].params).toEqual(params); + } + } + ); + it('handles empty units array', () => { const result = buildAdRequest([]); expect(result.adUnits).toEqual([]); diff --git a/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts b/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts index 0f2906d2b..738090e92 100644 --- a/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts @@ -1211,6 +1211,60 @@ describe('prebid/installPrebidNpm', () => { ); }); + it.each([ + {}, + { storedRequest: true }, + { storedRequest: false }, + { storedRequest: null }, + { storedRequest: 'false' }, + { storedRequest: 0 }, + { storedRequest: [] }, + { storedRequest: { invalid: true } }, + ])('preserves authored stored intent through repeated JSON requests: %j', (intent) => { + const pbjs = installPrebidNpm(); + const unit = { + code: 'example-authored', + bids: [{ bidder: 'trustedServer', params: { bidderParams: {}, ...intent } }], + }; + const spec = mockRegisterBidAdapter.mock.calls[0][2] as TestAdapterSpec; + for (let call = 0; call < 2; call++) { + pbjs.requestBids({ adUnits: [unit] } as unknown as RequestBidsArg); + const wire = JSON.parse( + spec.buildRequests([ + { + adUnitCode: unit.code, + mediaTypes: { banner: { sizes: [[300, 250]] } }, + ...unit.bids[0], + }, + ]).data + ); + expect(wire.adUnits[0].bids[0].params).toEqual({ bidderParams: {}, ...intent }); + } + }); + + it('serializes disabled stored demand on generated envelopes including inline candidates', () => { + const pbjs = installPrebidNpm(); + const adUnits = [ + { code: 'empty', bids: [] }, + { code: 'inline', bids: [{ bidder: 'appnexus', params: { placementId: 1 } }] }, + ]; + for (let call = 0; call < 2; call++) { + pbjs.requestBids({ adUnits } as unknown as RequestBidsArg); + const payload = JSON.parse( + (mockRegisterBidAdapter.mock.calls[0][2] as TestAdapterSpec).buildRequests( + adUnits.map((unit) => ({ + adUnitCode: unit.code, + mediaTypes: { banner: { sizes: [[300, 250]] } }, + ...unit.bids.find((bid: TestBid) => bid.bidder === 'trustedServer'), + })) + ).data + ); + expect( + payload.adUnits.map((unit: { bids: TestBid[] }) => unit.bids[0].params?.storedRequest) + ).toEqual([false, false]); + } + }); + it('injects trustedServer bidder into every ad unit', () => { const pbjs = installPrebidNpm(); @@ -1372,7 +1426,9 @@ describe('prebid/installPrebidNpm', () => { expect(() => pbjs.requestBids({ adUnits } as unknown as RequestBidsArg)).not.toThrow(); - expect(adUnits[0].bids).toEqual([{ bidder: 'trustedServer', params: { bidderParams: {} } }]); + expect(adUnits[0].bids).toEqual([ + { bidder: 'trustedServer', params: { storedRequest: false, bidderParams: {} } }, + ]); }); it('preserves the empty stored-request envelope on initial and repeated requests', () => { @@ -1651,7 +1707,7 @@ describe('prebid/installRefreshHandler', () => { ], }, }, - bids: [{ bidder: 'trustedServer', params: { zone: 'homepage' } }], + bids: [{ bidder: 'trustedServer', params: { storedRequest: false, zone: 'homepage' } }], }), ], }) @@ -1875,6 +1931,7 @@ describe('prebid/installRefreshHandler', () => { { bidder: 'trustedServer', params: { + storedRequest: false, zone: 'homepage', bidderParams: { appnexus: { placementId: 12345 } }, }, @@ -1944,6 +2001,7 @@ describe('prebid/installRefreshHandler', () => { { bidder: 'trustedServer', params: { + storedRequest: false, zone: 'homepage', bidderParams: { appnexus: { placementId: 12345 } }, }, @@ -2091,7 +2149,7 @@ describe('prebid/installRefreshHandler', () => { ], }, }, - bids: [{ bidder: 'trustedServer', params: { zone: 'homepage' } }], + bids: [{ bidder: 'trustedServer', params: { storedRequest: false, zone: 'homepage' } }], }), ], }) @@ -2674,6 +2732,119 @@ describe('prebid publisher snapshots and delivery refreshes', () => { return lastCall?.[0]?.adUnits?.[0]; } + it.each([ + {}, + { storedRequest: true }, + { storedRequest: false }, + { storedRequest: null }, + { storedRequest: 'false' }, + { storedRequest: 1 }, + { storedRequest: [] }, + { storedRequest: { invalid: true } }, + ])( + 'preserves authored intent in snapshot refresh JSON and lets live intent replace it: %j', + (intent) => { + const code = 'example-stored-intent'; + const slot = { + getSlotElementId: () => `${code}-container`, + getTargeting: () => [], + getSizes: () => [[300, 250]], + clearTargeting: vi.fn(), + }; + testWindow.tsjs = { + adSlots: [{ id: 'example', div_id: code, formats: [[300, 250]], targeting: {} }], + }; + const { pubads } = installGpt([slot]); + const pbjs = installPrebidNpm(); + const spec = mockRegisterBidAdapter.mock.calls[0][2] as TestAdapterSpec; + const unit = { + code, + bids: [ + { bidder: 'trustedServer', params: { bidderParams: {}, ...intent } }, + { bidder: 'exampleBrowser', params: { placement: 'browser' } }, + ], + }; + pbjs.requestBids({ adUnits: [unit] } as unknown as RequestBidsArg); + const refreshParams = () => { + pubads.refresh([slot]); + const refresh = refreshAdUnitFromLastRequest(); + expect(refresh.code).toBe(`${code}-container`); + expect(refresh.bids[1]).toEqual({ + bidder: 'exampleBrowser', + params: { placement: 'browser' }, + }); + const wire = JSON.parse( + spec.buildRequests([ + { adUnitCode: refresh.code, mediaTypes: refresh.mediaTypes, ...refresh.bids[0] }, + ]).data + ); + return wire.adUnits[0].bids[0].params; + }; + expect(mockPbjs.adUnits).toEqual([]); + expect(refreshParams()).toEqual({ bidderParams: {}, ...intent }); + expect(refreshParams()).toEqual({ bidderParams: {}, ...intent }); + // Live omission is authoritative, even when a prior snapshot explicitly opted in. + mockPbjs.adUnits = [ + { + code, + bids: [ + { bidder: 'trustedServer', params: { bidderParams: {}, storedRequest: true } }, + unit.bids[1], + ], + }, + ]; + expect(refreshParams()).toEqual({ bidderParams: {}, storedRequest: true }); + mockPbjs.adUnits = [ + { code, bids: [{ bidder: 'trustedServer', params: { bidderParams: {} } }, unit.bids[1]] }, + ]; + expect(refreshParams()).toEqual({ bidderParams: {} }); + mockPbjs.adUnits = [ + { + code, + bids: [ + { bidder: 'trustedServer', params: { bidderParams: {}, storedRequest: false } }, + unit.bids[1], + ], + }, + ]; + expect(refreshParams()).toEqual({ bidderParams: {}, storedRequest: false }); + } + ); + + it('snapshots invalid authored intent immutably and defaults unrecovered refresh JSON to false', () => { + const code = 'example-intent-snapshot'; + const slot = { + getSlotElementId: () => code, + getTargeting: () => [], + getSizes: () => [[300, 250]], + clearTargeting: vi.fn(), + }; + const { pubads } = installGpt([slot]); + const pbjs = installPrebidNpm(); + const spec = mockRegisterBidAdapter.mock.calls[0][2] as TestAdapterSpec; + const readRefresh = () => { + pubads.refresh([slot]); + const refresh = refreshAdUnitFromLastRequest(); + return JSON.parse( + spec.buildRequests([ + { adUnitCode: refresh.code, mediaTypes: refresh.mediaTypes, ...refresh.bids[0] }, + ]).data + ).adUnits[0].bids[0].params; + }; + expect(readRefresh()).toEqual({ bidderParams: {}, storedRequest: false }); + const invalid = { nested: ['original'] }; + pbjs.requestBids({ + adUnits: [ + { + code, + bids: [{ bidder: 'trustedServer', params: { bidderParams: {}, storedRequest: invalid } }], + }, + ], + } as unknown as RequestBidsArg); + invalid.nested[0] = 'mutated'; + expect(readRefresh()).toEqual({ bidderParams: {}, storedRequest: { nested: ['original'] } }); + }); + function completePublisherAuction( opts?: { adUnits?: Array<{ code?: string }>; bidsBackHandler?: (...args: unknown[]) => void }, options: { auctionId?: string; applyTargeting?: boolean } = {} @@ -3042,6 +3213,7 @@ describe('prebid publisher snapshots and delivery refreshes', () => { { bidder: 'trustedServer', params: { + storedRequest: false, bidderParams: { exampleServer: { placement: 'effective' } }, zone: 'example-zone', }, @@ -3082,7 +3254,7 @@ describe('prebid publisher snapshots and delivery refreshes', () => { expect(refreshAdUnitFromLastRequest().bids).toEqual([ { bidder: 'trustedServer', - params: { bidderParams: { exampleServer: { placement: 'server' } } }, + params: { storedRequest: false, bidderParams: { exampleServer: { placement: 'server' } } }, }, { bidder: 'publisherBrowserBidder', params: { placement: 'browser' } }, ]); @@ -3133,6 +3305,7 @@ describe('prebid publisher snapshots and delivery refreshes', () => { { bidder: 'trustedServer', params: { + storedRequest: false, bidderParams: { exampleServer: { placement: { @@ -3182,12 +3355,14 @@ describe('prebid publisher snapshots and delivery refreshes', () => { } as unknown as RequestBidsArg); pubads.refresh([slot]); expect(refreshAdUnitFromLastRequest().bids[0].params).toEqual({ + storedRequest: false, bidderParams: { exampleServer: { placement: 'one' } }, zone: 'example-zone-one', }); pubads.refresh([slot]); expect(refreshAdUnitFromLastRequest().bids[0].params).toEqual({ + storedRequest: false, bidderParams: { exampleServer: { placement: 'one' } }, zone: 'example-zone-one', }); @@ -3204,6 +3379,7 @@ describe('prebid publisher snapshots and delivery refreshes', () => { pubads.refresh([slot]); expect(refreshAdUnitFromLastRequest().bids[0].params).toEqual({ + storedRequest: false, bidderParams: { exampleServer: { placement: 'two' } }, zone: 'example-zone-two', }); @@ -3294,7 +3470,10 @@ describe('prebid publisher snapshots and delivery refreshes', () => { expect(refreshAdUnitFromLastRequest().bids).toEqual([ { bidder: 'trustedServer', - params: { bidderParams: { exampleServer: { placement: 'live-server' } } }, + params: { + storedRequest: false, + bidderParams: { exampleServer: { placement: 'live-server' } }, + }, }, { bidder: 'exampleBrowser', params: { placement: 'live-browser' } }, ]); @@ -3363,7 +3542,7 @@ describe('prebid publisher snapshots and delivery refreshes', () => { pubads.refresh([slot]); expect(refreshAdUnitFromLastRequest().bids).toEqual([ - { bidder: 'trustedServer', params: { bidderParams: {} } }, + { bidder: 'trustedServer', params: { storedRequest: false, bidderParams: {} } }, ]); }); @@ -3392,9 +3571,15 @@ describe('prebid publisher snapshots and delivery refreshes', () => { ]); pubads.refresh([slots[0]]); - expect(refreshAdUnitFromLastRequest().bids[0].params).toEqual({ bidderParams: {} }); + expect(refreshAdUnitFromLastRequest().bids[0].params).toEqual({ + storedRequest: false, + bidderParams: {}, + }); pubads.refresh([slots[1]]); - expect(refreshAdUnitFromLastRequest().bids[0].params).toEqual({ bidderParams: {} }); + expect(refreshAdUnitFromLastRequest().bids[0].params).toEqual({ + storedRequest: false, + bidderParams: {}, + }); pubads.refresh([slots[2]]); expect(refreshAdUnitFromLastRequest().bids[0].params.bidderParams).toEqual({ exampleServer: { placement: codes[2] }, @@ -3402,7 +3587,10 @@ describe('prebid publisher snapshots and delivery refreshes', () => { (pbjs as unknown as { removeAdUnit: (adUnitCode?: string | string[]) => void }).removeAdUnit(); pubads.refresh([slots[2]]); - expect(refreshAdUnitFromLastRequest().bids[0].params).toEqual({ bidderParams: {} }); + expect(refreshAdUnitFromLastRequest().bids[0].params).toEqual({ + storedRequest: false, + bidderParams: {}, + }); }); it('bounds snapshots with LRU eviction while retaining a recently refreshed entry', () => { @@ -3446,7 +3634,10 @@ describe('prebid publisher snapshots and delivery refreshes', () => { } as unknown as RequestBidsArg); pubads.refresh([oldestSlot]); - expect(refreshAdUnitFromLastRequest().bids[0].params).toEqual({ bidderParams: {} }); + expect(refreshAdUnitFromLastRequest().bids[0].params).toEqual({ + storedRequest: false, + bidderParams: {}, + }); pubads.refresh([activeSlot]); expect(refreshAdUnitFromLastRequest().bids[0].params.bidderParams).toEqual({ exampleServer: { placement: capacity - 1 }, diff --git a/crates/trusted-server-js/lib/test/prebid-artifact-integration.test.mjs b/crates/trusted-server-js/lib/test/prebid-artifact-integration.test.mjs index 6b38d93d2..f5c7d9b14 100644 --- a/crates/trusted-server-js/lib/test/prebid-artifact-integration.test.mjs +++ b/crates/trusted-server-js/lib/test/prebid-artifact-integration.test.mjs @@ -192,6 +192,11 @@ describe('external bundle + served shim evaluated together', () => { }, ], }, + { + code: 'ad-slot-2', + mediaTypes: { banner: { sizes: [[300, 250]] } }, + bids: [], + }, ], timeout: 1000, }); @@ -221,6 +226,9 @@ describe('external bundle + served shim evaluated together', () => { // codes; provider IDs and returned aliases cannot reach /auction. const trustedServerBid = adUnit.bids.find((bid) => bid.bidder === 'trustedServer'); expect(trustedServerBid.params.bidderParams).toEqual({ appnexus: { placementId: 1 } }); + expect(trustedServerBid.params).not.toHaveProperty('storedRequest'); + const generated = payload.adUnits.find((unit) => unit.code === 'ad-slot-2'); + expect(generated.bids[0].params).toEqual({ bidderParams: {}, storedRequest: false }); dom.window.close(); }, 60_000); diff --git a/docs/guide/api-reference.md b/docs/guide/api-reference.md index 56306e364..de1d73089 100644 --- a/docs/guide/api-reference.md +++ b/docs/guide/api-reference.md @@ -121,6 +121,61 @@ curl -X POST https://edge.example.com/auction \ -d '{"adUnits":[{"code":"banner","mediaTypes":{"banner":{"sizes":[[300,250]]}}}]}' ``` +#### PBS stored-request intent + +The reserved `trustedServer` bid accepts `storedRequest` inside `params`, beside +`bidderParams` and `zone`. It does not accept provider IDs, endpoints, or stored IDs. +Bidder keys still resolve through the server's `[auction.bidders]` routes. + +| `params.storedRequest` | PBS behavior | +| ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `false` | Disable stored fallback for this slot on every PBS provider. Usable inline demand still runs. | +| `true` | Permit stored fallback using the slot `code` as the stored impression ID. Usable inline params take precedence within each provider. | +| Omitted | Preserve legacy inference for existing callers and server-generated opportunities. Missing, `null`, or empty `bidderParams` permits stored demand; routed empty bidder objects also retain fallback after overrides. | + +`storedRequest: null` is invalid, as are strings, numbers, arrays, and objects. +An invalid value rejects the whole envelope, including its inline params and zone, +and increments the malformed-envelope diagnostic. Independent valid direct bidder +entries and eligible non-PBS providers still run; this is not whole-request HTTP +rejection. An absent or empty `bids` list also retains legacy stored inference. + +PBS applies provider-local overrides before checking inline demand. If no usable +inline params remain, it uses stored demand only when permitted, otherwise it +omits the impression. If none remain, it makes no PBS request. Eligible APS and +standard providers are unaffected. + +A generated envelope that should not request PBS stored demand: + +```json +{ + "bidder": "trustedServer", + "params": { "bidderParams": {}, "storedRequest": false } +} +``` + +An intentional stored request, posted to `https://edge.example.com/auction`: + +```json +{ + "adUnits": [ + { + "code": "homepage-banner", + "mediaTypes": { "banner": { "sizes": [[728, 90]] } }, + "bids": [ + { + "bidder": "trustedServer", + "params": { "bidderParams": {}, "storedRequest": true } + } + ] + } + ] +} +``` + +Stored demand retains existing PBS fanout. Each participating PBS instance must +have the requested slot-code ID. This filtering does not prevent errors from +unrelated invalid bidder params. See [deployment ordering](/guide/integrations/prebid#stored-intent-deployment). + --- ## Edge Cookie Endpoints diff --git a/docs/guide/auction-orchestration.md b/docs/guide/auction-orchestration.md index 2fc33796a..edbc38471 100644 --- a/docs/guide/auction-orchestration.md +++ b/docs/guide/auction-orchestration.md @@ -774,6 +774,22 @@ centrally routed or trusted stored-request demand. The `prebid-server` profile rejects `all_eligible` because PBS requires bidder or stored-request demand on each impression. +For PBS, `trustedServer.params.storedRequest` controls stored fallback, not +provider selection. `false` suppresses stored demand on every PBS instance; +`true` permits it; omission retains legacy empty-envelope and routed-empty-param +fallback. Invalid intent, including `null`, rejects the envelope atomically. +After provider-local overrides, usable inline params win. Otherwise only permitted +stored demand becomes `imp.ext.prebid.storedrequest.id`, using the slot code. +Demandless impressions are omitted, and a provider with no remaining impressions +makes no transport request. Server-owned explicit routes remain internal and do +not authorize demandless PBS wire impressions. APS and standard `all_eligible` +participation is unchanged. Intentional and legacy stored demand still fan out to +PBS instances and can fail if a slot-code ID is absent there. + +New TSJS envelopes explicitly disable stored fallback, while publisher-authored +omission remains compatible. Follow the [server-first deployment sequence](/guide/integrations/prebid#stored-intent-deployment) +before serving new JS; Rust builds embed those bundles. + Provider IDs must match `^[a-z][a-z0-9-]{0,62}$`. Bidder IDs are limited to 128 UTF-8 bytes and cannot be the exact reserved browser envelope ID `trustedServer`. Static standard-profile `request_ext` and `imp_ext` objects diff --git a/docs/guide/integrations/prebid.md b/docs/guide/integrations/prebid.md index dfc8ddb79..7331b3323 100644 --- a/docs/guide/integrations/prebid.md +++ b/docs/guide/integrations/prebid.md @@ -124,20 +124,57 @@ The typed `profile_config` fields are: Every server-side bidder code comes from `[auction.bidders.]`; the browser integration has no server bidder list. The validated route keys are injected as `serverSideBidders`. On initial and refresh auctions, only matching publisher -bids are folded into the `trustedServer.bidderParams` envelope. Configured +bids are folded into the `trustedServer.params.bidderParams` envelope. Configured `client_side_bidders` and other unowned demand remain native browser bids. Both paths compete in the same Prebid.js auction. The reserved `trustedServer` envelope cannot select a provider or endpoint. Its nested bidder keys resolve through `[auction.bidders]`, and one envelope accepts at most 128 bidder entries. The optional `zone` fact is limited to 256 UTF-8 -bytes. Missing, `null`, or empty `bidderParams` invokes Prebid stored-request -routing; malformed envelopes do not. +bytes. `params.storedRequest` controls PBS stored fallback: `false` disables it, +`true` permits it, and omission preserves legacy inference. `null` and non-boolean +values are invalid and reject the complete envelope. Usable inline params take +precedence after provider-local overrides. See the [wire contract](/guide/api-reference#pbs-stored-request-intent). + +TSJS sets `storedRequest: false` on every newly synthesized envelope, including +those carrying inline candidates. Existing publisher-authored envelopes retain +`true`, `false`, or omission on repeated requests. Refresh uses live ad-unit intent +when available and an immutable request snapshot otherwise, using the same code +and container-ID lookup as bidder params. Invalid authored values remain intact +for server validation. An unrecovered refresh defaults to `false`; it does not +invent a PBS stored lookup just to invoke eligible APS or standard providers. Browser `timeout_ms`/`debug` never inherit a server provider timeout or profile debug value. Enabling the browser integration does not create a server provider, and a `prebid-server` provider can exist independently from browser injection. +### Stored intent deployment + +Deploy server admission support before distributing the TSJS bundle that emits +`storedRequest`. The pre-fix configuration-first router from #1016 rejects unknown +envelope fields. Sending new JS to an old server can discard valid inline envelope +demand as well as fail to solve the unwanted stored lookup. + +Rust artifacts embed the generated JS bundles through +`crates/trusted-server-js/build.rs` and `include_str!`. A normal full build couples +server support and JS emission; they are not independently published artifacts. + +1. Prepare a server-support-only build that retains the old JS emission behavior. + Deploy it to every serving instance and verify admission of `true` and `false` + alongside valid inline demand. +2. Only after that verification, distribute the full build containing the new JS. + Account for serving instances, browser caches, and any external bundle that + still embeds an older shim. If the release process cannot produce the first + build, resolve that release mechanism before rollout. +3. After new JS reaches browsers or caches, do not roll back to a server that + rejects `storedRequest`. Keep compatible admission until those clients can + safely be served again. Rolling back JS alone does not remove cached clients. + +Omission remains supported in this change. Removing legacy inference requires a +separate migration: inventory direct callers and server-generated opportunities, +migrate them to explicit intent, account for cached clients, and approve a +versioned contract change. There is no time-based expiry in this fix. + ## External Bundle Generation Use `ts prebid bundle` to build the publisher-specific browser bundle from diff --git a/docs/superpowers/plans/2026-09-10-pbs-stored-request-intent.md b/docs/superpowers/plans/2026-09-10-pbs-stored-request-intent.md new file mode 100644 index 000000000..27245615b --- /dev/null +++ b/docs/superpowers/plans/2026-09-10-pbs-stored-request-intent.md @@ -0,0 +1,264 @@ +# PBS stored-request intent implementation plan + +Status: Implemented and verified. All applicable Task 6 gates passed; independent review found no correctness issues. Deployment has not been performed. + +Issue: [IABTechLab/trusted-server#1086](https://github.com/IABTechLab/trusted-server/issues/1086) + +Goal: Let TSJS invoke non-PBS all-eligible providers without inventing PBS stored demand. A slot without usable inline PBS params or permitted stored demand must not enter the outbound PBS request or invalidate valid sibling impressions. + +## Baseline and scope + +This plan follows the configuration-driven provider implementation merged in [#1016](https://github.com/IABTechLab/trusted-server/pull/1016). Source review used `origin/main` at `d704d0ab0c5916d429b80a5707c0f2d74b99cab1`. + +The parent fast-forwarded `fix/pbs-stored-requests` to `d704d0ab` before implementation and preserved this plan as an untracked file. Initial `git status --short` showed only this plan. No unrelated changes were present. + +Scope correction approved during implementation: the baseline compiler already rejects PBS `all_eligible` in `auction/plan.rs`. Do not enable it or mutate compiled plans for tests. Runtime PBS tests use supported explicit routing and server-owned trusted routes. The existing `compiler_rejects_all_eligible_for_prebid_server_only` regression covers that boundary; APS/standard all-eligible behavior remains supported. + +Keep the change limited to intent admission, provider routing, PBS request construction, TSJS envelope generation and refresh state, and their tests and documentation. No dependency, adapter, provider-configuration, or endpoint changes are expected. + +Explicit stored IDs, browser-selected provider IDs, revised stored-demand fanout, and removal of legacy inference are out of scope. + +## Contract + +The field lives inside `trustedServer.params`, beside `bidderParams` and `zone`: + +```json +{ + "bidder": "trustedServer", + "params": { + "bidderParams": {}, + "storedRequest": false + } +} +``` + +- `false` disables stored-request fallback for every PBS provider for this slot. It does not disable valid inline demand or eligible APS/standard providers. +- `true` explicitly permits stored-request fallback. Usable inline params still take precedence within each PBS provider. If overrides leave no usable inline params, the provider may use the slot code as its stored impression ID. +- Omission retains legacy behavior for existing direct `/auction` callers and server-generated opportunities. Preserve both empty-envelope stored inference and the existing fallback when routed empty bidder objects remain unusable after overrides. +- Explicit `null`, strings, numbers, arrays, and objects are invalid values. Do not deserialize through plain `Option` or use `as_bool().unwrap_or(...)`, which would conflate invalid values with omission. +- Invalid intent rejects the complete envelope atomically, including its inline params and zone. Increment the existing malformed-envelope diagnostic. Do not reinterpret rejection as missing demand. Valid direct sibling bidder entries and non-PBS all-eligible participation retain their existing behavior; this does not introduce whole-request HTTP rejection. +- Browser input still cannot name provider routes. Intent grants no new provider-selection authority. Explicit and legacy stored demand retain existing PBS fanout for this fix. + +Internally, introduce a narrow `StoredRequestIntent` enum with `Disabled`, `Explicit`, and `Legacy` states. Retain the distinction through provider-local request construction. Legacy inference depends on the original admitted shape, not merely the map left after route filtering. If needed, carry that admission fact as a payload of `Legacy`, rather than adding independently mutable flags that must agree with the enum. Existing routed candidate params remain necessary for legacy post-override fallback. + +A malformed envelope must normalize to no stored permission, not a default `Legacy` state. Keep parsing and validation in `auction/routing.rs` rather than repeatedly inspecting raw JSON in providers. + +## Demand flow + +```mermaid +flowchart TD + A[Validate trustedServer envelope] --> B{Valid envelope?} + B -->|No| C[Reject envelope demand and record diagnostic] + B -->|Yes| D[Normalize intent and bidder params] + C --> E[Route valid sibling demand independently] + D --> E + E --> F{Provider profile} + F -->|Non-PBS| G[Preserve explicit and all-eligible routing] + F -->|PBS| H[Admit inline candidates or permitted stored demand] + H --> I[Apply provider-local bidder overrides] + I --> J{Usable inline params remain?} + J -->|Yes| K[Emit inline PBS impression] + J -->|No| L{Stored fallback permitted?} + L -->|Yes| M[Emit stored impression using slot code] + L -->|No| N[Omit impression] + K --> O[Check final impression count before signing and transport] + M --> O + N --> O +``` + +## Target files + +Paths below are relative to the repository root. + +- `crates/trusted-server-core/src/auction/routing.rs`: intent normalization, legacy admission facts, provider-local intent, PBS admission rules, and routing tests. +- `crates/trusted-server-core/src/auction/openrtb.rs`: post-override demand decisions, impression omission, and final empty-request handling. +- `crates/trusted-server-core/src/auction/openrtb/tests.rs`: serialized request and override regressions. +- `crates/trusted-server-core/src/auction/orchestrator.rs`: transport-level mixed-slot and no-request regressions using existing test support. +- `crates/trusted-server-core/src/auction/formats.rs`: wire-to-auction regression coverage and accurate request documentation. Avoid adding a second envelope parser. +- `crates/trusted-server-core/src/creative_opportunities.rs`: preserve and test server-generated stored demand and zone behavior; change production generation only if required by the new internal model. +- `crates/trusted-server-js/lib/src/integrations/prebid/index.ts`: synthetic envelope defaults, publisher intent preservation, immutable snapshots, and refresh reconstruction. +- `crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts`: initial, repeated, and refresh auction payload tests. +- `crates/trusted-server-js/lib/test/core/auction.test.ts`: prove shared serialization retains the boolean without changing `core/auction.ts` unless a test exposes a need. +- `crates/trusted-server-js/lib/test/prebid-artifact-integration.test.mjs`: built-adapter payload expectations. +- `docs/guide/api-reference.md`, `docs/guide/integrations/prebid.md`, and `docs/guide/auction-orchestration.md`: intent semantics, examples, and deployment compatibility. +- `CHANGELOG.md`: concise unreleased fix entry. + +## Task 1: Establish the baseline and failing evidence + +- [x] Complete the approved branch update and inspect `git status`. +- [x] Run the current routing, PBS builder, and Prebid JS test groups before changing production code. Record environmental failures separately from product failures. +- [x] Add focused regressions in small steps, running each before its production fix. Do not bulk-update snapshots to conceal changed demand. +- [x] First prove that TSJS should serialize `storedRequest: false` for a generated all-eligible envelope but currently omits it. +- [x] Prove that an explicit `true` empty envelope should retain PBS demand and a `false` envelope with valid inline params should retain those params. The current unknown-field validator rejects both, so these distinguish missing support from a working fix. +- [x] Preserve existing omitted-field tests as compatibility evidence. A `false` empty-envelope skip test alone is insufficient: the old validator already rejects the unknown field and can make that assertion pass for the wrong reason. + +Focused commands: + +```bash +cargo test-fastly auction::routing::tests +cargo test-fastly auction::openrtb::tests +( + cd crates/trusted-server-js/lib + npx vitest run test/integrations/prebid/index.test.ts test/core/auction.test.ts +) +``` + +Acceptance: saved command output identifies at least one relevant failing Rust assertion and one failing JS serialization assertion before their production changes. + +## Task 2: Normalize intent and enforce PBS routing + +- [x] Add strict optional-field parsing to the existing envelope allowlist and atomic validator. Validate intent before returning from any missing, null, or empty `bidderParams` branch. +- [x] Introduce the internal intent type and carry the required legacy admission facts into `ProviderSlotInput`. +- [x] For PBS, route a slot only when it has assigned inline candidates or permitted stored demand. `AllEligible` alone cannot override this rule, including for malformed envelopes. +- [x] Keep non-PBS routing unchanged. Preserve existing server-owned route handling without exposing it through the browser envelope; a trusted route alone must not cause a demandless PBS wire impression. +- [x] Retain empty object candidates for configured envelope bidders until provider overrides run. Do not treat an empty object as usable inline demand at the final wire boundary. +- [x] Do not turn unconfigured bidders removed by plan filtering into new stored demand when intent is disabled. +- [x] Test two explicit PBS providers plus APS, retaining compiler rejection of all-eligible PBS configurations, empty envelopes, empty bidder objects, unconfigured bidders, and mixed inline ownership. +- [x] Test every malformed intent type, a malformed envelope containing valid-looking inline params, and preservation of independent valid direct demand. Assert diagnostics and absence of stored fallback. +- [x] Retain omitted-field direct `/auction` conversion coverage and the server-generated creative-opportunity stored-request/zone regression. + +Run the routing and format test groups after each focused change: + +```bash +cargo test-fastly auction::routing::tests +cargo test-fastly auction::formats::tests +cargo test-fastly creative_opportunity_canonical_slot_feeds_shared_stored_router_with_zone +``` + +Acceptance: `false` suppresses PBS providers without candidate demand while APS remains eligible. Explicit and legacy demand retain their documented routes. + +## Task 3: Enforce intent after overrides and prove outbound requests + +The current `apply_prebid` fallback uses `has_trusted_stored_request() || !slot.bidder_params().is_empty()`. Updating the router alone leaves this second source of stored inference intact. + +- [x] First add and run a builder regression for `false` with a configured bidder whose params remain `{}` after overrides. At this stage it must expose the remaining fallback bug. +- [x] Apply overrides before deciding whether inline params are usable. Preserve the positive case where an override fills an empty object. +- [x] Replace implicit stored inference with the provider-local intent policy. Emit inline demand first; otherwise emit stored demand only when allowed; otherwise omit the impression. +- [x] Preserve slot/impression pairing while filtering. Do not remove impressions and then zip the shortened list with the original slots, which could attach another slot's params or stored ID. +- [x] Check for `NoImpressions` after profile augmentation, before final signing and transport. The existing pre-augmentation check is insufficient once PBS can drop impressions. +- [x] Keep omitted-intent empty-candidate fallback covered. Add `true` with inline params, `true` with unusable post-override params, and `false` with override-populated params. +- [x] Using the existing deterministic executor/orchestrator test support, capture an actual serialized PBS transport request containing a valid inline slot beside a synthetic no-PBS slot. Assert only the valid impression is sent, no stored reference appears for the omitted slot, and the valid bid survives. +- [x] Repeat the mixed routing case across two PBS instances and APS. Assert no-demand PBS providers make zero requests and APS still runs. +- [x] Test an explicit PBS request whose last candidate disappears after overrides. All-eligible PBS is rejected by the compiler and is not a reachable runtime case. Assert zero transport calls, not a serialized empty `imp` array or a debug assertion failure. + +```bash +cargo test-fastly auction::openrtb::tests +cargo test-fastly auction::orchestrator::tests +``` + +Acceptance: evidence observes both the final wire payload and the absence of transport for empty requests. No live PBS service is required to prove the offending impression is absent. Do not claim this prevents HTTP 400 responses caused by unrelated invalid bidder params. + +## Task 4: Emit and preserve browser intent + +- [x] Add `storedRequest: false` to every newly generated TSJS envelope without publisher-supplied stored intent, including envelopes currently carrying inline candidates. Client-side filtering cannot predict the final server-local demand after routing and overrides. +- [x] Preserve a publisher-authored existing envelope's `true`, `false`, or omitted state during ordinary `requestBids` reuse. Do not rewrite legacy publisher intent merely because its bidder map is empty. +- [x] Extend the request-scoped snapshot with stored intent and preserve field presence. Do not coerce invalid authored values into omission or `false`; leave server validation authoritative. +- [x] Recover intent during synthetic refresh using the same live-ad-unit authority and snapshot fallback as bidder params. Distinguish a publisher-authored omitted legacy envelope from no recovered envelope, which needs the synthetic `false` default. +- [x] Keep intent attached to the slot when code aliases such as container IDs are used. Reuse existing refresh lookup rules rather than adding another matching policy. +- [x] Cover initial empty envelopes, repeated calls on mutated ad units, synthetic refresh without recovered demand, publisher `true` and `false`, legacy omission, live data replacing a stale snapshot, snapshot-only recovery, and client-side bidder preservation. +- [x] Assert actual JSON request bodies through `buildRequests`/`buildAdRequest`, not just intermediate objects. Update the built-artifact expectation as well. + +```bash +( + cd crates/trusted-server-js/lib + npx vitest run test/integrations/prebid/index.test.ts test/core/auction.test.ts + npx vitest run test/prebid-artifact-integration.test.mjs + node build-all.mjs +) +``` + +Acceptance: TSJS-generated no-stored-demand envelopes remain explicitly disabled on initial and refresh requests, while authored explicit and legacy stored demand are preserved. + +## Task 5: Document and sequence deployment + +- [x] Document field location, all three valid wire states, invalid `null`, inline-first behavior, post-override fallback, and unchanged server-owned routing authority. +- [x] Show separate examples for synthetic no-PBS demand and intentional stored demand using `example.com` data. +- [x] Update comments that equate every empty bidder map with a stored request. +- [x] Separate server support from TSJS emission in the delivery sequence. Deploy and verify server support on all serving instances before distributing the new JS bundle. If publishing automatically couples the artifacts, resolve the release mechanism before rollout rather than assuming staging is possible. +- [x] Document that the pre-fix #1016 router rejects unknown envelope fields. Early JS deployment can discard valid inline envelope demand, not just retain the original stored-lookup bug. +- [x] Document rollback ordering: after new JS has reached browsers or caches, do not roll back to a server that rejects `storedRequest`. Keep compatible server admission until old clients can safely be served again. +- [x] Keep omission supported in this change. Record removal criteria for a separate migration: inventory direct callers and server-generated paths, migrate them to explicit intent, account for cached clients, and approve a versioned contract change. Do not add an arbitrary expiry or a new telemetry system here. + +## Task 6: Full verification and handoff + +Shared core changes affect every adapter. Run the repository's applicable gates before PR handoff: + +```bash +cargo test-fastly +cargo test-axum +cargo test-cloudflare +cargo test-spin +./scripts/test-cli.sh +cargo fmt --all -- --check +cargo clippy-fastly +cargo clippy-axum +cargo clippy-cloudflare +cargo clippy-cloudflare-wasm +cargo clippy-spin-native +cargo clippy-spin-wasm +( + cd crates/trusted-server-js/lib + npx vitest run + npm run lint + npm run format + node build-all.mjs +) +( + cd docs + npm run format + npm run build +) +git diff --check +git status --short +``` + +- [x] Inspect the final diff against this plan. Preserve unrelated edits and avoid broad formatting churn. +- [x] Report changed files, failing-before and passing-after commands, full gate results, outbound payload/zero-I/O evidence, and any unavailable runtime checks. +- [x] Confirm no browser provider selector, explicit stored ID feature, legacy-removal change, or unrelated adapter refactor entered the patch. +- [x] Keep remaining risks explicit: legacy omitted callers can still infer stored demand; intentional stored requests still fan out and may use nonexistent slot-code IDs; the release depends on server-first deployment. + +### Focused implementation evidence + +Logs are saved under `/tmp/pbs-intent/`. + +- Baselines passed: routing, OpenRTB builder, and the Prebid/shared JS test groups. +- `red-routing-assertion.log` records the old validator rejecting explicit stored intent, with only APS routed rather than APS plus both PBS instances. +- `red-js.log` records serialized generated intent as `[undefined, undefined]` instead of `[false, false]`. +- `red-post-override.log` records the remaining fallback bug after the router fix: disabled empty candidates still produced a stored impression instead of `NoImpressions`. +- Routing, builder, format conversion, creative-opportunity stored/zone compatibility, compiler boundary, orchestrator, and JS serialization/refresh groups passed after the changes. The transport test captures one or two valid PBS wire requests beside APS and observes zero PBS calls when all PBS candidates are unusable. +- Built-artifact integration and `node build-all.mjs` passed. Source changes did not require a shared serializer or creative-opportunity generator change. +- Early local failures were test setup issues, not environment blockers: a JS helper was scoped to a sibling suite; the first Rust rerun briefly hid a still-used method behind `cfg(test)`; a planned PBS AllEligible fixture hit the pre-existing compiler rejection. These were corrected without changing dependencies or configuration. The meaningful red assertions above were then captured independently. + +Final focused results: 16 routing, 29 OpenRTB, 33 formats, 77 orchestrator, one creative-opportunity compatibility, and one PBS configuration-boundary test passed. The combined JS source/shared/built-artifact group passed 191 tests. + +### Full verification results + +All Task 6 commands above were executed. Logs and exact command metadata are under `/tmp/pbs-task6-gates/`. + +- `cargo test-fastly`: 2,886 passed, 10 explicitly ignored, including doctests; executed through Viceroy. +- `cargo test-axum`: 41 passed. `cargo test-cloudflare`: 44 passed. `cargo test-spin`: 86 passed. `./scripts/test-cli.sh`: 87 passed. +- `npx vitest run`: 923 tests passed across 45 files, with no type errors. JS lint, formatting, and `node build-all.mjs` passed. +- Rust formatting, documentation formatting/build, and `git diff --check` passed. +- All six clippy aliases initially failed on the new parser's redundant closure. The parent replaced it with `serde_json::Map::is_empty` and corrected one new test URI to `publisher.example.com`. All six clippy aliases, Rust formatting, the 16 routing tests, the 29 OpenRTB tests, and diff checks passed afterward. The full test suites ran before these two mechanical edits and were not repeated in full. +- Independent read-only correctness review inspected the diff and reran focused Rust and JS tests. It found no correctness issues. Parent inspection confirmed the result and the two subsequent mechanical edits. +- No live PBS or deployed-adapter check was run. Transport tests establish that the offending impressions are absent, not that all possible PBS HTTP 400 responses are prevented. +- Documentation dependency installation used the existing lockfile and reported 17 vulnerabilities. No dependency changes were made. Documentation build warnings about `vcl` highlighting and bundle size were non-blocking. + +Actual changed files: + +- `crates/trusted-server-core/src/auction/routing.rs` +- `crates/trusted-server-core/src/auction/openrtb.rs` +- `crates/trusted-server-core/src/auction/openrtb/tests.rs` +- `crates/trusted-server-core/src/auction/orchestrator.rs` +- `crates/trusted-server-core/src/auction/formats.rs` +- `crates/trusted-server-js/lib/src/integrations/prebid/index.ts` +- `crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts` +- `crates/trusted-server-js/lib/test/core/auction.test.ts` +- `crates/trusted-server-js/lib/test/prebid-artifact-integration.test.mjs` +- `docs/guide/api-reference.md` +- `docs/guide/integrations/prebid.md` +- `docs/guide/auction-orchestration.md` +- `CHANGELOG.md` +- `docs/superpowers/plans/2026-09-10-pbs-stored-request-intent.md` + +Remaining risks are the preserved legacy inference and stored-demand fanout, unavailable slot-code stored IDs, and server-first release ordering. The Rust artifact embeds JS, so deployment requires a server-support-only build retaining old JS before the full build. Verification did not change dependencies or perform deployment.