From a17c9b6d9939753b4ddda7fef0f7c56b7332c05b Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Fri, 3 Apr 2026 21:43:37 +0530 Subject: [PATCH 01/13] Feat generaic prebid bidder param overrides --- .env.example | 1 + .../src/integrations/prebid.rs | 113 ++++++++++++++++-- crates/trusted-server-core/src/settings.rs | 50 ++++++++ trusted-server.toml | 8 +- 4 files changed, 162 insertions(+), 10 deletions(-) diff --git a/.env.example b/.env.example index c62b4a25..af712367 100644 --- a/.env.example +++ b/.env.example @@ -40,6 +40,7 @@ TRUSTED_SERVER__INTEGRATIONS__PREBID__ENABLED=false # TRUSTED_SERVER__INTEGRATIONS__PREBID__SERVER_URL=https://prebid-server.com/openrtb2/auction # TRUSTED_SERVER__INTEGRATIONS__PREBID__TIMEOUT_MS=1000 # TRUSTED_SERVER__INTEGRATIONS__PREBID__BIDDERS=kargo,rubicon,appnexus +# TRUSTED_SERVER__INTEGRATIONS__PREBID__BIDDER_PARAM_OVERRIDES='{"criteo":{"networkId":112141,"pubid":"112141"}}' # TRUSTED_SERVER__INTEGRATIONS__PREBID__AUTO_CONFIGURE=false # TRUSTED_SERVER__INTEGRATIONS__PREBID__DEBUG=false # TRUSTED_SERVER__INTEGRATIONS__PREBID__TEST_MODE=false diff --git a/crates/trusted-server-core/src/integrations/prebid.rs b/crates/trusted-server-core/src/integrations/prebid.rs index 1ced7059..cfa08b94 100644 --- a/crates/trusted-server-core/src/integrations/prebid.rs +++ b/crates/trusted-server-core/src/integrations/prebid.rs @@ -111,6 +111,26 @@ pub struct PrebidIntegrationConfig { /// ``` #[serde(default)] pub bid_param_zone_overrides: HashMap>, + /// Static per-bidder parameter overrides merged into every outgoing + /// `OpenRTB` imp `ext.prebid.bidder.{bidder}` object before zone-specific + /// overrides are applied. + /// + /// These are useful when server-side bidder IDs should be enforced from + /// Trusted Server regardless of the page's client-side Prebid config. + /// + /// Example in TOML: + /// ```toml + /// [integrations.prebid.bidder_param_overrides.criteo] + /// networkId = 112141 + /// pubid = "112141" + /// ``` + /// + /// Example via environment variable: + /// ```text + /// TRUSTED_SERVER__INTEGRATIONS__PREBID__BIDDER_PARAM_OVERRIDES='{"criteo":{"networkId":112141,"pubid":"112141"}}' + /// ``` + #[serde(default)] + pub bidder_param_overrides: HashMap, /// How consent signals are forwarded to Prebid Server. /// /// - `openrtb_only` — consent in `OpenRTB` body only, consent cookies stripped @@ -418,6 +438,18 @@ fn expand_trusted_server_bidders( }) .collect() } + +fn merge_bidder_param_object(params: &mut Json, override_obj: &serde_json::Map) { + match params { + Json::Object(base) => { + base.extend(override_obj.iter().map(|(k, v)| (k.clone(), v.clone()))); + } + _ => { + *params = Json::Object(override_obj.clone()); + } + } +} + /// Copies browser headers to the outgoing Prebid Server request. /// /// In [`ConsentForwardingMode::OpenrtbOnly`] mode, consent cookies are @@ -540,6 +572,21 @@ impl PrebidAuctionProvider { } } + // Apply static bidder param overrides before the more specific + // zone-based overrides below. + for (name, params) in &mut bidder { + if let Some(Json::Object(ovr)) = + self.config.bidder_param_overrides.get(name.as_str()) + { + log::debug!( + "prebid: bidder override for '{}': keys {:?}", + name, + ovr.keys().collect::>() + ); + merge_bidder_param_object(params, ovr); + } + } + // Apply zone-specific bid param overrides when configured. for (name, params) in &mut bidder { let zone_override = zone.and_then(|z| { @@ -550,15 +597,13 @@ impl PrebidAuctionProvider { }); if let Some(Json::Object(ovr)) = zone_override { - if let Json::Object(base) = params { - log::debug!( - "prebid: zone override for '{}' zone '{}': keys {:?}", - name, - zone.unwrap_or(""), - ovr.keys().collect::>() - ); - base.extend(ovr.iter().map(|(k, v)| (k.clone(), v.clone()))); - } + log::debug!( + "prebid: zone override for '{}' zone '{}': keys {:?}", + name, + zone.unwrap_or(""), + ovr.keys().collect::>() + ); + merge_bidder_param_object(params, ovr); } } @@ -1245,6 +1290,7 @@ mod tests { script_patterns: default_script_patterns(), client_side_bidders: Vec::new(), bid_param_zone_overrides: HashMap::new(), + bidder_param_overrides: HashMap::new(), consent_forwarding: ConsentForwardingMode::Both, } } @@ -2688,6 +2734,55 @@ server_url = "https://prebid.example" serde_json::from_value(ext["prebid"].clone()).expect("should deserialise ext.prebid") } + // ======================================================================== + // bidder_param_overrides tests + // ======================================================================== + + #[test] + fn bidder_param_override_replaces_and_merges_client_params() { + let config = parse_prebid_toml( + r#" +[integrations.prebid] +enabled = true +server_url = "https://prebid.example" +bidders = ["criteo"] + +[integrations.prebid.bidder_param_overrides.criteo] +networkId = 112141 +pubid = "112141" +"#, + ); + + let slot = make_ts_slot( + "ad-header-0", + &json!({ + "criteo": { + "networkId": 11048, + "pubid": "5254_4YG1PB", + "keep": "present" + } + }), + None, + ); + let request = make_auction_request(vec![slot]); + + let ortb = call_to_openrtb(config, &request); + let params = bidder_params(&ortb); + + assert_eq!( + params["criteo"]["networkId"], 112141, + "override should replace the client-side networkId" + ); + assert_eq!( + params["criteo"]["pubid"], "112141", + "override should replace the client-side pubid" + ); + assert_eq!( + params["criteo"]["keep"], "present", + "override should preserve unrelated bidder params" + ); + } + // ======================================================================== // bid_param_zone_overrides tests // ======================================================================== diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index 78549262..f97f0928 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -1031,6 +1031,56 @@ mod tests { ); } + #[test] + fn test_prebid_bidder_param_overrides_override_with_json_env() { + let toml_str = crate_test_settings_str(); + let env_key = format!( + "{}{}INTEGRATIONS{}PREBID{}BIDDER_PARAM_OVERRIDES", + ENVIRONMENT_VARIABLE_PREFIX, + ENVIRONMENT_VARIABLE_SEPARATOR, + ENVIRONMENT_VARIABLE_SEPARATOR, + ENVIRONMENT_VARIABLE_SEPARATOR + ); + + let origin_key = format!( + "{}{}PUBLISHER{}ORIGIN_URL", + ENVIRONMENT_VARIABLE_PREFIX, + ENVIRONMENT_VARIABLE_SEPARATOR, + ENVIRONMENT_VARIABLE_SEPARATOR + ); + temp_env::with_var( + origin_key, + Some("https://origin.test-publisher.com"), + || { + temp_env::with_var( + env_key, + Some(r#"{"criteo":{"networkId":112141,"pubid":"112141"}}"#), + || { + let settings = Settings::from_toml_and_env(&toml_str) + .expect("Settings should parse with bidder param override env"); + let cfg = settings + .integration_config::("prebid") + .expect("Prebid config query should succeed") + .expect("Prebid config should exist with env override"); + let cfg_json = + serde_json::to_value(&cfg).expect("should serialize config to JSON"); + + assert_eq!( + cfg_json["bidder_param_overrides"]["criteo"]["networkId"], + json!(112141), + "should deserialize networkId override from env JSON" + ); + assert_eq!( + cfg_json["bidder_param_overrides"]["criteo"]["pubid"], + json!("112141"), + "should deserialize pubid override from env JSON" + ); + }, + ); + }, + ); + } + #[test] fn test_handlers_override_with_env() { let toml_str = crate_test_settings_str(); diff --git a/trusted-server.toml b/trusted-server.toml index d9189aaa..a3bb6a39 100644 --- a/trusted-server.toml +++ b/trusted-server.toml @@ -50,6 +50,13 @@ client_side_bidders = ["rubicon"] # Zone-specific bid param overrides for Kargo s2s placement IDs. # The JS adapter reads the zone from mediaTypes.banner.name on each ad unit # and includes it in the request. The server maps zone → s2s placementId here. +[integrations.prebid.bidder_param_overrides] +# Static per-bidder params merged into every outgoing PBS request. +# Example: +# [integrations.prebid.bidder_param_overrides.criteo] +# networkId = 112141 +# pubid = "112141" + [integrations.prebid.bid_param_zone_overrides.kargo] # header = {placementId = "_abc"} @@ -190,4 +197,3 @@ timeout_ms = 1000 [integrations.adserver_mock.context_query_params] permutive_segments = "permutive" - From 4f1c419b49b2475f2f73801ce51cb0ce6664a03e Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Fri, 3 Apr 2026 21:58:09 +0530 Subject: [PATCH 02/13] fix comment for override --- trusted-server.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/trusted-server.toml b/trusted-server.toml index a3bb6a39..a8c5e80a 100644 --- a/trusted-server.toml +++ b/trusted-server.toml @@ -50,7 +50,7 @@ client_side_bidders = ["rubicon"] # Zone-specific bid param overrides for Kargo s2s placement IDs. # The JS adapter reads the zone from mediaTypes.banner.name on each ad unit # and includes it in the request. The server maps zone → s2s placementId here. -[integrations.prebid.bidder_param_overrides] +# [integrations.prebid.bidder_param_overrides] # Static per-bidder params merged into every outgoing PBS request. # Example: # [integrations.prebid.bidder_param_overrides.criteo] From a3786324cd9cae23712e0386fb82caed6362c7c7 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Mon, 6 Apr 2026 17:30:37 +0530 Subject: [PATCH 03/13] Rename bidder -> bid for consistency --- .../trusted-server-core/src/integrations/prebid.rs | 14 +++++++------- crates/trusted-server-core/src/settings.rs | 8 ++++---- trusted-server.toml | 14 +++++++------- 3 files changed, 18 insertions(+), 18 deletions(-) diff --git a/crates/trusted-server-core/src/integrations/prebid.rs b/crates/trusted-server-core/src/integrations/prebid.rs index cfa08b94..ff0d497c 100644 --- a/crates/trusted-server-core/src/integrations/prebid.rs +++ b/crates/trusted-server-core/src/integrations/prebid.rs @@ -120,17 +120,17 @@ pub struct PrebidIntegrationConfig { /// /// Example in TOML: /// ```toml - /// [integrations.prebid.bidder_param_overrides.criteo] + /// [integrations.prebid.bid_param_overrides.criteo] /// networkId = 112141 /// pubid = "112141" /// ``` /// /// Example via environment variable: /// ```text - /// TRUSTED_SERVER__INTEGRATIONS__PREBID__BIDDER_PARAM_OVERRIDES='{"criteo":{"networkId":112141,"pubid":"112141"}}' + /// TRUSTED_SERVER__INTEGRATIONS__PREBID__BID_PARAM_OVERRIDES='{"criteo":{"networkId":112141,"pubid":"112141"}}' /// ``` #[serde(default)] - pub bidder_param_overrides: HashMap, + pub bid_param_overrides: HashMap, /// How consent signals are forwarded to Prebid Server. /// /// - `openrtb_only` — consent in `OpenRTB` body only, consent cookies stripped @@ -576,7 +576,7 @@ impl PrebidAuctionProvider { // zone-based overrides below. for (name, params) in &mut bidder { if let Some(Json::Object(ovr)) = - self.config.bidder_param_overrides.get(name.as_str()) + self.config.bid_param_overrides.get(name.as_str()) { log::debug!( "prebid: bidder override for '{}': keys {:?}", @@ -1290,7 +1290,7 @@ mod tests { script_patterns: default_script_patterns(), client_side_bidders: Vec::new(), bid_param_zone_overrides: HashMap::new(), - bidder_param_overrides: HashMap::new(), + bid_param_overrides: HashMap::new(), consent_forwarding: ConsentForwardingMode::Both, } } @@ -2735,7 +2735,7 @@ server_url = "https://prebid.example" } // ======================================================================== - // bidder_param_overrides tests + // bid_param_overrides tests // ======================================================================== #[test] @@ -2747,7 +2747,7 @@ enabled = true server_url = "https://prebid.example" bidders = ["criteo"] -[integrations.prebid.bidder_param_overrides.criteo] +[integrations.prebid.bid_param_overrides.criteo] networkId = 112141 pubid = "112141" "#, diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index f97f0928..44c341ff 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -1032,10 +1032,10 @@ mod tests { } #[test] - fn test_prebid_bidder_param_overrides_override_with_json_env() { + fn test_prebid_bid_param_overrides_override_with_json_env() { let toml_str = crate_test_settings_str(); let env_key = format!( - "{}{}INTEGRATIONS{}PREBID{}BIDDER_PARAM_OVERRIDES", + "{}{}INTEGRATIONS{}PREBID{}BID_PARAM_OVERRIDES", ENVIRONMENT_VARIABLE_PREFIX, ENVIRONMENT_VARIABLE_SEPARATOR, ENVIRONMENT_VARIABLE_SEPARATOR, @@ -1066,12 +1066,12 @@ mod tests { serde_json::to_value(&cfg).expect("should serialize config to JSON"); assert_eq!( - cfg_json["bidder_param_overrides"]["criteo"]["networkId"], + cfg_json["bid_param_overrides"]["criteo"]["networkId"], json!(112141), "should deserialize networkId override from env JSON" ); assert_eq!( - cfg_json["bidder_param_overrides"]["criteo"]["pubid"], + cfg_json["bid_param_overrides"]["criteo"]["pubid"], json!("112141"), "should deserialize pubid override from env JSON" ); diff --git a/trusted-server.toml b/trusted-server.toml index a8c5e80a..c82faeb8 100644 --- a/trusted-server.toml +++ b/trusted-server.toml @@ -47,16 +47,16 @@ debug = false # be statically imported in the JS bundle. client_side_bidders = ["rubicon"] -# Zone-specific bid param overrides for Kargo s2s placement IDs. -# The JS adapter reads the zone from mediaTypes.banner.name on each ad unit -# and includes it in the request. The server maps zone → s2s placementId here. -# [integrations.prebid.bidder_param_overrides] +# [integrations.prebid.bid_param_overrides] # Static per-bidder params merged into every outgoing PBS request. # Example: -# [integrations.prebid.bidder_param_overrides.criteo] -# networkId = 112141 -# pubid = "112141" +# [integrations.prebid.bid_param_overrides.criteo] +# networkId = 12345 +# pubid = "233422" +# Zone-specific bid param overrides for Kargo s2s placement IDs. +# The JS adapter reads the zone from mediaTypes.banner.name on each ad unit +# and includes it in the request. The server maps zone → s2s placementId here. [integrations.prebid.bid_param_zone_overrides.kargo] # header = {placementId = "_abc"} From 7b69ce91e4508f35988745218c9e254222269dfa Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Mon, 6 Apr 2026 18:16:33 +0530 Subject: [PATCH 04/13] Rename bidder_param_overrides to bid_param_overrides for consistency Renames the new field to match the existing `bid_param_zone_overrides` naming convention. Updates all references: struct field, env var key, doc comments, TOML example, tests, and .env.example. Also replaces production IDs in test fixtures and examples with generic placeholder values. --- .env.example | 2 +- .../src/integrations/prebid.rs | 20 +++++++++---------- crates/trusted-server-core/src/settings.rs | 6 +++--- trusted-server.toml | 6 +++--- 4 files changed, 17 insertions(+), 17 deletions(-) diff --git a/.env.example b/.env.example index af712367..2e57cb88 100644 --- a/.env.example +++ b/.env.example @@ -40,7 +40,7 @@ TRUSTED_SERVER__INTEGRATIONS__PREBID__ENABLED=false # TRUSTED_SERVER__INTEGRATIONS__PREBID__SERVER_URL=https://prebid-server.com/openrtb2/auction # TRUSTED_SERVER__INTEGRATIONS__PREBID__TIMEOUT_MS=1000 # TRUSTED_SERVER__INTEGRATIONS__PREBID__BIDDERS=kargo,rubicon,appnexus -# TRUSTED_SERVER__INTEGRATIONS__PREBID__BIDDER_PARAM_OVERRIDES='{"criteo":{"networkId":112141,"pubid":"112141"}}' +# TRUSTED_SERVER__INTEGRATIONS__PREBID__BID_PARAM_OVERRIDES='{"bidder-name":{"param1":122112,"param2":"11212323"}}' # TRUSTED_SERVER__INTEGRATIONS__PREBID__AUTO_CONFIGURE=false # TRUSTED_SERVER__INTEGRATIONS__PREBID__DEBUG=false # TRUSTED_SERVER__INTEGRATIONS__PREBID__TEST_MODE=false diff --git a/crates/trusted-server-core/src/integrations/prebid.rs b/crates/trusted-server-core/src/integrations/prebid.rs index ff0d497c..acf9c40c 100644 --- a/crates/trusted-server-core/src/integrations/prebid.rs +++ b/crates/trusted-server-core/src/integrations/prebid.rs @@ -120,14 +120,14 @@ pub struct PrebidIntegrationConfig { /// /// Example in TOML: /// ```toml - /// [integrations.prebid.bid_param_overrides.criteo] - /// networkId = 112141 - /// pubid = "112141" + /// [integrations.prebid.bid_param_overrides.bidder-name] + /// param1 = 12345 + /// param2 = "value" /// ``` /// /// Example via environment variable: /// ```text - /// TRUSTED_SERVER__INTEGRATIONS__PREBID__BID_PARAM_OVERRIDES='{"criteo":{"networkId":112141,"pubid":"112141"}}' + /// TRUSTED_SERVER__INTEGRATIONS__PREBID__BID_PARAM_OVERRIDES='{"bidder-name":{"param1":12345,"param2":"value"}}' /// ``` #[serde(default)] pub bid_param_overrides: HashMap, @@ -2748,8 +2748,8 @@ server_url = "https://prebid.example" bidders = ["criteo"] [integrations.prebid.bid_param_overrides.criteo] -networkId = 112141 -pubid = "112141" +networkId = 99999 +pubid = "server-pub" "#, ); @@ -2757,8 +2757,8 @@ pubid = "112141" "ad-header-0", &json!({ "criteo": { - "networkId": 11048, - "pubid": "5254_4YG1PB", + "networkId": 11111, + "pubid": "client-pub", "keep": "present" } }), @@ -2770,11 +2770,11 @@ pubid = "112141" let params = bidder_params(&ortb); assert_eq!( - params["criteo"]["networkId"], 112141, + params["criteo"]["networkId"], 99999, "override should replace the client-side networkId" ); assert_eq!( - params["criteo"]["pubid"], "112141", + params["criteo"]["pubid"], "server-pub", "override should replace the client-side pubid" ); assert_eq!( diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index 44c341ff..22d248ff 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -1054,7 +1054,7 @@ mod tests { || { temp_env::with_var( env_key, - Some(r#"{"criteo":{"networkId":112141,"pubid":"112141"}}"#), + Some(r#"{"criteo":{"networkId":99999,"pubid":"server-pub"}}"#), || { let settings = Settings::from_toml_and_env(&toml_str) .expect("Settings should parse with bidder param override env"); @@ -1067,12 +1067,12 @@ mod tests { assert_eq!( cfg_json["bid_param_overrides"]["criteo"]["networkId"], - json!(112141), + json!(99999), "should deserialize networkId override from env JSON" ); assert_eq!( cfg_json["bid_param_overrides"]["criteo"]["pubid"], - json!("112141"), + json!("server-pub"), "should deserialize pubid override from env JSON" ); }, diff --git a/trusted-server.toml b/trusted-server.toml index c82faeb8..3079f308 100644 --- a/trusted-server.toml +++ b/trusted-server.toml @@ -50,9 +50,9 @@ client_side_bidders = ["rubicon"] # [integrations.prebid.bid_param_overrides] # Static per-bidder params merged into every outgoing PBS request. # Example: -# [integrations.prebid.bid_param_overrides.criteo] -# networkId = 12345 -# pubid = "233422" +# [integrations.prebid.bid_param_overrides.bidder-name] +# param1 = 12345 +# param2 = "value" # Zone-specific bid param overrides for Kargo s2s placement IDs. # The JS adapter reads the zone from mediaTypes.banner.name on each ad unit From 685bffc673c15cf5550c5688255abc5283eae384 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Wed, 8 Apr 2026 15:16:02 +0530 Subject: [PATCH 05/13] Add BidOverride trait with StaticBidOverride and ZoneBidOverride newtypes --- .../src/integrations/prebid.rs | 254 ++++++++++++++++++ 1 file changed, 254 insertions(+) diff --git a/crates/trusted-server-core/src/integrations/prebid.rs b/crates/trusted-server-core/src/integrations/prebid.rs index acf9c40c..f60c8709 100644 --- a/crates/trusted-server-core/src/integrations/prebid.rs +++ b/crates/trusted-server-core/src/integrations/prebid.rs @@ -1,4 +1,5 @@ use std::collections::HashMap; +use std::ops::{Deref, DerefMut}; use std::sync::Arc; use std::time::Duration; @@ -450,6 +451,130 @@ fn merge_bidder_param_object(params: &mut Json, override_obj: &serde_json::Map { + /// Zone name from `mediaTypes.banner.name` (e.g. `"header"`, `"fixed_bottom"`). + pub zone: Option<&'a str>, +} + +/// A source that can apply parameter overrides to a bidder's JSON params. +/// +/// Implement this trait to add a new override dimension. Each implementation +/// receives the full [`BidOverrideContext`] and mutates `params` in place via a +/// shallow merge if the override applies. +pub(crate) trait BidOverride { + /// Apply an override to `params` for the given `bidder` and `context`. + /// + /// Implementors should be a no-op when no override applies. + fn apply(&self, bidder: &str, context: &BidOverrideContext<'_>, params: &mut Json); +} + +/// Static (unconditional) per-bidder parameter overrides. +/// +/// Applied on every request regardless of context. Useful for enforcing +/// server-side bidder IDs that must not be overridden by the client-side +/// Prebid.js configuration. +/// +/// # Examples +/// +/// In TOML: +/// ```toml +/// [integrations.prebid.bid_param_overrides.criteo] +/// networkId = 99999 +/// pubid = "server-pub" +/// ``` +/// +/// Via environment variable: +/// ```text +/// TRUSTED_SERVER__INTEGRATIONS__PREBID__BID_PARAM_OVERRIDES='{"criteo":{"networkId":99999}}' +/// ``` +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(transparent)] +pub struct StaticBidOverride(pub HashMap); + +impl BidOverride for StaticBidOverride { + fn apply(&self, bidder: &str, _context: &BidOverrideContext<'_>, params: &mut Json) { + if let Some(Json::Object(ovr)) = self.0.get(bidder) { + log::debug!( + "prebid: bidder override for '{}': keys {:?}", + bidder, + ovr.keys().collect::>() + ); + merge_bidder_param_object(params, ovr); + } + } +} + +impl Deref for StaticBidOverride { + type Target = HashMap; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl DerefMut for StaticBidOverride { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +/// Zone-keyed per-bidder parameter overrides. +/// +/// Applied when the zone in [`BidOverrideContext`] matches a configured zone +/// entry for the bidder. Zone is sent by the JS adapter from +/// `mediaTypes.banner.name` — a non-standard Prebid.js field. +/// +/// # Examples +/// +/// In TOML: +/// ```toml +/// [integrations.prebid.bid_param_zone_overrides.kargo] +/// header = {placementId = "_s2sHeaderId"} +/// in_content = {placementId = "_s2sContentId"} +/// fixed_bottom = {placementId = "_s2sBottomId"} +/// ``` +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(transparent)] +pub struct ZoneBidOverride(pub HashMap>); + +impl BidOverride for ZoneBidOverride { + fn apply(&self, bidder: &str, context: &BidOverrideContext<'_>, params: &mut Json) { + let Some(zone) = context.zone else { return }; + let Some(zone_map) = self.0.get(bidder) else { return }; + let Some(Json::Object(ovr)) = zone_map.get(zone) else { return }; + log::debug!( + "prebid: zone override for '{}' zone '{}': keys {:?}", + bidder, + zone, + ovr.keys().collect::>() + ); + merge_bidder_param_object(params, ovr); + } +} + +impl Deref for ZoneBidOverride { + type Target = HashMap>; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl DerefMut for ZoneBidOverride { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + /// Copies browser headers to the outgoing Prebid Server request. /// /// In [`ConsentForwardingMode::OpenrtbOnly`] mode, consent cookies are @@ -2978,6 +3103,135 @@ fixed_bottom = {placementId = "_s2sBottom"} ); } + // ======================================================================== + // BidOverride trait unit tests + // ======================================================================== + + mod bid_override { + use super::*; + + fn empty_params() -> Json { + Json::Object(serde_json::Map::new()) + } + + fn params_with(key: &str, value: Json) -> Json { + let mut map = serde_json::Map::new(); + map.insert(key.to_string(), value); + Json::Object(map) + } + + // --- StaticBidOverride --- + + #[test] + fn static_applies_when_bidder_matches() { + let mut overrides = StaticBidOverride::default(); + overrides.insert("criteo".to_string(), json!({ "networkId": 99 })); + let ctx = BidOverrideContext::default(); + let mut params = empty_params(); + overrides.apply("criteo", &ctx, &mut params); + assert_eq!(params["networkId"], 99, "should apply static override"); + } + + #[test] + fn static_noop_when_bidder_absent() { + let mut overrides = StaticBidOverride::default(); + overrides.insert("criteo".to_string(), json!({ "networkId": 99 })); + let ctx = BidOverrideContext::default(); + let mut params = params_with("networkId", json!(1)); + overrides.apply("kargo", &ctx, &mut params); + assert_eq!(params["networkId"], 1, "should not touch params for absent bidder"); + } + + #[test] + fn static_ignores_zone_in_context() { + let mut overrides = StaticBidOverride::default(); + overrides.insert("criteo".to_string(), json!({ "networkId": 42 })); + let ctx = BidOverrideContext { zone: Some("header") }; + let mut params = empty_params(); + overrides.apply("criteo", &ctx, &mut params); + assert_eq!(params["networkId"], 42, "static override should apply regardless of zone"); + } + + #[test] + fn static_merges_preserving_non_overridden_keys() { + let mut overrides = StaticBidOverride::default(); + overrides.insert("criteo".to_string(), json!({ "networkId": 99 })); + let ctx = BidOverrideContext::default(); + let mut params = json!({ "networkId": 1, "keep": "yes" }); + overrides.apply("criteo", &ctx, &mut params); + assert_eq!(params["networkId"], 99, "should replace overridden key"); + assert_eq!(params["keep"], "yes", "should preserve non-overridden key"); + } + + // --- ZoneBidOverride --- + + #[test] + fn zone_applies_when_bidder_and_zone_match() { + let mut overrides = ZoneBidOverride::default(); + overrides.insert( + "kargo".to_string(), + HashMap::from([("header".to_string(), json!({ "placementId": "s2s_h" }))]), + ); + let ctx = BidOverrideContext { zone: Some("header") }; + let mut params = params_with("placementId", json!("client")); + overrides.apply("kargo", &ctx, &mut params); + assert_eq!(params["placementId"], "s2s_h", "should apply zone override"); + } + + #[test] + fn zone_noop_when_context_has_no_zone() { + let mut overrides = ZoneBidOverride::default(); + overrides.insert( + "kargo".to_string(), + HashMap::from([("header".to_string(), json!({ "placementId": "s2s_h" }))]), + ); + let ctx = BidOverrideContext { zone: None }; + let mut params = params_with("placementId", json!("client")); + overrides.apply("kargo", &ctx, &mut params); + assert_eq!(params["placementId"], "client", "missing zone should be a no-op"); + } + + #[test] + fn zone_noop_when_zone_not_in_map() { + let mut overrides = ZoneBidOverride::default(); + overrides.insert( + "kargo".to_string(), + HashMap::from([("header".to_string(), json!({ "placementId": "s2s_h" }))]), + ); + let ctx = BidOverrideContext { zone: Some("sidebar") }; + let mut params = params_with("placementId", json!("client")); + overrides.apply("kargo", &ctx, &mut params); + assert_eq!(params["placementId"], "client", "unknown zone should be a no-op"); + } + + #[test] + fn zone_noop_when_bidder_absent() { + let mut overrides = ZoneBidOverride::default(); + overrides.insert( + "kargo".to_string(), + HashMap::from([("header".to_string(), json!({ "placementId": "s2s_h" }))]), + ); + let ctx = BidOverrideContext { zone: Some("header") }; + let mut params = params_with("placementId", json!("client")); + overrides.apply("rubicon", &ctx, &mut params); + assert_eq!(params["placementId"], "client", "absent bidder should be a no-op"); + } + + #[test] + fn zone_merges_preserving_non_overridden_keys() { + let mut overrides = ZoneBidOverride::default(); + overrides.insert( + "kargo".to_string(), + HashMap::from([("header".to_string(), json!({ "placementId": "s2s_h" }))]), + ); + let ctx = BidOverrideContext { zone: Some("header") }; + let mut params = json!({ "placementId": "client", "extra": "keep" }); + overrides.apply("kargo", &ctx, &mut params); + assert_eq!(params["placementId"], "s2s_h", "should replace overridden key"); + assert_eq!(params["extra"], "keep", "should preserve non-overridden key"); + } + } + #[test] fn enrich_response_metadata_attaches_always_on_fields() { let provider = PrebidAuctionProvider::new(base_config()); From 0f9c87b2d2b98f61cc309aa1cf8f4bffe8995a17 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Wed, 8 Apr 2026 15:17:57 +0530 Subject: [PATCH 06/13] Refactor bid param override fields to use BidOverride trait types --- .../src/integrations/prebid.rs | 109 ++++++++++-------- 1 file changed, 59 insertions(+), 50 deletions(-) diff --git a/crates/trusted-server-core/src/integrations/prebid.rs b/crates/trusted-server-core/src/integrations/prebid.rs index f60c8709..e348fe86 100644 --- a/crates/trusted-server-core/src/integrations/prebid.rs +++ b/crates/trusted-server-core/src/integrations/prebid.rs @@ -111,7 +111,7 @@ pub struct PrebidIntegrationConfig { /// fixed_bottom = {placementId = "_s2sBottomId"} /// ``` #[serde(default)] - pub bid_param_zone_overrides: HashMap>, + pub bid_param_zone_overrides: ZoneBidOverride, /// Static per-bidder parameter overrides merged into every outgoing /// `OpenRTB` imp `ext.prebid.bidder.{bidder}` object before zone-specific /// overrides are applied. @@ -131,7 +131,7 @@ pub struct PrebidIntegrationConfig { /// TRUSTED_SERVER__INTEGRATIONS__PREBID__BID_PARAM_OVERRIDES='{"bidder-name":{"param1":12345,"param2":"value"}}' /// ``` #[serde(default)] - pub bid_param_overrides: HashMap, + pub bid_param_overrides: StaticBidOverride, /// How consent signals are forwarded to Prebid Server. /// /// - `openrtb_only` — consent in `OpenRTB` body only, consent cookies stripped @@ -549,8 +549,12 @@ pub struct ZoneBidOverride(pub HashMap>); impl BidOverride for ZoneBidOverride { fn apply(&self, bidder: &str, context: &BidOverrideContext<'_>, params: &mut Json) { let Some(zone) = context.zone else { return }; - let Some(zone_map) = self.0.get(bidder) else { return }; - let Some(Json::Object(ovr)) = zone_map.get(zone) else { return }; + let Some(zone_map) = self.0.get(bidder) else { + return; + }; + let Some(Json::Object(ovr)) = zone_map.get(zone) else { + return; + }; log::debug!( "prebid: zone override for '{}' zone '{}': keys {:?}", bidder, @@ -697,39 +701,13 @@ impl PrebidAuctionProvider { } } - // Apply static bidder param overrides before the more specific - // zone-based overrides below. - for (name, params) in &mut bidder { - if let Some(Json::Object(ovr)) = - self.config.bid_param_overrides.get(name.as_str()) - { - log::debug!( - "prebid: bidder override for '{}': keys {:?}", - name, - ovr.keys().collect::>() - ); - merge_bidder_param_object(params, ovr); - } - } - - // Apply zone-specific bid param overrides when configured. + // Apply overrides in order: static first, then zone-specific. + let ctx = BidOverrideContext { zone }; for (name, params) in &mut bidder { - let zone_override = zone.and_then(|z| { - self.config - .bid_param_zone_overrides - .get(name.as_str()) - .and_then(|zones| zones.get(z)) - }); - - if let Some(Json::Object(ovr)) = zone_override { - log::debug!( - "prebid: zone override for '{}' zone '{}': keys {:?}", - name, - zone.unwrap_or(""), - ovr.keys().collect::>() - ); - merge_bidder_param_object(params, ovr); - } + self.config.bid_param_overrides.apply(name, &ctx, params); + self.config + .bid_param_zone_overrides + .apply(name, &ctx, params); } Some(Imp { @@ -1414,8 +1392,8 @@ mod tests { debug_query_params: None, script_patterns: default_script_patterns(), client_side_bidders: Vec::new(), - bid_param_zone_overrides: HashMap::new(), - bid_param_overrides: HashMap::new(), + bid_param_zone_overrides: ZoneBidOverride::default(), + bid_param_overrides: StaticBidOverride::default(), consent_forwarding: ConsentForwardingMode::Both, } } @@ -3139,17 +3117,25 @@ fixed_bottom = {placementId = "_s2sBottom"} let ctx = BidOverrideContext::default(); let mut params = params_with("networkId", json!(1)); overrides.apply("kargo", &ctx, &mut params); - assert_eq!(params["networkId"], 1, "should not touch params for absent bidder"); + assert_eq!( + params["networkId"], 1, + "should not touch params for absent bidder" + ); } #[test] fn static_ignores_zone_in_context() { let mut overrides = StaticBidOverride::default(); overrides.insert("criteo".to_string(), json!({ "networkId": 42 })); - let ctx = BidOverrideContext { zone: Some("header") }; + let ctx = BidOverrideContext { + zone: Some("header"), + }; let mut params = empty_params(); overrides.apply("criteo", &ctx, &mut params); - assert_eq!(params["networkId"], 42, "static override should apply regardless of zone"); + assert_eq!( + params["networkId"], 42, + "static override should apply regardless of zone" + ); } #[test] @@ -3172,7 +3158,9 @@ fixed_bottom = {placementId = "_s2sBottom"} "kargo".to_string(), HashMap::from([("header".to_string(), json!({ "placementId": "s2s_h" }))]), ); - let ctx = BidOverrideContext { zone: Some("header") }; + let ctx = BidOverrideContext { + zone: Some("header"), + }; let mut params = params_with("placementId", json!("client")); overrides.apply("kargo", &ctx, &mut params); assert_eq!(params["placementId"], "s2s_h", "should apply zone override"); @@ -3188,7 +3176,10 @@ fixed_bottom = {placementId = "_s2sBottom"} let ctx = BidOverrideContext { zone: None }; let mut params = params_with("placementId", json!("client")); overrides.apply("kargo", &ctx, &mut params); - assert_eq!(params["placementId"], "client", "missing zone should be a no-op"); + assert_eq!( + params["placementId"], "client", + "missing zone should be a no-op" + ); } #[test] @@ -3198,10 +3189,15 @@ fixed_bottom = {placementId = "_s2sBottom"} "kargo".to_string(), HashMap::from([("header".to_string(), json!({ "placementId": "s2s_h" }))]), ); - let ctx = BidOverrideContext { zone: Some("sidebar") }; + let ctx = BidOverrideContext { + zone: Some("sidebar"), + }; let mut params = params_with("placementId", json!("client")); overrides.apply("kargo", &ctx, &mut params); - assert_eq!(params["placementId"], "client", "unknown zone should be a no-op"); + assert_eq!( + params["placementId"], "client", + "unknown zone should be a no-op" + ); } #[test] @@ -3211,10 +3207,15 @@ fixed_bottom = {placementId = "_s2sBottom"} "kargo".to_string(), HashMap::from([("header".to_string(), json!({ "placementId": "s2s_h" }))]), ); - let ctx = BidOverrideContext { zone: Some("header") }; + let ctx = BidOverrideContext { + zone: Some("header"), + }; let mut params = params_with("placementId", json!("client")); overrides.apply("rubicon", &ctx, &mut params); - assert_eq!(params["placementId"], "client", "absent bidder should be a no-op"); + assert_eq!( + params["placementId"], "client", + "absent bidder should be a no-op" + ); } #[test] @@ -3224,11 +3225,19 @@ fixed_bottom = {placementId = "_s2sBottom"} "kargo".to_string(), HashMap::from([("header".to_string(), json!({ "placementId": "s2s_h" }))]), ); - let ctx = BidOverrideContext { zone: Some("header") }; + let ctx = BidOverrideContext { + zone: Some("header"), + }; let mut params = json!({ "placementId": "client", "extra": "keep" }); overrides.apply("kargo", &ctx, &mut params); - assert_eq!(params["placementId"], "s2s_h", "should replace overridden key"); - assert_eq!(params["extra"], "keep", "should preserve non-overridden key"); + assert_eq!( + params["placementId"], "s2s_h", + "should replace overridden key" + ); + assert_eq!( + params["extra"], "keep", + "should preserve non-overridden key" + ); } } From 9760b8444c7c59d300743e92199006ad1945c7e8 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Wed, 8 Apr 2026 16:55:59 +0530 Subject: [PATCH 07/13] Add Prebid generic override rules design --- ...generic-bid-param-override-rules-design.md | 336 ++++++++++++++++++ 1 file changed, 336 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-08-prebid-generic-bid-param-override-rules-design.md diff --git a/docs/superpowers/specs/2026-04-08-prebid-generic-bid-param-override-rules-design.md b/docs/superpowers/specs/2026-04-08-prebid-generic-bid-param-override-rules-design.md new file mode 100644 index 00000000..36831209 --- /dev/null +++ b/docs/superpowers/specs/2026-04-08-prebid-generic-bid-param-override-rules-design.md @@ -0,0 +1,336 @@ +# Generic Prebid Bid Param Override Rules Design + +## Problem + +`prebid` currently supports two specialized bidder-param override shapes: + +- `bid_param_overrides` for unconditional per-bidder overrides +- `bid_param_zone_overrides` for per-bidder, per-zone overrides + +This is ergonomic for today's use cases, but it does not scale well. Every new +override dimension would require: + +1. a new config field such as `bid_param_country_overrides` +2. new normalization and validation code +3. new runtime application wiring + +That creates two problems: + +- the config surface fragments into many `bid_param_*_overrides` sections +- adding a new override type requires code changes even when the desired + behavior is conceptually "just another condition" + +## Goals + +- Replace the specialized runtime override implementations with one generic + ordered rule engine. +- Preserve the existing operator-friendly config shape for + `bid_param_overrides` and `bid_param_zone_overrides`. +- Introduce one canonical config format for future overrides: + `bid_param_override_rules`. +- Keep current override semantics: + - shallow merge into bidder params + - deterministic last-write-wins precedence + - exact string matching only in v1 +- Fail fast on invalid config. + +## Non-Goals + +- Arbitrary JSON-path or expression matching in config. +- Deep JSON merge semantics. +- Immediate removal or deprecation of existing compatibility fields. +- Zero-code support for entirely new fact sources. This design makes override + combinations config-driven, but adding a brand-new matcher dimension still + requires exposing that fact in code. + +## Proposed Configuration Model + +### Compatibility Fields + +Retain these existing fields: + +- `integrations.prebid.bid_param_overrides` +- `integrations.prebid.bid_param_zone_overrides` + +They remain supported because they are natural and concise for the two existing +use cases. + +### Canonical Field + +Add a new ordered rule list: + +```toml +[[integrations.prebid.bid_param_override_rules]] +when.bidder = "criteo" +set = { networkId = 99999, pubid = "server-pub" } + +[[integrations.prebid.bid_param_override_rules]] +when.bidder = "kargo" +when.zone = "header" +set = { placementId = "_abc" } +``` + +This becomes the preferred long-term configuration surface. The compatibility +fields are normalized into the same internal rule list before runtime use. + +## Rule Semantics + +Each rule has: + +- `when`: structured matchers +- `set`: a non-empty JSON object shallow-merged into bidder params + +Semantics for v1: + +- all populated matchers in one rule are ANDed together +- matching is exact equality only +- rules are evaluated in order +- later rules win on overlapping keys because merge is shallow and + last-write-wins + +Example: + +```toml +[[integrations.prebid.bid_param_override_rules]] +when.bidder = "kargo" +when.zone = "header" +set = { placementId = "_zone_default", extra = "a" } + +[[integrations.prebid.bid_param_override_rules]] +when.bidder = "kargo" +when.zone = "header" +set = { placementId = "_zone_override" } +``` + +Effective result: + +- `placementId` becomes `"_zone_override"` +- `extra` remains `"a"` + +## Matcher Vocabulary + +### V1 Matchers + +Support these fields in `when`: + +- `bidder` +- `zone` + +Both are strings. + +### Future Matchers + +The runtime engine should be structured so additional typed matchers can be +added later without redesigning the system, for example: + +- `country` +- `region` +- `slot_id` +- `publisher_domain` +- forwarded auction context keys + +These are intentionally not part of v1. + +## Runtime Architecture + +### Config Types + +Add new config structs in `prebid.rs`: + +- `BidParamOverrideRule` +- `BidParamOverrideWhen` + +Suggested shape: + +```rust +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct BidParamOverrideRule { + pub when: BidParamOverrideWhen, + pub set: Json, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct BidParamOverrideWhen { + pub bidder: Option, + pub zone: Option, +} +``` + +`set` remains `Json` at the config boundary to preserve TOML/env flexibility, +but runtime normalization should require it to be a JSON object. + +### Internal Engine + +Replace the current split runtime override application with one internal engine, +for example: + +```rust +struct BidParamOverrideEngine(Vec); + +struct CompiledBidParamOverrideRule { + bidder: Option, + zone: Option, + set: serde_json::Map, +} + +struct BidParamOverrideFacts<'a> { + bidder: &'a str, + zone: Option<&'a str>, +} +``` + +The engine applies rules against facts gathered at the existing bidder-param +assembly point in `to_openrtb`. + +### Normalization + +Normalize all config inputs into one ordered rule list when constructing the +integration or lazily on first use. + +Normalization order: + +1. rules derived from `bid_param_overrides` +2. rules derived from `bid_param_zone_overrides` +3. explicit `bid_param_override_rules` + +This preserves current behavior while allowing canonical explicit rules to +override compatibility-derived rules intentionally. + +### Compatibility Normalization Examples + +This compatibility config: + +```toml +[integrations.prebid.bid_param_overrides.criteo] +networkId = 99999 + +[integrations.prebid.bid_param_zone_overrides.kargo] +header = { placementId = "_abc" } +``` + +normalizes to the internal equivalent of: + +```toml +[[integrations.prebid.bid_param_override_rules]] +when.bidder = "criteo" +set = { networkId = 99999 } + +[[integrations.prebid.bid_param_override_rules]] +when.bidder = "kargo" +when.zone = "header" +set = { placementId = "_abc" } +``` + +## Validation Rules + +Validation should happen during settings parsing or integration construction, +before any live request processing. + +Validation requirements: + +- `set` must be a non-empty JSON object +- unknown `when.*` fields must fail parsing +- `when.bidder` and `when.zone` must be non-empty strings +- empty `when` should be rejected in v1 to avoid accidental global rules +- compatibility-derived rules must be validated through the same compiled-rule + path as explicit rules + +Failing invalid config early is preferred to silently ignoring malformed rules. + +## Behavior in `to_openrtb` + +Current behavior: + +- bidder params are expanded +- static overrides are applied +- zone overrides are applied + +New behavior: + +- bidder params are expanded +- one rule engine is applied using facts built from the current request context + +For v1, the facts are: + +- bidder name +- zone from `trustedServer` bidder params / `mediaTypes.banner.name` + +This preserves the current request-time behavior while eliminating the +specialized override application paths. + +## Migration Strategy + +Short term: + +- keep `bid_param_overrides` and `bid_param_zone_overrides` +- add `bid_param_override_rules` +- document `bid_param_override_rules` as the preferred canonical format + +Medium term: + +- gather real operator usage of the canonical form +- decide later whether to deprecate the compatibility fields + +This avoids forcing operators to migrate immediately while allowing new use +cases to adopt the generic rules format now. + +## Testing Strategy + +### Config Parsing + +- parse explicit `bid_param_override_rules` +- reject unknown matcher fields +- reject empty `set` +- reject non-object `set` +- reject empty `when.bidder` / `when.zone` + +### Normalization + +- `bid_param_overrides` normalizes to bidder-only rules +- `bid_param_zone_overrides` normalizes to bidder-plus-zone rules +- mixed compatibility and canonical rules preserve normalization order + +### Runtime Evaluation + +- bidder-only rule applies when bidder matches +- bidder-plus-zone rule applies when both match +- rule does not apply when zone is absent +- later rule overrides earlier rule on overlapping keys +- non-overlapping keys are preserved through shallow merge + +### Env Overrides + +- JSON env override for `bid_param_override_rules` parses correctly +- compatibility env overrides continue to parse correctly + +## Risks + +### Compatibility Drift + +If compatibility normalization does not exactly mirror current semantics, +existing configs may change behavior subtly. This is mitigated by preserving the +current ordering and adding mixed-config regression tests. + +### Ambiguous Precedence + +Introducing multiple config inputs can create confusion if precedence is not +explicit. This is mitigated by documenting and testing the fixed normalization +order. + +### Misinterpreting "Generic" + +This design makes override rules generic. It does not make matcher dimensions +fully dynamic. Adding a brand-new matcher like `country` still requires exposing +that fact in code. This is an intentional tradeoff for production-grade typed +validation and predictable behavior. + +## Files Expected to Change During Implementation + +- `crates/trusted-server-core/src/integrations/prebid.rs` +- `trusted-server.toml` +- `crates/trusted-server-core/src/settings.rs` tests, if env override coverage + needs expansion + +No other subsystems should be required for the initial implementation. From dad993fb07c71766fa8588e7eec8d6b1a5e1484f Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Wed, 8 Apr 2026 17:57:21 +0530 Subject: [PATCH 08/13] Implement generic Prebid bid param override rules --- .../src/integrations/prebid.rs | 749 ++++++++++++------ crates/trusted-server-core/src/settings.rs | 57 ++ ...prebid-generic-bid-param-override-rules.md | 437 ++++++++++ trusted-server.toml | 18 +- 4 files changed, 1035 insertions(+), 226 deletions(-) create mode 100644 docs/superpowers/plans/2026-04-08-prebid-generic-bid-param-override-rules.md diff --git a/crates/trusted-server-core/src/integrations/prebid.rs b/crates/trusted-server-core/src/integrations/prebid.rs index e348fe86..59ce3cae 100644 --- a/crates/trusted-server-core/src/integrations/prebid.rs +++ b/crates/trusted-server-core/src/integrations/prebid.rs @@ -1,5 +1,4 @@ use std::collections::HashMap; -use std::ops::{Deref, DerefMut}; use std::sync::Arc; use std::time::Duration; @@ -98,10 +97,12 @@ pub struct PrebidIntegrationConfig { /// manages both lists explicitly. #[serde(default, deserialize_with = "crate::settings::vec_from_seq_or_map")] pub client_side_bidders: Vec, - /// Per-bidder, per-zone param overrides. The outer key is a bidder name, the - /// inner key is a zone name (sent by the JS adapter from `mediaTypes.banner.name` - /// — a non-standard Prebid.js field used as a temporary workaround), - /// and the value is a JSON object shallow-merged into that bidder's params. + /// Compatibility sugar for per-bidder, per-zone param overrides. + /// + /// This preserves the natural `bidder -> zone -> params` config shape for + /// the existing zone-based use case, but it is normalized into the + /// canonical [`bid_param_override_rules`](Self::bid_param_override_rules) + /// engine before runtime use. /// /// Example in TOML: /// ```toml @@ -111,13 +112,13 @@ pub struct PrebidIntegrationConfig { /// fixed_bottom = {placementId = "_s2sBottomId"} /// ``` #[serde(default)] - pub bid_param_zone_overrides: ZoneBidOverride, - /// Static per-bidder parameter overrides merged into every outgoing - /// `OpenRTB` imp `ext.prebid.bidder.{bidder}` object before zone-specific - /// overrides are applied. + pub bid_param_zone_overrides: HashMap>, + /// Compatibility sugar for static per-bidder parameter overrides. /// - /// These are useful when server-side bidder IDs should be enforced from - /// Trusted Server regardless of the page's client-side Prebid config. + /// These rules are normalized into the canonical + /// [`bid_param_override_rules`](Self::bid_param_override_rules) engine and + /// therefore share the same validation and precedence behavior as explicit + /// rules. /// /// Example in TOML: /// ```toml @@ -131,7 +132,29 @@ pub struct PrebidIntegrationConfig { /// TRUSTED_SERVER__INTEGRATIONS__PREBID__BID_PARAM_OVERRIDES='{"bidder-name":{"param1":12345,"param2":"value"}}' /// ``` #[serde(default)] - pub bid_param_overrides: StaticBidOverride, + pub bid_param_overrides: HashMap, + /// Canonical ordered bidder-param override rules. + /// + /// Each rule has structured `when` matchers and a non-empty `set` object + /// that is shallow-merged into the bidder params when every matcher + /// matches. Compatibility fields such as [`bid_param_overrides`](Self::bid_param_overrides) + /// and [`bid_param_zone_overrides`](Self::bid_param_zone_overrides) are + /// normalized into the same runtime rule engine before request handling. + /// + /// Example in TOML: + /// ```toml + /// [[integrations.prebid.bid_param_override_rules]] + /// when.bidder = "kargo" + /// when.zone = "header" + /// set = { placementId = "_abc" } + /// ``` + /// + /// Example via environment variable: + /// ```text + /// TRUSTED_SERVER__INTEGRATIONS__PREBID__BID_PARAM_OVERRIDE_RULES='[{"when":{"bidder":"kargo","zone":"header"},"set":{"placementId":"_abc"}}]' + /// ``` + #[serde(default)] + pub bid_param_override_rules: Vec, /// How consent signals are forwarded to Prebid Server. /// /// - `openrtb_only` — consent in `OpenRTB` body only, consent cookies stripped @@ -147,6 +170,33 @@ impl IntegrationConfig for PrebidIntegrationConfig { } } +/// Canonical bidder-param override rule. +/// +/// A rule matches against the request-time facts in [`BidParamOverrideWhen`] +/// and shallow-merges [`set`](Self::set) into the bidder params when all +/// populated matchers are equal. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct BidParamOverrideRule { + /// Structured exact-match conditions for this rule. + pub when: BidParamOverrideWhen, + /// Parameters shallow-merged into bidder params when the rule matches. + pub set: Json, +} + +/// Structured exact-match conditions for a [`BidParamOverrideRule`]. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct BidParamOverrideWhen { + /// Bidder name matcher. + #[serde(default)] + pub bidder: Option, + /// Zone matcher from `mediaTypes.banner.name` propagated via + /// `trustedServer.zone`. + #[serde(default)] + pub zone: Option, +} + fn default_timeout_ms() -> u32 { 1000 } @@ -179,8 +229,14 @@ pub struct PrebidIntegration { } impl PrebidIntegration { + fn try_new(config: PrebidIntegrationConfig) -> Result, Report> { + let _ = BidParamOverrideEngine::try_from_config(&config)?; + Ok(Arc::new(Self { config })) + } + + #[cfg(test)] fn new(config: PrebidIntegrationConfig) -> Arc { - Arc::new(Self { config }) + Self::try_new(config).expect("should compile prebid bid param overrides") } fn matches_script_url(&self, attr_value: &str) -> bool { @@ -284,7 +340,7 @@ fn build( } } - Ok(Some(PrebidIntegration::new(config))) + Ok(Some(PrebidIntegration::try_new(config)?)) } /// Register the Prebid integration when enabled. @@ -452,131 +508,182 @@ fn merge_bidder_param_object(params: &mut Json, override_obj: &serde_json::Map { - /// Zone name from `mediaTypes.banner.name` (e.g. `"header"`, `"fixed_bottom"`). - pub zone: Option<&'a str>, +struct BidParamOverrideEngine { + rules: Vec, } -/// A source that can apply parameter overrides to a bidder's JSON params. -/// -/// Implement this trait to add a new override dimension. Each implementation -/// receives the full [`BidOverrideContext`] and mutates `params` in place via a -/// shallow merge if the override applies. -pub(crate) trait BidOverride { - /// Apply an override to `params` for the given `bidder` and `context`. - /// - /// Implementors should be a no-op when no override applies. - fn apply(&self, bidder: &str, context: &BidOverrideContext<'_>, params: &mut Json); +#[derive(Debug, Clone, PartialEq, Eq)] +struct CompiledBidParamOverrideRule { + bidder: Option, + zone: Option, + set: serde_json::Map, } -/// Static (unconditional) per-bidder parameter overrides. -/// -/// Applied on every request regardless of context. Useful for enforcing -/// server-side bidder IDs that must not be overridden by the client-side -/// Prebid.js configuration. -/// -/// # Examples -/// -/// In TOML: -/// ```toml -/// [integrations.prebid.bid_param_overrides.criteo] -/// networkId = 99999 -/// pubid = "server-pub" -/// ``` -/// -/// Via environment variable: -/// ```text -/// TRUSTED_SERVER__INTEGRATIONS__PREBID__BID_PARAM_OVERRIDES='{"criteo":{"networkId":99999}}' -/// ``` -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(transparent)] -pub struct StaticBidOverride(pub HashMap); - -impl BidOverride for StaticBidOverride { - fn apply(&self, bidder: &str, _context: &BidOverrideContext<'_>, params: &mut Json) { - if let Some(Json::Object(ovr)) = self.0.get(bidder) { - log::debug!( - "prebid: bidder override for '{}': keys {:?}", - bidder, - ovr.keys().collect::>() - ); - merge_bidder_param_object(params, ovr); - } - } +#[derive(Debug, Copy, Clone)] +struct BidParamOverrideFacts<'a> { + bidder: &'a str, + zone: Option<&'a str>, } -impl Deref for StaticBidOverride { - type Target = HashMap; +impl BidParamOverrideEngine { + fn try_from_config( + config: &PrebidIntegrationConfig, + ) -> Result> { + let mut rules = Vec::new(); + + for (bidder, set) in &config.bid_param_overrides { + rules.push(CompiledBidParamOverrideRule::from_bidder_override( + bidder.as_str(), + set, + )?); + } + + for (bidder, zone_overrides) in &config.bid_param_zone_overrides { + for (zone, set) in zone_overrides { + rules.push(CompiledBidParamOverrideRule::from_zone_override( + bidder.as_str(), + zone.as_str(), + set, + )?); + } + } + + for rule in &config.bid_param_override_rules { + rules.push(CompiledBidParamOverrideRule::try_from(rule.clone())?); + } - fn deref(&self) -> &Self::Target { - &self.0 + Ok(Self { rules }) } -} -impl DerefMut for StaticBidOverride { - fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.0 + fn apply(&self, facts: BidParamOverrideFacts<'_>, params: &mut Json) { + for rule in &self.rules { + if rule.matches(facts) { + log::debug!( + "prebid: applying bidder param override for bidder '{}' zone {:?}: keys {:?}", + facts.bidder, + facts.zone, + rule.set.keys().collect::>() + ); + merge_bidder_param_object(params, &rule.set); + } + } } } -/// Zone-keyed per-bidder parameter overrides. -/// -/// Applied when the zone in [`BidOverrideContext`] matches a configured zone -/// entry for the bidder. Zone is sent by the JS adapter from -/// `mediaTypes.banner.name` — a non-standard Prebid.js field. -/// -/// # Examples -/// -/// In TOML: -/// ```toml -/// [integrations.prebid.bid_param_zone_overrides.kargo] -/// header = {placementId = "_s2sHeaderId"} -/// in_content = {placementId = "_s2sContentId"} -/// fixed_bottom = {placementId = "_s2sBottomId"} -/// ``` -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(transparent)] -pub struct ZoneBidOverride(pub HashMap>); - -impl BidOverride for ZoneBidOverride { - fn apply(&self, bidder: &str, context: &BidOverrideContext<'_>, params: &mut Json) { - let Some(zone) = context.zone else { return }; - let Some(zone_map) = self.0.get(bidder) else { - return; - }; - let Some(Json::Object(ovr)) = zone_map.get(zone) else { - return; - }; - log::debug!( - "prebid: zone override for '{}' zone '{}': keys {:?}", +impl CompiledBidParamOverrideRule { + fn from_bidder_override(bidder: &str, set: &Json) -> Result> { + Self::new( + Some(bidder.to_string()), + None, + set, + &format!("integrations.prebid.bid_param_overrides.{bidder}"), + ) + } + + fn from_zone_override( + bidder: &str, + zone: &str, + set: &Json, + ) -> Result> { + Self::new( + Some(bidder.to_string()), + Some(zone.to_string()), + set, + &format!("integrations.prebid.bid_param_zone_overrides.{bidder}.{zone}"), + ) + } + + fn new( + bidder: Option, + zone: Option, + set: &Json, + source: &str, + ) -> Result> { + let bidder = bidder + .map(|value| validate_override_matcher_string(value, "when.bidder", source)) + .transpose()?; + let zone = zone + .map(|value| validate_override_matcher_string(value, "when.zone", source)) + .transpose()?; + + if bidder.is_none() && zone.is_none() { + return Err(Report::new(TrustedServerError::Configuration { + message: format!("{source} must include at least one matcher"), + })); + } + + Ok(Self { bidder, zone, - ovr.keys().collect::>() - ); - merge_bidder_param_object(params, ovr); + set: json_object_for_override(set, source)?, + }) + } + + fn matches(&self, facts: BidParamOverrideFacts<'_>) -> bool { + if let Some(expected_bidder) = self.bidder.as_deref() { + if expected_bidder != facts.bidder { + return false; + } + } + + if let Some(expected_zone) = self.zone.as_deref() { + if facts.zone != Some(expected_zone) { + return false; + } + } + + true } } -impl Deref for ZoneBidOverride { - type Target = HashMap>; +impl TryFrom for CompiledBidParamOverrideRule { + type Error = Report; - fn deref(&self) -> &Self::Target { - &self.0 + fn try_from(rule: BidParamOverrideRule) -> Result { + Self::new( + rule.when.bidder, + rule.when.zone, + &rule.set, + "integrations.prebid.bid_param_override_rules[*]", + ) } } -impl DerefMut for ZoneBidOverride { - fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.0 +fn validate_override_matcher_string( + value: String, + field: &str, + source: &str, +) -> Result> { + if value.trim().is_empty() { + return Err(Report::new(TrustedServerError::Configuration { + message: format!("{source}.{field} must not be empty"), + })); } + + Ok(value) +} + +fn json_object_for_override( + value: &Json, + source: &str, +) -> Result, Report> { + let Json::Object(map) = value else { + return Err(Report::new(TrustedServerError::Configuration { + message: format!("{source}.set must be a JSON object"), + })); + }; + + if map.is_empty() { + return Err(Report::new(TrustedServerError::Configuration { + message: format!("{source}.set must not be empty"), + })); + } + + Ok(map.clone()) } /// Copies browser headers to the outgoing Prebid Server request. @@ -625,13 +732,31 @@ fn append_query_params(url: &str, params: &str) -> String { /// Prebid Server auction provider. pub struct PrebidAuctionProvider { config: PrebidIntegrationConfig, + bid_param_override_engine: BidParamOverrideEngine, } impl PrebidAuctionProvider { /// Create a new Prebid auction provider. + /// + /// # Panics + /// + /// Panics if `config` contains invalid bidder-param override rules. Use + /// [`Self::try_new`] when constructing from untrusted configuration. #[must_use] pub fn new(config: PrebidIntegrationConfig) -> Self { - Self { config } + Self::try_new(config).expect("should compile prebid bid param overrides") + } + + /// Create a new Prebid auction provider with validated override rules. + /// + /// # Errors + /// + /// Returns an error when the configured bidder-param override rules are invalid. + pub fn try_new(config: PrebidIntegrationConfig) -> Result> { + Ok(Self { + bid_param_override_engine: BidParamOverrideEngine::try_from_config(&config)?, + config, + }) } /// Convert auction request to `OpenRTB` format with all enrichments. @@ -701,13 +826,10 @@ impl PrebidAuctionProvider { } } - // Apply overrides in order: static first, then zone-specific. - let ctx = BidOverrideContext { zone }; + // Apply canonical and compatibility-derived rules in normalized order. for (name, params) in &mut bidder { - self.config.bid_param_overrides.apply(name, &ctx, params); - self.config - .bid_param_zone_overrides - .apply(name, &ctx, params); + self.bid_param_override_engine + .apply(BidParamOverrideFacts { bidder: name, zone }, params); } Some(Imp { @@ -1342,7 +1464,7 @@ pub fn register_auction_provider( bidstatus) will be included in /auction responses" ); } - providers.push(Arc::new(PrebidAuctionProvider::new(config))); + providers.push(Arc::new(PrebidAuctionProvider::try_new(config)?)); } Ok(None) => { log::info!("Prebid auction provider not registered: integration not found or disabled"); @@ -1392,8 +1514,9 @@ mod tests { debug_query_params: None, script_patterns: default_script_patterns(), client_side_bidders: Vec::new(), - bid_param_zone_overrides: ZoneBidOverride::default(), - bid_param_overrides: StaticBidOverride::default(), + bid_param_zone_overrides: HashMap::default(), + bid_param_overrides: HashMap::default(), + bid_param_override_rules: Vec::new(), consent_forwarding: ConsentForwardingMode::Both, } } @@ -3081,11 +3204,127 @@ fixed_bottom = {placementId = "_s2sBottom"} ); } + #[test] + fn bid_param_override_rules_config_parsing_from_toml() { + let config = parse_prebid_toml( + r#" +[integrations.prebid] +enabled = true +server_url = "https://prebid.example" + +[[integrations.prebid.bid_param_override_rules]] +when.bidder = "kargo" +when.zone = "header" +set = { placementId = "_s2sHeader", extra = "x" } +"#, + ); + + assert_eq!( + config.bid_param_override_rules.len(), + 1, + "should parse one canonical override rule" + ); + assert_eq!( + config.bid_param_override_rules[0].when.bidder.as_deref(), + Some("kargo"), + "should parse bidder matcher" + ); + assert_eq!( + config.bid_param_override_rules[0].when.zone.as_deref(), + Some("header"), + "should parse zone matcher" + ); + assert_eq!( + config.bid_param_override_rules[0].set["placementId"], "_s2sHeader", + "should parse canonical set object" + ); + } + + #[test] + fn explicit_bid_param_override_rule_applies_for_bidder_and_zone() { + let config = parse_prebid_toml( + r#" +[integrations.prebid] +enabled = true +server_url = "https://prebid.example" +bidders = ["kargo"] + +[[integrations.prebid.bid_param_override_rules]] +when.bidder = "kargo" +when.zone = "header" +set = { placementId = "rule_header", keep = "server" } +"#, + ); + + let slot = make_ts_slot( + "ad-header-0", + &json!({ + "kargo": { + "placementId": "client", + "keep": "client", + "other": "present" + } + }), + Some("header"), + ); + let request = make_auction_request(vec![slot]); + + let ortb = call_to_openrtb(config, &request); + let params = bidder_params(&ortb); + + assert_eq!( + params["kargo"]["placementId"], "rule_header", + "canonical rule should override placementId" + ); + assert_eq!( + params["kargo"]["keep"], "server", + "canonical rule should replace overlapping keys" + ); + assert_eq!( + params["kargo"]["other"], "present", + "canonical rule should preserve unrelated keys" + ); + } + + #[test] + fn explicit_bid_param_override_rule_wins_over_zone_compatibility_rule() { + let config = parse_prebid_toml( + r#" +[integrations.prebid] +enabled = true +server_url = "https://prebid.example" +bidders = ["kargo"] + +[integrations.prebid.bid_param_zone_overrides.kargo] +header = { placementId = "compat_header" } + +[[integrations.prebid.bid_param_override_rules]] +when.bidder = "kargo" +when.zone = "header" +set = { placementId = "explicit_header" } +"#, + ); + + let slot = make_ts_slot( + "ad-header-0", + &json!({ "kargo": { "placementId": "client" } }), + Some("header"), + ); + let request = make_auction_request(vec![slot]); + + let ortb = call_to_openrtb(config, &request); + assert_eq!( + bidder_params(&ortb)["kargo"]["placementId"], + "explicit_header", + "canonical rules should run after compatibility-derived rules" + ); + } + // ======================================================================== - // BidOverride trait unit tests + // BidParamOverrideEngine unit tests // ======================================================================== - mod bid_override { + mod bid_param_override_engine { use super::*; fn empty_params() -> Json { @@ -3098,146 +3337,214 @@ fixed_bottom = {placementId = "_s2sBottom"} Json::Object(map) } - // --- StaticBidOverride --- - #[test] - fn static_applies_when_bidder_matches() { - let mut overrides = StaticBidOverride::default(); - overrides.insert("criteo".to_string(), json!({ "networkId": 99 })); - let ctx = BidOverrideContext::default(); - let mut params = empty_params(); - overrides.apply("criteo", &ctx, &mut params); - assert_eq!(params["networkId"], 99, "should apply static override"); - } + fn engine_normalizes_static_compatibility_rule() { + let mut config = base_config(); + config + .bid_param_overrides + .insert("criteo".to_string(), json!({ "networkId": 99 })); - #[test] - fn static_noop_when_bidder_absent() { - let mut overrides = StaticBidOverride::default(); - overrides.insert("criteo".to_string(), json!({ "networkId": 99 })); - let ctx = BidOverrideContext::default(); - let mut params = params_with("networkId", json!(1)); - overrides.apply("kargo", &ctx, &mut params); + let engine = BidParamOverrideEngine::try_from_config(&config) + .expect("should compile static compatibility overrides"); + + assert_eq!(engine.rules.len(), 1, "should compile one static rule"); assert_eq!( - params["networkId"], 1, - "should not touch params for absent bidder" + engine.rules[0].bidder.as_deref(), + Some("criteo"), + "should set bidder matcher" ); - } - - #[test] - fn static_ignores_zone_in_context() { - let mut overrides = StaticBidOverride::default(); - overrides.insert("criteo".to_string(), json!({ "networkId": 42 })); - let ctx = BidOverrideContext { - zone: Some("header"), - }; - let mut params = empty_params(); - overrides.apply("criteo", &ctx, &mut params); assert_eq!( - params["networkId"], 42, - "static override should apply regardless of zone" + engine.rules[0].zone, None, + "should not set zone matcher for static overrides" + ); + assert_eq!( + engine.rules[0].set.get("networkId"), + Some(&json!(99)), + "should preserve set object" ); } #[test] - fn static_merges_preserving_non_overridden_keys() { - let mut overrides = StaticBidOverride::default(); - overrides.insert("criteo".to_string(), json!({ "networkId": 99 })); - let ctx = BidOverrideContext::default(); - let mut params = json!({ "networkId": 1, "keep": "yes" }); - overrides.apply("criteo", &ctx, &mut params); - assert_eq!(params["networkId"], 99, "should replace overridden key"); - assert_eq!(params["keep"], "yes", "should preserve non-overridden key"); - } + fn engine_normalizes_zone_compatibility_rule() { + let mut config = base_config(); + config.bid_param_zone_overrides.insert( + "kargo".to_string(), + HashMap::from([( + "header".to_string(), + json!({ "placementId": "zone-header" }), + )]), + ); - // --- ZoneBidOverride --- + let engine = BidParamOverrideEngine::try_from_config(&config) + .expect("should compile zone compatibility overrides"); - #[test] - fn zone_applies_when_bidder_and_zone_match() { - let mut overrides = ZoneBidOverride::default(); - overrides.insert( - "kargo".to_string(), - HashMap::from([("header".to_string(), json!({ "placementId": "s2s_h" }))]), + assert_eq!(engine.rules.len(), 1, "should compile one zone rule"); + assert_eq!( + engine.rules[0].bidder.as_deref(), + Some("kargo"), + "should set bidder matcher" + ); + assert_eq!( + engine.rules[0].zone.as_deref(), + Some("header"), + "should set zone matcher" + ); + assert_eq!( + engine.rules[0].set.get("placementId"), + Some(&json!("zone-header")), + "should preserve zone set object" ); - let ctx = BidOverrideContext { - zone: Some("header"), - }; - let mut params = params_with("placementId", json!("client")); - overrides.apply("kargo", &ctx, &mut params); - assert_eq!(params["placementId"], "s2s_h", "should apply zone override"); } #[test] - fn zone_noop_when_context_has_no_zone() { - let mut overrides = ZoneBidOverride::default(); - overrides.insert( - "kargo".to_string(), - HashMap::from([("header".to_string(), json!({ "placementId": "s2s_h" }))]), + fn engine_applies_bidder_only_rule() { + let mut config = base_config(); + config + .bid_param_overrides + .insert("criteo".to_string(), json!({ "networkId": 42 })); + + let engine = BidParamOverrideEngine::try_from_config(&config) + .expect("should compile bidder-only override"); + let mut params = empty_params(); + + engine.apply( + BidParamOverrideFacts { + bidder: "criteo", + zone: Some("header"), + }, + &mut params, ); - let ctx = BidOverrideContext { zone: None }; - let mut params = params_with("placementId", json!("client")); - overrides.apply("kargo", &ctx, &mut params); + assert_eq!( - params["placementId"], "client", - "missing zone should be a no-op" + params["networkId"], 42, + "bidder-only override should apply regardless of zone" ); } #[test] - fn zone_noop_when_zone_not_in_map() { - let mut overrides = ZoneBidOverride::default(); - overrides.insert( + fn engine_applies_bidder_and_zone_rule() { + let mut config = base_config(); + config.bid_param_zone_overrides.insert( "kargo".to_string(), HashMap::from([("header".to_string(), json!({ "placementId": "s2s_h" }))]), ); - let ctx = BidOverrideContext { - zone: Some("sidebar"), - }; + + let engine = BidParamOverrideEngine::try_from_config(&config) + .expect("should compile bidder-and-zone override"); let mut params = params_with("placementId", json!("client")); - overrides.apply("kargo", &ctx, &mut params); + engine.apply( + BidParamOverrideFacts { + bidder: "kargo", + zone: Some("header"), + }, + &mut params, + ); assert_eq!( - params["placementId"], "client", - "unknown zone should be a no-op" + params["placementId"], "s2s_h", + "bidder-and-zone override should apply when both facts match" ); } #[test] - fn zone_noop_when_bidder_absent() { - let mut overrides = ZoneBidOverride::default(); - overrides.insert( + fn engine_noop_when_facts_do_not_match() { + let mut config = base_config(); + config.bid_param_zone_overrides.insert( "kargo".to_string(), HashMap::from([("header".to_string(), json!({ "placementId": "s2s_h" }))]), ); - let ctx = BidOverrideContext { - zone: Some("header"), - }; + + let engine = BidParamOverrideEngine::try_from_config(&config) + .expect("should compile unmatched override"); let mut params = params_with("placementId", json!("client")); - overrides.apply("rubicon", &ctx, &mut params); + + engine.apply( + BidParamOverrideFacts { + bidder: "kargo", + zone: Some("sidebar"), + }, + &mut params, + ); + assert_eq!( params["placementId"], "client", - "absent bidder should be a no-op" + "should leave params unchanged when facts do not match" ); } #[test] - fn zone_merges_preserving_non_overridden_keys() { - let mut overrides = ZoneBidOverride::default(); - overrides.insert( - "kargo".to_string(), - HashMap::from([("header".to_string(), json!({ "placementId": "s2s_h" }))]), + fn engine_applies_later_rule_last_write_wins() { + let mut config = base_config(); + config + .bid_param_overrides + .insert("kargo".to_string(), json!({ "placementId": "compat" })); + config.bid_param_override_rules.push(BidParamOverrideRule { + when: BidParamOverrideWhen { + bidder: Some("kargo".to_string()), + zone: Some("header".to_string()), + }, + set: json!({ "placementId": "explicit", "extra": "x" }), + }); + + let engine = BidParamOverrideEngine::try_from_config(&config) + .expect("should compile ordered overrides"); + let mut params = params_with("keep", json!("yes")); + + engine.apply( + BidParamOverrideFacts { + bidder: "kargo", + zone: Some("header"), + }, + &mut params, ); - let ctx = BidOverrideContext { - zone: Some("header"), - }; - let mut params = json!({ "placementId": "client", "extra": "keep" }); - overrides.apply("kargo", &ctx, &mut params); + assert_eq!( - params["placementId"], "s2s_h", - "should replace overridden key" + params["placementId"], "explicit", + "later canonical rule should override earlier compatibility rule" ); assert_eq!( - params["extra"], "keep", - "should preserve non-overridden key" + params["extra"], "x", + "should merge additional explicit keys" ); + assert_eq!(params["keep"], "yes", "should preserve unrelated params"); + } + + #[test] + fn compile_rule_rejects_empty_when() { + let rule = BidParamOverrideRule { + when: BidParamOverrideWhen::default(), + set: json!({ "placementId": "x" }), + }; + + let result = CompiledBidParamOverrideRule::try_from(rule); + assert!(result.is_err(), "should reject empty when"); + } + + #[test] + fn compile_rule_rejects_non_object_set() { + let rule = BidParamOverrideRule { + when: BidParamOverrideWhen { + bidder: Some("kargo".to_string()), + zone: None, + }, + set: json!("not-an-object"), + }; + + let result = CompiledBidParamOverrideRule::try_from(rule); + assert!(result.is_err(), "should reject non-object set"); + } + + #[test] + fn compile_rule_rejects_empty_object_set() { + let rule = BidParamOverrideRule { + when: BidParamOverrideWhen { + bidder: Some("kargo".to_string()), + zone: None, + }, + set: json!({}), + }; + + let result = CompiledBidParamOverrideRule::try_from(rule); + assert!(result.is_err(), "should reject empty set object"); } } diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index 22d248ff..1c8ae790 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -1081,6 +1081,63 @@ mod tests { ); } + #[test] + fn test_prebid_bid_param_override_rules_override_with_json_env() { + let toml_str = crate_test_settings_str(); + let env_key = format!( + "{}{}INTEGRATIONS{}PREBID{}BID_PARAM_OVERRIDE_RULES", + ENVIRONMENT_VARIABLE_PREFIX, + ENVIRONMENT_VARIABLE_SEPARATOR, + ENVIRONMENT_VARIABLE_SEPARATOR, + ENVIRONMENT_VARIABLE_SEPARATOR + ); + + let origin_key = format!( + "{}{}PUBLISHER{}ORIGIN_URL", + ENVIRONMENT_VARIABLE_PREFIX, + ENVIRONMENT_VARIABLE_SEPARATOR, + ENVIRONMENT_VARIABLE_SEPARATOR + ); + temp_env::with_var( + origin_key, + Some("https://origin.test-publisher.com"), + || { + temp_env::with_var( + env_key, + Some( + r#"[{"when":{"bidder":"kargo","zone":"header"},"set":{"placementId":"server-header","keep":"yes"}}]"#, + ), + || { + let settings = Settings::from_toml_and_env(&toml_str) + .expect("Settings should parse canonical bidder param override rules"); + let cfg = settings + .integration_config::("prebid") + .expect("Prebid config query should succeed") + .expect("Prebid config should exist with env override"); + let cfg_json = + serde_json::to_value(&cfg).expect("should serialize config to JSON"); + + assert_eq!( + cfg_json["bid_param_override_rules"][0]["when"]["bidder"], + json!("kargo"), + "should deserialize bidder matcher from env JSON" + ); + assert_eq!( + cfg_json["bid_param_override_rules"][0]["when"]["zone"], + json!("header"), + "should deserialize zone matcher from env JSON" + ); + assert_eq!( + cfg_json["bid_param_override_rules"][0]["set"]["placementId"], + json!("server-header"), + "should deserialize set object from env JSON" + ); + }, + ); + }, + ); + } + #[test] fn test_handlers_override_with_env() { let toml_str = crate_test_settings_str(); diff --git a/docs/superpowers/plans/2026-04-08-prebid-generic-bid-param-override-rules.md b/docs/superpowers/plans/2026-04-08-prebid-generic-bid-param-override-rules.md new file mode 100644 index 00000000..c5835783 --- /dev/null +++ b/docs/superpowers/plans/2026-04-08-prebid-generic-bid-param-override-rules.md @@ -0,0 +1,437 @@ +# Prebid Generic Bid Param Override Rules Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the current split Prebid bidder-param override implementation with one generic ordered rule engine while preserving `bid_param_overrides` and `bid_param_zone_overrides` as compatibility config and adding canonical `bid_param_override_rules`. + +**Architecture:** Normalize all override config shapes into one ordered internal rule list, then evaluate that list against request facts (`bidder`, `zone`) at the existing bidder-param assembly point in `to_openrtb`. Keep the current shallow-merge semantics and last-write-wins precedence, but move validation into one compiled-rule path so explicit and compatibility config behave consistently. + +**Tech Stack:** Rust 2024, `serde`, `serde_json`, `validator`, unit tests in `prebid.rs`, env parsing tests in `settings.rs`. + +--- + +## Files Modified + +| File | Changes | +|------|---------| +| `crates/trusted-server-core/src/integrations/prebid.rs` | Replace `BidOverride` / `StaticBidOverride` / `ContextKey` / `KeyedBidOverride` runtime with canonical rule structs and one internal engine; add canonical rule parsing, validation, normalization, and runtime application; update config docs and tests | +| `crates/trusted-server-core/src/settings.rs` | Add env parsing coverage for `bid_param_override_rules` | +| `trusted-server.toml` | Document the new canonical rule syntax while keeping compatibility examples | + +## Task 1: Add failing tests for canonical rules and compatibility normalization + +**Files:** +- Modify: `crates/trusted-server-core/src/integrations/prebid.rs` + +- [ ] **Step 1.1: Add a canonical rule parsing test near the existing Prebid config parsing tests** + +```rust +#[test] +fn bid_param_override_rules_config_parsing_from_toml() { + let config = parse_prebid_toml( + r#" +[integrations.prebid] +enabled = true +server_url = "https://prebid.example" + +[[integrations.prebid.bid_param_override_rules]] +when.bidder = "kargo" +when.zone = "header" +set = { placementId = "_s2sHeader", extra = "x" } +"#, + ); + + assert_eq!(config.bid_param_override_rules.len(), 1); + assert_eq!( + config.bid_param_override_rules[0].when.bidder.as_deref(), + Some("kargo") + ); + assert_eq!( + config.bid_param_override_rules[0].when.zone.as_deref(), + Some("header") + ); + assert_eq!( + config.bid_param_override_rules[0].set["placementId"], + "_s2sHeader" + ); +} +``` + +- [ ] **Step 1.2: Add a failing runtime test that proves explicit canonical rules apply** + +```rust +#[test] +fn explicit_bid_param_override_rule_applies_for_bidder_and_zone() { + let config = parse_prebid_toml( + r#" +[integrations.prebid] +enabled = true +server_url = "https://prebid.example" +bidders = ["kargo"] + +[[integrations.prebid.bid_param_override_rules]] +when.bidder = "kargo" +when.zone = "header" +set = { placementId = "rule_header", keep = "server" } +"#, + ); + + let slot = make_ts_slot( + "ad-header-0", + &json!({ "kargo": { "placementId": "client", "keep": "client", "other": "present" } }), + Some("header"), + ); + let request = make_auction_request(vec![slot]); + + let ortb = call_to_openrtb(config, &request); + let params = bidder_params(&ortb); + + assert_eq!(params["kargo"]["placementId"], "rule_header"); + assert_eq!(params["kargo"]["keep"], "server"); + assert_eq!(params["kargo"]["other"], "present"); +} +``` + +- [ ] **Step 1.3: Add a failing precedence test proving canonical rules override compatibility-derived rules** + +```rust +#[test] +fn explicit_bid_param_override_rule_wins_over_zone_compatibility_rule() { + let config = parse_prebid_toml( + r#" +[integrations.prebid] +enabled = true +server_url = "https://prebid.example" +bidders = ["kargo"] + +[integrations.prebid.bid_param_zone_overrides.kargo] +header = { placementId = "compat_header" } + +[[integrations.prebid.bid_param_override_rules]] +when.bidder = "kargo" +when.zone = "header" +set = { placementId = "explicit_header" } +"#, + ); + + let slot = make_ts_slot( + "ad-header-0", + &json!({ "kargo": { "placementId": "client" } }), + Some("header"), + ); + let request = make_auction_request(vec![slot]); + + let ortb = call_to_openrtb(config, &request); + assert_eq!(bidder_params(&ortb)["kargo"]["placementId"], "explicit_header"); +} +``` + +- [ ] **Step 1.4: Add failing validation tests in the existing override-focused unit test module** + +```rust +#[test] +fn compile_rule_rejects_empty_when() { + let rule = BidParamOverrideRule { + when: BidParamOverrideWhen::default(), + set: json!({ "placementId": "x" }), + }; + + let result = CompiledBidParamOverrideRule::try_from(rule); + assert!(result.is_err(), "should reject empty when"); +} + +#[test] +fn compile_rule_rejects_non_object_set() { + let rule = BidParamOverrideRule { + when: BidParamOverrideWhen { + bidder: Some("kargo".to_string()), + zone: None, + }, + set: json!("not-an-object"), + }; + + let result = CompiledBidParamOverrideRule::try_from(rule); + assert!(result.is_err(), "should reject non-object set"); +} +``` + +- [ ] **Step 1.5: Run the focused tests and confirm they fail for the expected missing pieces** + +Run: +```bash +cargo test -p trusted-server-core explicit_bid_param_override_rule +cargo test -p trusted-server-core compile_rule_rejects +``` + +Expected: failures because `bid_param_override_rules` and compiled-rule validation do not exist yet. + +## Task 2: Replace the split override runtime with one compiled rule engine + +**Files:** +- Modify: `crates/trusted-server-core/src/integrations/prebid.rs` + +- [ ] **Step 2.1: Add the canonical config structs to `PrebidIntegrationConfig`** + +Add: +```rust +/// Canonical ordered bidder-param override rules. +#[serde(default)] +pub bid_param_override_rules: Vec, +``` + +With new supporting structs: +```rust +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct BidParamOverrideRule { + pub when: BidParamOverrideWhen, + pub set: Json, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct BidParamOverrideWhen { + #[serde(default)] + pub bidder: Option, + #[serde(default)] + pub zone: Option, +} +``` + +- [ ] **Step 2.2: Delete the current runtime override abstraction block** + +Delete: +- `BidOverrideContext` +- `BidOverride` +- `StaticBidOverride` +- `ContextKey` +- `ZoneKey` +- `KeyedBidOverride` +- `ZoneBidOverride` + +Replace them with: +```rust +#[derive(Debug, Default)] +struct BidParamOverrideEngine { + rules: Vec, +} + +#[derive(Debug)] +struct BidParamOverrideFacts<'a> { + bidder: &'a str, + zone: Option<&'a str>, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct CompiledBidParamOverrideRule { + bidder: Option, + zone: Option, + set: serde_json::Map, +} +``` + +- [ ] **Step 2.3: Implement compiled-rule validation and engine normalization** + +Add: +- `impl TryFrom for CompiledBidParamOverrideRule` +- helper `fn validate_matcher_string(...)` +- helper `fn json_object_for_override(...)` +- `impl BidParamOverrideEngine { fn try_from_config(...) -> Result> }` +- compatibility normalization helpers for: + - `bid_param_overrides` + - `bid_param_zone_overrides` + - explicit `bid_param_override_rules` + +Validation rules: +- reject empty `when` +- reject empty strings for `bidder` or `zone` +- reject non-object or empty-object `set` + +- [ ] **Step 2.4: Store the compiled engine in `PrebidIntegration` and `PrebidAuctionProvider`** + +Change the structs from: +```rust +pub struct PrebidIntegration { config: PrebidIntegrationConfig } +pub struct PrebidAuctionProvider { config: PrebidIntegrationConfig } +``` + +To: +```rust +pub struct PrebidIntegration { + config: PrebidIntegrationConfig, + bid_param_override_engine: Arc, +} + +pub struct PrebidAuctionProvider { + config: PrebidIntegrationConfig, + bid_param_override_engine: Arc, +} +``` + +Compile the engine in `PrebidIntegration::new` and `PrebidAuctionProvider::new`. + +- [ ] **Step 2.5: Replace the runtime application path in `to_openrtb`** + +Replace: +```rust +let ctx = BidOverrideContext { zone }; +for (name, params) in &mut bidder { + self.config.bid_param_overrides.apply(name, &ctx, params); + self.config.bid_param_zone_overrides.apply(name, &ctx, params); +} +``` + +With: +```rust +for (name, params) in &mut bidder { + self.bid_param_override_engine.apply( + BidParamOverrideFacts { + bidder: name, + zone, + }, + params, + ); +} +``` + +- [ ] **Step 2.6: Run the targeted tests and make them pass** + +Run: +```bash +cargo test -p trusted-server-core explicit_bid_param_override_rule +cargo test -p trusted-server-core compile_rule_rejects +``` + +Expected: PASS + +## Task 3: Update existing tests to the new engine model and extend env coverage + +**Files:** +- Modify: `crates/trusted-server-core/src/integrations/prebid.rs` +- Modify: `crates/trusted-server-core/src/settings.rs` + +- [ ] **Step 3.1: Rewrite the old override unit tests to target the engine directly** + +Replace the current `mod bid_override` tests with engine-centric tests: +- compatibility static rule normalization +- compatibility zone rule normalization +- `apply` with bidder-only facts +- `apply` with bidder + zone facts +- no-op on unmatched facts +- later rule wins on overlapping keys + +- [ ] **Step 3.2: Remove tests that only exist for deleted types** + +Delete tests specific to: +- `StaticBidOverride` +- `ZoneBidOverride` +- `KeyedBidOverride` +- `ContextKey` + +- [ ] **Step 3.3: Add env parsing coverage for canonical rules in `settings.rs`** + +Add a test similar to the existing `bid_param_overrides` env test: +```rust +#[test] +fn test_prebid_bid_param_override_rules_override_with_json_env() { + // TRUSTED_SERVER__INTEGRATIONS__PREBID__BID_PARAM_OVERRIDE_RULES='[...]' +} +``` + +Assert that: +- the array parses +- `when.bidder` and `when.zone` survive round-trip +- `set` preserves the JSON object + +- [ ] **Step 3.4: Run the focused Rust tests for Prebid and settings** + +Run: +```bash +cargo test -p trusted-server-core bid_param_override +cargo test -p trusted-server-core test_prebid_bid_param_override +``` + +Expected: PASS + +## Task 4: Update operator-facing documentation and sample config + +**Files:** +- Modify: `trusted-server.toml` +- Modify: `crates/trusted-server-core/src/integrations/prebid.rs` + +- [ ] **Step 4.1: Update `trusted-server.toml` comments** + +Keep the existing compatibility examples, then add the canonical rule format: + +```toml +# [[integrations.prebid.bid_param_override_rules]] +# when.bidder = "kargo" +# when.zone = "header" +# set = { placementId = "_abc" } +``` + +Document: +- compatibility fields are still supported +- canonical rules are preferred for future overrides + +- [ ] **Step 4.2: Update `PrebidIntegrationConfig` field docs** + +Adjust the field comments so they describe: +- `bid_param_overrides` as compatibility sugar +- `bid_param_zone_overrides` as compatibility sugar +- `bid_param_override_rules` as the canonical ordered rule list + +- [ ] **Step 4.3: Run rustdoc-sensitive checks for the touched crate** + +Run: +```bash +cargo test -p trusted-server-core parse_prebid_toml +``` + +Expected: PASS + +## Task 5: Verify, commit, and leave the branch clean + +**Files:** +- Modify: `docs/superpowers/plans/2026-04-08-prebid-generic-bid-param-override-rules.md` (check off completed steps if desired) + +- [ ] **Step 5.1: Run formatting** + +Run: +```bash +cargo fmt --all -- --check +``` + +Expected: PASS + +- [ ] **Step 5.2: Run crate-level verification** + +Run: +```bash +cargo test -p trusted-server-core +cargo clippy -p trusted-server-core --all-targets --all-features -- -D warnings +``` + +Expected: PASS + +- [ ] **Step 5.3: Run workspace verification** + +Run: +```bash +cargo test --workspace +cargo clippy --workspace --all-targets --all-features -- -D warnings +``` + +Expected: PASS + +- [ ] **Step 5.4: Review `git diff` and commit all changes** + +Run: +```bash +git status --short +git diff -- crates/trusted-server-core/src/integrations/prebid.rs crates/trusted-server-core/src/settings.rs trusted-server.toml docs/superpowers/plans/2026-04-08-prebid-generic-bid-param-override-rules.md +git add crates/trusted-server-core/src/integrations/prebid.rs crates/trusted-server-core/src/settings.rs trusted-server.toml docs/superpowers/plans/2026-04-08-prebid-generic-bid-param-override-rules.md +git commit -m "Implement generic Prebid bid param override rules" +``` + +Expected: working tree clean except for any intentionally untracked files. diff --git a/trusted-server.toml b/trusted-server.toml index 3079f308..9ed1e591 100644 --- a/trusted-server.toml +++ b/trusted-server.toml @@ -48,18 +48,27 @@ debug = false client_side_bidders = ["rubicon"] # [integrations.prebid.bid_param_overrides] -# Static per-bidder params merged into every outgoing PBS request. +# Compatibility sugar for static per-bidder params merged into every outgoing +# PBS request. These normalize into bid_param_override_rules internally. # Example: # [integrations.prebid.bid_param_overrides.bidder-name] # param1 = 12345 # param2 = "value" -# Zone-specific bid param overrides for Kargo s2s placement IDs. -# The JS adapter reads the zone from mediaTypes.banner.name on each ad unit -# and includes it in the request. The server maps zone → s2s placementId here. +# Compatibility sugar for zone-specific bid param overrides. +# The JS adapter reads the zone from mediaTypes.banner.name on each ad unit and +# includes it in the request. These normalize into bid_param_override_rules +# internally. [integrations.prebid.bid_param_zone_overrides.kargo] # header = {placementId = "_abc"} +# Preferred canonical override format for future rules. +# Rules run in order with exact-match conditions and shallow last-write-wins merge. +# [[integrations.prebid.bid_param_override_rules]] +# when.bidder = "kargo" +# when.zone = "header" +# set = { placementId = "_abc" } + [integrations.nextjs] enabled = false rewrite_attributes = ["href", "link", "siteBaseUrl", "siteProductionDomain", "url"] @@ -196,4 +205,3 @@ timeout_ms = 1000 # query parameter name. Arrays are joined with commas. [integrations.adserver_mock.context_query_params] permutive_segments = "permutive" - From c4c56acdbc07b96bdff8f6113e834167950185fd Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Wed, 8 Apr 2026 18:01:08 +0530 Subject: [PATCH 09/13] Format docs --- ...prebid-generic-bid-param-override-rules.md | 37 +++++++++++++++++-- 1 file changed, 33 insertions(+), 4 deletions(-) diff --git a/docs/superpowers/plans/2026-04-08-prebid-generic-bid-param-override-rules.md b/docs/superpowers/plans/2026-04-08-prebid-generic-bid-param-override-rules.md index c5835783..3e49e9bf 100644 --- a/docs/superpowers/plans/2026-04-08-prebid-generic-bid-param-override-rules.md +++ b/docs/superpowers/plans/2026-04-08-prebid-generic-bid-param-override-rules.md @@ -12,15 +12,16 @@ ## Files Modified -| File | Changes | -|------|---------| +| File | Changes | +| ------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `crates/trusted-server-core/src/integrations/prebid.rs` | Replace `BidOverride` / `StaticBidOverride` / `ContextKey` / `KeyedBidOverride` runtime with canonical rule structs and one internal engine; add canonical rule parsing, validation, normalization, and runtime application; update config docs and tests | -| `crates/trusted-server-core/src/settings.rs` | Add env parsing coverage for `bid_param_override_rules` | -| `trusted-server.toml` | Document the new canonical rule syntax while keeping compatibility examples | +| `crates/trusted-server-core/src/settings.rs` | Add env parsing coverage for `bid_param_override_rules` | +| `trusted-server.toml` | Document the new canonical rule syntax while keeping compatibility examples | ## Task 1: Add failing tests for canonical rules and compatibility normalization **Files:** + - Modify: `crates/trusted-server-core/src/integrations/prebid.rs` - [ ] **Step 1.1: Add a canonical rule parsing test near the existing Prebid config parsing tests** @@ -158,6 +159,7 @@ fn compile_rule_rejects_non_object_set() { - [ ] **Step 1.5: Run the focused tests and confirm they fail for the expected missing pieces** Run: + ```bash cargo test -p trusted-server-core explicit_bid_param_override_rule cargo test -p trusted-server-core compile_rule_rejects @@ -168,11 +170,13 @@ Expected: failures because `bid_param_override_rules` and compiled-rule validati ## Task 2: Replace the split override runtime with one compiled rule engine **Files:** + - Modify: `crates/trusted-server-core/src/integrations/prebid.rs` - [ ] **Step 2.1: Add the canonical config structs to `PrebidIntegrationConfig`** Add: + ```rust /// Canonical ordered bidder-param override rules. #[serde(default)] @@ -180,6 +184,7 @@ pub bid_param_override_rules: Vec, ``` With new supporting structs: + ```rust #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields)] @@ -201,6 +206,7 @@ pub struct BidParamOverrideWhen { - [ ] **Step 2.2: Delete the current runtime override abstraction block** Delete: + - `BidOverrideContext` - `BidOverride` - `StaticBidOverride` @@ -210,6 +216,7 @@ Delete: - `ZoneBidOverride` Replace them with: + ```rust #[derive(Debug, Default)] struct BidParamOverrideEngine { @@ -233,6 +240,7 @@ struct CompiledBidParamOverrideRule { - [ ] **Step 2.3: Implement compiled-rule validation and engine normalization** Add: + - `impl TryFrom for CompiledBidParamOverrideRule` - helper `fn validate_matcher_string(...)` - helper `fn json_object_for_override(...)` @@ -243,6 +251,7 @@ Add: - explicit `bid_param_override_rules` Validation rules: + - reject empty `when` - reject empty strings for `bidder` or `zone` - reject non-object or empty-object `set` @@ -250,12 +259,14 @@ Validation rules: - [ ] **Step 2.4: Store the compiled engine in `PrebidIntegration` and `PrebidAuctionProvider`** Change the structs from: + ```rust pub struct PrebidIntegration { config: PrebidIntegrationConfig } pub struct PrebidAuctionProvider { config: PrebidIntegrationConfig } ``` To: + ```rust pub struct PrebidIntegration { config: PrebidIntegrationConfig, @@ -273,6 +284,7 @@ Compile the engine in `PrebidIntegration::new` and `PrebidAuctionProvider::new`. - [ ] **Step 2.5: Replace the runtime application path in `to_openrtb`** Replace: + ```rust let ctx = BidOverrideContext { zone }; for (name, params) in &mut bidder { @@ -282,6 +294,7 @@ for (name, params) in &mut bidder { ``` With: + ```rust for (name, params) in &mut bidder { self.bid_param_override_engine.apply( @@ -297,6 +310,7 @@ for (name, params) in &mut bidder { - [ ] **Step 2.6: Run the targeted tests and make them pass** Run: + ```bash cargo test -p trusted-server-core explicit_bid_param_override_rule cargo test -p trusted-server-core compile_rule_rejects @@ -307,12 +321,14 @@ Expected: PASS ## Task 3: Update existing tests to the new engine model and extend env coverage **Files:** + - Modify: `crates/trusted-server-core/src/integrations/prebid.rs` - Modify: `crates/trusted-server-core/src/settings.rs` - [ ] **Step 3.1: Rewrite the old override unit tests to target the engine directly** Replace the current `mod bid_override` tests with engine-centric tests: + - compatibility static rule normalization - compatibility zone rule normalization - `apply` with bidder-only facts @@ -323,6 +339,7 @@ Replace the current `mod bid_override` tests with engine-centric tests: - [ ] **Step 3.2: Remove tests that only exist for deleted types** Delete tests specific to: + - `StaticBidOverride` - `ZoneBidOverride` - `KeyedBidOverride` @@ -331,6 +348,7 @@ Delete tests specific to: - [ ] **Step 3.3: Add env parsing coverage for canonical rules in `settings.rs`** Add a test similar to the existing `bid_param_overrides` env test: + ```rust #[test] fn test_prebid_bid_param_override_rules_override_with_json_env() { @@ -339,6 +357,7 @@ fn test_prebid_bid_param_override_rules_override_with_json_env() { ``` Assert that: + - the array parses - `when.bidder` and `when.zone` survive round-trip - `set` preserves the JSON object @@ -346,6 +365,7 @@ Assert that: - [ ] **Step 3.4: Run the focused Rust tests for Prebid and settings** Run: + ```bash cargo test -p trusted-server-core bid_param_override cargo test -p trusted-server-core test_prebid_bid_param_override @@ -356,6 +376,7 @@ Expected: PASS ## Task 4: Update operator-facing documentation and sample config **Files:** + - Modify: `trusted-server.toml` - Modify: `crates/trusted-server-core/src/integrations/prebid.rs` @@ -371,12 +392,14 @@ Keep the existing compatibility examples, then add the canonical rule format: ``` Document: + - compatibility fields are still supported - canonical rules are preferred for future overrides - [ ] **Step 4.2: Update `PrebidIntegrationConfig` field docs** Adjust the field comments so they describe: + - `bid_param_overrides` as compatibility sugar - `bid_param_zone_overrides` as compatibility sugar - `bid_param_override_rules` as the canonical ordered rule list @@ -384,6 +407,7 @@ Adjust the field comments so they describe: - [ ] **Step 4.3: Run rustdoc-sensitive checks for the touched crate** Run: + ```bash cargo test -p trusted-server-core parse_prebid_toml ``` @@ -393,11 +417,13 @@ Expected: PASS ## Task 5: Verify, commit, and leave the branch clean **Files:** + - Modify: `docs/superpowers/plans/2026-04-08-prebid-generic-bid-param-override-rules.md` (check off completed steps if desired) - [ ] **Step 5.1: Run formatting** Run: + ```bash cargo fmt --all -- --check ``` @@ -407,6 +433,7 @@ Expected: PASS - [ ] **Step 5.2: Run crate-level verification** Run: + ```bash cargo test -p trusted-server-core cargo clippy -p trusted-server-core --all-targets --all-features -- -D warnings @@ -417,6 +444,7 @@ Expected: PASS - [ ] **Step 5.3: Run workspace verification** Run: + ```bash cargo test --workspace cargo clippy --workspace --all-targets --all-features -- -D warnings @@ -427,6 +455,7 @@ Expected: PASS - [ ] **Step 5.4: Review `git diff` and commit all changes** Run: + ```bash git status --short git diff -- crates/trusted-server-core/src/integrations/prebid.rs crates/trusted-server-core/src/settings.rs trusted-server.toml docs/superpowers/plans/2026-04-08-prebid-generic-bid-param-override-rules.md From 2bd1bf433f0b52f6e9d770c4b10f99b40f96888b Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Wed, 8 Apr 2026 18:03:47 +0530 Subject: [PATCH 10/13] Update .env.example for example env --- .env.example | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.env.example b/.env.example index 2e57cb88..5305a62c 100644 --- a/.env.example +++ b/.env.example @@ -41,6 +41,10 @@ TRUSTED_SERVER__INTEGRATIONS__PREBID__ENABLED=false # 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":122112,"param2":"11212323"}}' +# Compatibility env shape for bidder -> zone -> params overrides +# 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__AUTO_CONFIGURE=false # TRUSTED_SERVER__INTEGRATIONS__PREBID__DEBUG=false # TRUSTED_SERVER__INTEGRATIONS__PREBID__TEST_MODE=false From 7e024a2975607461860e35963d2914ab27c50214 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Wed, 8 Apr 2026 21:04:47 +0530 Subject: [PATCH 11/13] Resolve PR findings --- .env.example | 2 +- .../src/integrations/prebid.rs | 172 ++++++++++++------ docs/guide/configuration.md | 48 +++-- docs/guide/integrations/prebid.md | 74 +++++++- 4 files changed, 232 insertions(+), 64 deletions(-) diff --git a/.env.example b/.env.example index 5305a62c..1121ecd9 100644 --- a/.env.example +++ b/.env.example @@ -40,7 +40,7 @@ TRUSTED_SERVER__INTEGRATIONS__PREBID__ENABLED=false # TRUSTED_SERVER__INTEGRATIONS__PREBID__SERVER_URL=https://prebid-server.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":122112,"param2":"11212323"}}' +# TRUSTED_SERVER__INTEGRATIONS__PREBID__BID_PARAM_OVERRIDES='{"bidder-name":{"param1":12345,"param2":"value"}}' # Compatibility env shape for bidder -> zone -> params overrides # TRUSTED_SERVER__INTEGRATIONS__PREBID__BID_PARAM_ZONE_OVERRIDES='{"kargo":{"header":{"placementId":"_abc"}}}' # Preferred canonical env shape for future generic rules diff --git a/crates/trusted-server-core/src/integrations/prebid.rs b/crates/trusted-server-core/src/integrations/prebid.rs index 59ce3cae..a98568d9 100644 --- a/crates/trusted-server-core/src/integrations/prebid.rs +++ b/crates/trusted-server-core/src/integrations/prebid.rs @@ -112,7 +112,7 @@ pub struct PrebidIntegrationConfig { /// fixed_bottom = {placementId = "_s2sBottomId"} /// ``` #[serde(default)] - pub bid_param_zone_overrides: HashMap>, + pub bid_param_zone_overrides: HashMap>>, /// Compatibility sugar for static per-bidder parameter overrides. /// /// These rules are normalized into the canonical @@ -132,7 +132,7 @@ pub struct PrebidIntegrationConfig { /// TRUSTED_SERVER__INTEGRATIONS__PREBID__BID_PARAM_OVERRIDES='{"bidder-name":{"param1":12345,"param2":"value"}}' /// ``` #[serde(default)] - pub bid_param_overrides: HashMap, + pub bid_param_overrides: HashMap>, /// Canonical ordered bidder-param override rules. /// /// Each rule has structured `when` matchers and a non-empty `set` object @@ -181,7 +181,7 @@ pub struct BidParamOverrideRule { /// Structured exact-match conditions for this rule. pub when: BidParamOverrideWhen, /// Parameters shallow-merged into bidder params when the rule matches. - pub set: Json, + pub set: serde_json::Map, } /// Structured exact-match conditions for a [`BidParamOverrideRule`]. @@ -496,6 +496,11 @@ fn expand_trusted_server_bidders( .collect() } +/// Shallow-merges `override_obj` into `params`. +/// +/// When `params` is a JSON object, each key in `override_obj` is inserted +/// or replaced in `params`. When `params` is not an object, it is replaced +/// entirely with the override object. fn merge_bidder_param_object(params: &mut Json, override_obj: &serde_json::Map) { match params { Json::Object(base) => { @@ -575,7 +580,10 @@ impl BidParamOverrideEngine { } impl CompiledBidParamOverrideRule { - fn from_bidder_override(bidder: &str, set: &Json) -> Result> { + fn from_bidder_override( + bidder: &str, + set: &serde_json::Map, + ) -> Result> { Self::new( Some(bidder.to_string()), None, @@ -587,7 +595,7 @@ impl CompiledBidParamOverrideRule { fn from_zone_override( bidder: &str, zone: &str, - set: &Json, + set: &serde_json::Map, ) -> Result> { Self::new( Some(bidder.to_string()), @@ -600,7 +608,7 @@ impl CompiledBidParamOverrideRule { fn new( bidder: Option, zone: Option, - set: &Json, + set: &serde_json::Map, source: &str, ) -> Result> { let bidder = bidder @@ -619,7 +627,7 @@ impl CompiledBidParamOverrideRule { Ok(Self { bidder, zone, - set: json_object_for_override(set, source)?, + set: non_empty_override_object(set, source)?, }) } @@ -667,23 +675,17 @@ fn validate_override_matcher_string( Ok(value) } -fn json_object_for_override( - value: &Json, +fn non_empty_override_object( + value: &serde_json::Map, source: &str, ) -> Result, Report> { - let Json::Object(map) = value else { - return Err(Report::new(TrustedServerError::Configuration { - message: format!("{source}.set must be a JSON object"), - })); - }; - - if map.is_empty() { + if value.is_empty() { return Err(Report::new(TrustedServerError::Configuration { message: format!("{source}.set must not be empty"), })); } - Ok(map.clone()) + Ok(value.clone()) } /// Copies browser headers to the outgoing Prebid Server request. @@ -1603,6 +1605,24 @@ secret_key = "test-secret-key" .expect("should be enabled") } + fn parse_prebid_toml_result( + prebid_section: &str, + ) -> Result> { + let toml_str = format!("{}{}", TOML_BASE, prebid_section); + let settings = Settings::from_toml(&toml_str)?; + settings + .integration_config::("prebid")? + .ok_or_else(|| { + Report::new(TrustedServerError::Configuration { + message: "prebid integration should be enabled".to_string(), + }) + }) + } + + fn json_object(value: Json) -> serde_json::Map { + serde_json::from_value(value).expect("should build JSON object") + } + #[test] fn attribute_rewriter_removes_prebid_scripts() { let integration = PrebidIntegration { @@ -3034,7 +3054,7 @@ pubid = "server-pub" "kargo".to_string(), HashMap::from([( "header".to_string(), - json!({ "placementId": "s2s_header_id" }), + json_object(json!({ "placementId": "s2s_header_id" })), )]), ); @@ -3061,7 +3081,7 @@ pubid = "server-pub" "kargo".to_string(), HashMap::from([( "header".to_string(), - json!({ "placementId": "zone_header_id" }), + json_object(json!({ "placementId": "zone_header_id" })), )]), ); @@ -3089,7 +3109,7 @@ pubid = "server-pub" "kargo".to_string(), HashMap::from([( "header".to_string(), - json!({ "placementId": "zone_header_id" }), + json_object(json!({ "placementId": "zone_header_id" })), )]), ); @@ -3117,7 +3137,7 @@ pubid = "server-pub" "kargo".to_string(), HashMap::from([( "header".to_string(), - json!({ "placementId": "s2s_header_id" }), + json_object(json!({ "placementId": "s2s_header_id" })), )]), ); @@ -3149,7 +3169,10 @@ pubid = "server-pub" config.bidders = vec!["kargo".to_string()]; config.bid_param_zone_overrides.insert( "kargo".to_string(), - HashMap::from([("header".to_string(), json!({ "placementId": "s2s_header" }))]), + HashMap::from([( + "header".to_string(), + json_object(json!({ "placementId": "s2s_header" })), + )]), ); // Client sends extra field alongside placementId @@ -3240,6 +3263,58 @@ set = { placementId = "_s2sHeader", extra = "x" } ); } + #[test] + fn bid_param_overrides_config_rejects_non_object_bidder_value() { + let result = parse_prebid_toml_result( + r#" +[integrations.prebid] +enabled = true +server_url = "https://prebid.example" + +[integrations.prebid.bid_param_overrides] +criteo = "not-an-object" +"#, + ); + + assert!(result.is_err(), "should reject non-object bidder overrides"); + } + + #[test] + fn zone_overrides_config_rejects_non_object_zone_value() { + let result = parse_prebid_toml_result( + r#" +[integrations.prebid] +enabled = true +server_url = "https://prebid.example" + +[integrations.prebid.bid_param_zone_overrides.kargo] +header = "not-an-object" +"#, + ); + + assert!(result.is_err(), "should reject non-object zone overrides"); + } + + #[test] + fn bid_param_override_rules_config_rejects_non_object_set() { + let result = parse_prebid_toml_result( + r#" +[integrations.prebid] +enabled = true +server_url = "https://prebid.example" + +[[integrations.prebid.bid_param_override_rules]] +when.bidder = "kargo" +set = "not-an-object" +"#, + ); + + assert!( + result.is_err(), + "should reject canonical override rules with non-object sets" + ); + } + #[test] fn explicit_bid_param_override_rule_applies_for_bidder_and_zone() { let config = parse_prebid_toml( @@ -3340,9 +3415,10 @@ set = { placementId = "explicit_header" } #[test] fn engine_normalizes_static_compatibility_rule() { let mut config = base_config(); - config - .bid_param_overrides - .insert("criteo".to_string(), json!({ "networkId": 99 })); + config.bid_param_overrides.insert( + "criteo".to_string(), + json_object(json!({ "networkId": 99 })), + ); let engine = BidParamOverrideEngine::try_from_config(&config) .expect("should compile static compatibility overrides"); @@ -3371,7 +3447,7 @@ set = { placementId = "explicit_header" } "kargo".to_string(), HashMap::from([( "header".to_string(), - json!({ "placementId": "zone-header" }), + json_object(json!({ "placementId": "zone-header" })), )]), ); @@ -3399,9 +3475,10 @@ set = { placementId = "explicit_header" } #[test] fn engine_applies_bidder_only_rule() { let mut config = base_config(); - config - .bid_param_overrides - .insert("criteo".to_string(), json!({ "networkId": 42 })); + config.bid_param_overrides.insert( + "criteo".to_string(), + json_object(json!({ "networkId": 42 })), + ); let engine = BidParamOverrideEngine::try_from_config(&config) .expect("should compile bidder-only override"); @@ -3426,7 +3503,10 @@ set = { placementId = "explicit_header" } let mut config = base_config(); config.bid_param_zone_overrides.insert( "kargo".to_string(), - HashMap::from([("header".to_string(), json!({ "placementId": "s2s_h" }))]), + HashMap::from([( + "header".to_string(), + json_object(json!({ "placementId": "s2s_h" })), + )]), ); let engine = BidParamOverrideEngine::try_from_config(&config) @@ -3450,7 +3530,10 @@ set = { placementId = "explicit_header" } let mut config = base_config(); config.bid_param_zone_overrides.insert( "kargo".to_string(), - HashMap::from([("header".to_string(), json!({ "placementId": "s2s_h" }))]), + HashMap::from([( + "header".to_string(), + json_object(json!({ "placementId": "s2s_h" })), + )]), ); let engine = BidParamOverrideEngine::try_from_config(&config) @@ -3474,15 +3557,16 @@ set = { placementId = "explicit_header" } #[test] fn engine_applies_later_rule_last_write_wins() { let mut config = base_config(); - config - .bid_param_overrides - .insert("kargo".to_string(), json!({ "placementId": "compat" })); + config.bid_param_overrides.insert( + "kargo".to_string(), + json_object(json!({ "placementId": "compat" })), + ); config.bid_param_override_rules.push(BidParamOverrideRule { when: BidParamOverrideWhen { bidder: Some("kargo".to_string()), zone: Some("header".to_string()), }, - set: json!({ "placementId": "explicit", "extra": "x" }), + set: json_object(json!({ "placementId": "explicit", "extra": "x" })), }); let engine = BidParamOverrideEngine::try_from_config(&config) @@ -3512,27 +3596,13 @@ set = { placementId = "explicit_header" } fn compile_rule_rejects_empty_when() { let rule = BidParamOverrideRule { when: BidParamOverrideWhen::default(), - set: json!({ "placementId": "x" }), + set: json_object(json!({ "placementId": "x" })), }; let result = CompiledBidParamOverrideRule::try_from(rule); assert!(result.is_err(), "should reject empty when"); } - #[test] - fn compile_rule_rejects_non_object_set() { - let rule = BidParamOverrideRule { - when: BidParamOverrideWhen { - bidder: Some("kargo".to_string()), - zone: None, - }, - set: json!("not-an-object"), - }; - - let result = CompiledBidParamOverrideRule::try_from(rule); - assert!(result.is_err(), "should reject non-object set"); - } - #[test] fn compile_rule_rejects_empty_object_set() { let rule = BidParamOverrideRule { @@ -3540,7 +3610,7 @@ set = { placementId = "explicit_header" } bidder: Some("kargo".to_string()), zone: None, }, - set: json!({}), + set: serde_json::Map::new(), }; let result = CompiledBidParamOverrideRule::try_from(rule); diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 029163bb..bfa655d9 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -697,17 +697,20 @@ apply when the integration section exists in `trusted-server.toml`. **Section**: `[integrations.prebid]` -| Field | Type | Default | Description | -| --------------------- | ------------- | ---------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | -| `enabled` | Boolean | `true` | Enable Prebid integration | -| `server_url` | String | Required | Prebid Server endpoint URL | -| `timeout_ms` | Integer | `1000` | Request timeout in milliseconds | -| `bidders` | Array[String] | `["mocktioneer"]` | List of enabled bidders | -| `debug` | Boolean | `false` | Enable debug mode (sets `ext.prebid.debug` and `returnallbidstatus`; surfaces debug metadata in responses) | -| `test_mode` | Boolean | `false` | Set OpenRTB `test: 1` flag for non-billable test traffic (independent of `debug`) | -| `debug_query_params` | String | `None` | Extra query params appended for debugging | -| `client_side_bidders` | Array[String] | `[]` | Bidders that run client-side via native Prebid.js adapters instead of server-side (see [Prebid docs](/guide/integrations/prebid#client-side-bidders)) | -| `script_patterns` | Array[String] | `["/prebid.js", "/prebid.min.js", "/prebidjs.js", "/prebidjs.min.js"]` | URL patterns for Prebid script interception | +| Field | Type | Default | Description | +| -------------------------- | ------------- | ---------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | +| `enabled` | Boolean | `true` | Enable Prebid integration | +| `server_url` | String | Required | Prebid Server endpoint URL | +| `timeout_ms` | Integer | `1000` | Request timeout in milliseconds | +| `bidders` | Array[String] | `["mocktioneer"]` | List of enabled bidders | +| `bid_param_overrides` | Table | `{}` | Static per-bidder param overrides; normalized into the canonical override-rule engine and shallow-merged into bidder params | +| `bid_param_zone_overrides` | Table | `{}` | Per-bidder, per-zone param overrides; normalized into the canonical override-rule engine and shallow-merged into bidder params | +| `bid_param_override_rules` | Array[Table] | `[]` | Canonical ordered override rules with `when` matchers and `set` objects; evaluated after compatibility fields so later rules win on conflicts | +| `debug` | Boolean | `false` | Enable debug mode (sets `ext.prebid.debug` and `returnallbidstatus`; surfaces debug metadata in responses) | +| `test_mode` | Boolean | `false` | Set OpenRTB `test: 1` flag for non-billable test traffic (independent of `debug`) | +| `debug_query_params` | String | `None` | Extra query params appended for debugging | +| `client_side_bidders` | Array[String] | `[]` | Bidders that run client-side via native Prebid.js adapters instead of server-side (see [Prebid docs](/guide/integrations/prebid#client-side-bidders)) | +| `script_patterns` | Array[String] | `["/prebid.js", "/prebid.min.js", "/prebidjs.js", "/prebidjs.min.js"]` | URL patterns for Prebid script interception | **Example**: @@ -725,6 +728,18 @@ client_side_bidders = ["rubicon"] # Customize script interception (optional) script_patterns = ["/prebid.js", "/prebid.min.js"] + +[integrations.prebid.bid_param_overrides.criteo] +networkId = 99999 +pubid = "server-pub" + +[integrations.prebid.bid_param_zone_overrides.kargo] +header = { placementId = "_s2sHeaderPlacement" } + +[[integrations.prebid.bid_param_override_rules]] +when.bidder = "kargo" +when.zone = "header" +set = { placementId = "_s2sHeaderPlacement" } ``` **Environment Override**: @@ -734,6 +749,9 @@ TRUSTED_SERVER__INTEGRATIONS__PREBID__ENABLED=true TRUSTED_SERVER__INTEGRATIONS__PREBID__SERVER_URL=https://prebid.example/auction TRUSTED_SERVER__INTEGRATIONS__PREBID__TIMEOUT_MS=1200 TRUSTED_SERVER__INTEGRATIONS__PREBID__BIDDERS=kargo,appnexus,openx +TRUSTED_SERVER__INTEGRATIONS__PREBID__BID_PARAM_OVERRIDES='{"criteo":{"networkId":99999,"pubid":"server-pub"}}' +TRUSTED_SERVER__INTEGRATIONS__PREBID__BID_PARAM_ZONE_OVERRIDES='{"kargo":{"header":{"placementId":"_s2sHeaderPlacement"}}}' +TRUSTED_SERVER__INTEGRATIONS__PREBID__BID_PARAM_OVERRIDE_RULES='[{"when":{"bidder":"kargo","zone":"header"},"set":{"placementId":"_s2sHeaderPlacement"}}]' TRUSTED_SERVER__INTEGRATIONS__PREBID__CLIENT_SIDE_BIDDERS=rubicon TRUSTED_SERVER__INTEGRATIONS__PREBID__DEBUG=false TRUSTED_SERVER__INTEGRATIONS__PREBID__TEST_MODE=false @@ -751,6 +769,14 @@ The `script_patterns` configuration determines which Prebid scripts are intercep See [Prebid Integration](/guide/integrations/prebid) for full details. +**Bid Param Override Surfaces**: + +- `bid_param_overrides`: Static per-bidder shallow-merge overrides. +- `bid_param_zone_overrides`: Per-bidder, per-zone shallow-merge overrides. +- `bid_param_override_rules`: Canonical ordered rules with `when` matchers and `set` objects. + +Compatibility fields are normalized into the same runtime engine as canonical rules. Explicit `bid_param_override_rules` run after compatibility-derived rules, so later canonical rules win on conflicts. + ### Next.js Integration **Section**: `[integrations.nextjs]` diff --git a/docs/guide/integrations/prebid.md b/docs/guide/integrations/prebid.md index e9ba6fcb..1e028a1e 100644 --- a/docs/guide/integrations/prebid.md +++ b/docs/guide/integrations/prebid.md @@ -30,10 +30,21 @@ client_side_bidders = ["rubicon"] # Script interception patterns (optional - defaults shown below) script_patterns = ["/prebid.js", "/prebid.min.js", "/prebidjs.js", "/prebidjs.min.js"] +# Optional static per-bidder param overrides (shallow merge) +[integrations.prebid.bid_param_overrides.criteo] +networkId = 99999 +pubid = "server-pub" + # Optional per-bidder, per-zone param overrides (shallow merge) [integrations.prebid.bid_param_zone_overrides.kargo] header = {placementId = "_s2sHeaderPlacement"} in_content = {placementId = "_s2sContentPlacement"} + +# Optional canonical ordered override rules +[[integrations.prebid.bid_param_override_rules]] +when.bidder = "kargo" +when.zone = "header" +set = { placementId = "_s2sHeaderPlacement" } ``` ### Configuration Options @@ -44,7 +55,9 @@ in_content = {placementId = "_s2sContentPlacement"} | `server_url` | String | Required | Prebid Server endpoint URL | | `timeout_ms` | Integer | `1000` | Request timeout in milliseconds | | `bidders` | Array[String] | `["mocktioneer"]` | List of enabled bidders | -| `bid_param_zone_overrides` | Table | `{}` | Per-bidder, per-zone param overrides; zone values are shallow-merged into bidder params | +| `bid_param_overrides` | Table | `{}` | Static per-bidder param overrides; normalized into the canonical override-rule engine and shallow-merged into bidder params | +| `bid_param_zone_overrides` | Table | `{}` | Per-bidder, per-zone param overrides; normalized into the canonical override-rule engine and shallow-merged into bidder params | +| `bid_param_override_rules` | Array[Table] | `[]` | Canonical ordered override rules with `when` matchers and `set` objects; evaluated after compatibility fields so later rules win on conflicts | | `debug` | Boolean | `false` | Enable Prebid debug mode (sets `ext.prebid.debug` and `ext.prebid.returnallbidstatus`; surfaces debug metadata in auction responses) | | `test_mode` | Boolean | `false` | Set the OpenRTB `test: 1` flag so bidders treat the auction as non-billable test traffic. Separate from `debug` to avoid suppressing real demand | | `debug_query_params` | String | `None` | Extra query params appended for debugging | @@ -147,6 +160,32 @@ script_patterns = [] When a request matches a script pattern, Trusted Server returns an empty JavaScript file with aggressive caching (`max-age=31536000, immutable`). +### Bid Param Overrides + +Use `bid_param_overrides` for static per-bidder param overrides when the same override should apply regardless of ad zone. + +**Behavior**: + +- Overrides are matched by bidder name only +- Override params are shallow-merged into incoming bidder params +- Override values win on key conflicts +- Unrelated incoming fields are preserved +- These compatibility entries are normalized into the same runtime engine as `bid_param_override_rules` + +**Example**: + +```toml +[integrations.prebid.bid_param_overrides.criteo] +networkId = 99999 +pubid = "server-pub" +``` + +**Environment variable**: + +```text +TRUSTED_SERVER__INTEGRATIONS__PREBID__BID_PARAM_OVERRIDES='{"criteo":{"networkId":99999,"pubid":"server-pub"}}' +``` + ### Bid Param Zone Overrides Use `bid_param_zone_overrides` for per-zone, per-bidder param overrides. This is designed for bidders like Kargo that use different server-to-server placement IDs per ad zone. @@ -159,6 +198,7 @@ The JS adapter reads the zone from `mediaTypes.banner.name` on each Prebid ad un - Override params are shallow-merged into incoming bidder params (override values win on key conflicts) - Non-conflicting incoming fields are preserved - When no zone override matches (unknown zone or missing zone), incoming params are left unchanged +- These compatibility entries are normalized into the same runtime engine as `bid_param_override_rules` **Example**: @@ -183,6 +223,38 @@ the outgoing bidder params become: For an unrecognised zone (e.g., `sidebar`), the incoming params are left unchanged. +**Environment variable**: + +```text +TRUSTED_SERVER__INTEGRATIONS__PREBID__BID_PARAM_ZONE_OVERRIDES='{"kargo":{"header":{"placementId":"_s2sHeaderPlacement"}}}' +``` + +### Bid Param Override Rules + +Use `bid_param_override_rules` for the canonical ordered override format. Each rule contains exact-match `when` conditions and a non-empty `set` object that is shallow-merged into bidder params when all populated matchers match. + +**Behavior**: + +- Rules can match on `when.bidder`, `when.zone`, or both +- Rules are evaluated in declaration order +- Later matching rules win on overlapping keys +- Compatibility fields from `bid_param_overrides` and `bid_param_zone_overrides` are normalized into earlier rules, so explicit canonical rules take precedence on conflicts + +**Example**: + +```toml +[[integrations.prebid.bid_param_override_rules]] +when.bidder = "kargo" +when.zone = "header" +set = { placementId = "_s2sHeaderPlacement", keep = "server" } +``` + +**Environment variable**: + +```text +TRUSTED_SERVER__INTEGRATIONS__PREBID__BID_PARAM_OVERRIDE_RULES='[{"when":{"bidder":"kargo","zone":"header"},"set":{"placementId":"_s2sHeaderPlacement","keep":"server"}}]' +``` + ## Client-Side Bidders Some Prebid.js bid adapters do not work well through Prebid Server (e.g. Magnite/Rubicon). The `client_side_bidders` config field lets you keep these bidders running natively in the browser while routing all other bidders through the server-side auction. From 107510b85b3e30f9dcfaba45d268493c47af68d5 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Thu, 9 Apr 2026 15:52:43 +0530 Subject: [PATCH 12/13] Address PR review fundings --- .../src/integrations/prebid.rs | 131 ++++++++++++++++-- 1 file changed, 118 insertions(+), 13 deletions(-) diff --git a/crates/trusted-server-core/src/integrations/prebid.rs b/crates/trusted-server-core/src/integrations/prebid.rs index a98568d9..eaa57ff5 100644 --- a/crates/trusted-server-core/src/integrations/prebid.rs +++ b/crates/trusted-server-core/src/integrations/prebid.rs @@ -540,15 +540,25 @@ impl BidParamOverrideEngine { ) -> Result> { let mut rules = Vec::new(); - for (bidder, set) in &config.bid_param_overrides { + let mut bidder_overrides = config.bid_param_overrides.iter().collect::>(); + bidder_overrides.sort_by(|(left, _), (right, _)| left.as_str().cmp(right.as_str())); + + for (bidder, set) in bidder_overrides { rules.push(CompiledBidParamOverrideRule::from_bidder_override( bidder.as_str(), set, )?); } - for (bidder, zone_overrides) in &config.bid_param_zone_overrides { - for (zone, set) in zone_overrides { + let mut zone_overrides = config.bid_param_zone_overrides.iter().collect::>(); + zone_overrides.sort_by(|(left, _), (right, _)| left.as_str().cmp(right.as_str())); + + for (bidder, zone_override_sets) in zone_overrides { + let mut sorted_zone_overrides = zone_override_sets.iter().collect::>(); + sorted_zone_overrides + .sort_by(|(left, _), (right, _)| left.as_str().cmp(right.as_str())); + + for (zone, set) in sorted_zone_overrides { rules.push(CompiledBidParamOverrideRule::from_zone_override( bidder.as_str(), zone.as_str(), @@ -585,7 +595,7 @@ impl CompiledBidParamOverrideRule { set: &serde_json::Map, ) -> Result> { Self::new( - Some(bidder.to_string()), + Some(bidder), None, set, &format!("integrations.prebid.bid_param_overrides.{bidder}"), @@ -598,16 +608,16 @@ impl CompiledBidParamOverrideRule { set: &serde_json::Map, ) -> Result> { Self::new( - Some(bidder.to_string()), - Some(zone.to_string()), + Some(bidder), + Some(zone), set, &format!("integrations.prebid.bid_param_zone_overrides.{bidder}.{zone}"), ) } fn new( - bidder: Option, - zone: Option, + bidder: Option<&str>, + zone: Option<&str>, set: &serde_json::Map, source: &str, ) -> Result> { @@ -653,8 +663,8 @@ impl TryFrom for CompiledBidParamOverrideRule { fn try_from(rule: BidParamOverrideRule) -> Result { Self::new( - rule.when.bidder, - rule.when.zone, + rule.when.bidder.as_deref(), + rule.when.zone.as_deref(), &rule.set, "integrations.prebid.bid_param_override_rules[*]", ) @@ -662,17 +672,19 @@ impl TryFrom for CompiledBidParamOverrideRule { } fn validate_override_matcher_string( - value: String, + value: &str, field: &str, source: &str, ) -> Result> { - if value.trim().is_empty() { + let trimmed = value.trim(); + + if trimmed.is_empty() { return Err(Report::new(TrustedServerError::Configuration { message: format!("{source}.{field} must not be empty"), })); } - Ok(value) + Ok(trimmed.to_string()) } fn non_empty_override_object( @@ -3412,6 +3424,14 @@ set = { placementId = "explicit_header" } Json::Object(map) } + fn rule_signature(rule: &CompiledBidParamOverrideRule) -> String { + format!( + "{}:{}", + rule.bidder.as_deref().unwrap_or("*"), + rule.zone.as_deref().unwrap_or("*") + ) + } + #[test] fn engine_normalizes_static_compatibility_rule() { let mut config = base_config(); @@ -3592,6 +3612,91 @@ set = { placementId = "explicit_header" } assert_eq!(params["keep"], "yes", "should preserve unrelated params"); } + #[test] + fn compile_rule_trims_matcher_whitespace() { + let rule = BidParamOverrideRule { + when: BidParamOverrideWhen { + bidder: Some(" kargo ".to_string()), + zone: Some(" header ".to_string()), + }, + set: json_object(json!({ "placementId": "trimmed" })), + }; + + let compiled = CompiledBidParamOverrideRule::try_from(rule) + .expect("should compile rule with surrounding whitespace"); + + assert_eq!( + compiled.bidder.as_deref(), + Some("kargo"), + "should store trimmed bidder matcher" + ); + assert_eq!( + compiled.zone.as_deref(), + Some("header"), + "should store trimmed zone matcher" + ); + assert!( + compiled.matches(BidParamOverrideFacts { + bidder: "kargo", + zone: Some("header"), + }), + "trimmed matchers should match normalized request facts" + ); + } + + #[test] + fn engine_compiles_compatibility_rules_in_sorted_matcher_order() { + let expected = [ + "alpha:*", + "beta:*", + "gamma:*", + "kargo:footer", + "kargo:header", + "openx:sidebar", + ]; + + for iteration in 0..16 { + let mut config = base_config(); + config.bid_param_overrides = HashMap::from([ + ("gamma".to_string(), json_object(json!({ "networkId": 3 }))), + ("alpha".to_string(), json_object(json!({ "networkId": 1 }))), + ("beta".to_string(), json_object(json!({ "networkId": 2 }))), + ]); + config.bid_param_zone_overrides = HashMap::from([ + ( + "openx".to_string(), + HashMap::from([( + "sidebar".to_string(), + json_object(json!({ "placementId": "openx-sidebar" })), + )]), + ), + ( + "kargo".to_string(), + HashMap::from([ + ( + "header".to_string(), + json_object(json!({ "placementId": "kargo-header" })), + ), + ( + "footer".to_string(), + json_object(json!({ "placementId": "kargo-footer" })), + ), + ]), + ), + ]); + + let engine = BidParamOverrideEngine::try_from_config(&config) + .expect("should compile compatibility overrides"); + let actual = engine.rules.iter().map(rule_signature).collect::>(); + + assert_eq!( + actual, + expected, + "compatibility rules should compile in sorted matcher order on iteration {iteration}" + ); + } + } + #[test] fn compile_rule_rejects_empty_when() { let rule = BidParamOverrideRule { From c637cf47a42bdd01080974bfe00c54f11af7bb5c Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Fri, 10 Apr 2026 16:36:01 +0530 Subject: [PATCH 13/13] Address review findings --- .../src/integrations/prebid.rs | 80 ++++++++++++++++--- 1 file changed, 71 insertions(+), 9 deletions(-) diff --git a/crates/trusted-server-core/src/integrations/prebid.rs b/crates/trusted-server-core/src/integrations/prebid.rs index eaa57ff5..fc643e29 100644 --- a/crates/trusted-server-core/src/integrations/prebid.rs +++ b/crates/trusted-server-core/src/integrations/prebid.rs @@ -136,7 +136,7 @@ pub struct PrebidIntegrationConfig { /// Canonical ordered bidder-param override rules. /// /// Each rule has structured `when` matchers and a non-empty `set` object - /// that is shallow-merged into the bidder params when every matcher + /// that is deep-merged into the bidder params when every matcher /// matches. Compatibility fields such as [`bid_param_overrides`](Self::bid_param_overrides) /// and [`bid_param_zone_overrides`](Self::bid_param_zone_overrides) are /// normalized into the same runtime rule engine before request handling. @@ -173,14 +173,14 @@ impl IntegrationConfig for PrebidIntegrationConfig { /// Canonical bidder-param override rule. /// /// A rule matches against the request-time facts in [`BidParamOverrideWhen`] -/// and shallow-merges [`set`](Self::set) into the bidder params when all +/// and deep-merges [`set`](Self::set) into the bidder params when all /// populated matchers are equal. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct BidParamOverrideRule { /// Structured exact-match conditions for this rule. pub when: BidParamOverrideWhen, - /// Parameters shallow-merged into bidder params when the rule matches. + /// Parameters deep-merged into bidder params when the rule matches. pub set: serde_json::Map, } @@ -230,6 +230,9 @@ pub struct PrebidIntegration { impl PrebidIntegration { fn try_new(config: PrebidIntegrationConfig) -> Result, Report> { + // Validate override rules eagerly so that `register()` fails fast before the + // integration is added to the registry. `PrebidAuctionProvider::try_new` repeats + // this build when the auction provider is registered from the same config. let _ = BidParamOverrideEngine::try_from_config(&config)?; Ok(Arc::new(Self { config })) } @@ -496,15 +499,27 @@ fn expand_trusted_server_bidders( .collect() } -/// Shallow-merges `override_obj` into `params`. +/// Deep-merges `override_obj` into `params`. /// -/// When `params` is a JSON object, each key in `override_obj` is inserted -/// or replaced in `params`. When `params` is not an object, it is replaced -/// entirely with the override object. +/// When `params` is a JSON object, each key in `override_obj` is inserted or +/// replaced in `params`. When both the existing value and the override value +/// are JSON objects, the merge recurses so nested objects are merged rather +/// than replaced. When `params` is not an object, it is replaced entirely +/// with the override object. fn merge_bidder_param_object(params: &mut Json, override_obj: &serde_json::Map) { match params { Json::Object(base) => { - base.extend(override_obj.iter().map(|(k, v)| (k.clone(), v.clone()))); + for (k, v) in override_obj { + let existing = base.entry(k.clone()).or_insert(Json::Null); + if existing.is_object() && v.is_object() { + merge_bidder_param_object( + existing, + v.as_object().expect("should be a JSON object"), + ); + } else { + *existing = v.clone(); + } + } } _ => { *params = Json::Object(override_obj.clone()); @@ -1626,7 +1641,7 @@ secret_key = "test-secret-key" .integration_config::("prebid")? .ok_or_else(|| { Report::new(TrustedServerError::Configuration { - message: "prebid integration should be enabled".to_string(), + message: "prebid integration config should be present and enabled".to_string(), }) }) } @@ -3041,6 +3056,53 @@ pubid = "server-pub" ); } + #[test] + fn bidder_param_override_deep_merges_nested_objects() { + let config = parse_prebid_toml( + r#" +[integrations.prebid] +enabled = true +server_url = "https://prebid.example" +bidders = ["appnexus"] + +[[integrations.prebid.bid_param_override_rules]] +when = { bidder = "appnexus" } +set = { keywords = { genre = "news" } } +"#, + ); + + // Client sends a nested `keywords` object with an existing key alongside + // one that the override does not touch. + let slot = make_ts_slot( + "ad-header-0", + &json!({ + "appnexus": { + "placementId": 12345, + "keywords": { "sport": "football", "genre": "sports" } + } + }), + None, + ); + let request = make_auction_request(vec![slot]); + + let ortb = call_to_openrtb(config, &request); + let params = bidder_params(&ortb); + let keywords = ¶ms["appnexus"]["keywords"]; + + assert_eq!( + keywords["genre"], "news", + "override should replace the matching nested key" + ); + assert_eq!( + keywords["sport"], "football", + "deep merge should preserve unrelated nested keys" + ); + assert_eq!( + params["appnexus"]["placementId"], 12345, + "deep merge should preserve top-level keys not in the override" + ); + } + // ======================================================================== // bid_param_zone_overrides tests // ========================================================================