diff --git a/.env.example b/.env.example index c62b4a25..1121ecd9 100644 --- a/.env.example +++ b/.env.example @@ -40,6 +40,11 @@ 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":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 +# 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 diff --git a/crates/trusted-server-core/src/integrations/prebid.rs b/crates/trusted-server-core/src/integrations/prebid.rs index 1ced7059..fc643e29 100644 --- a/crates/trusted-server-core/src/integrations/prebid.rs +++ b/crates/trusted-server-core/src/integrations/prebid.rs @@ -97,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 @@ -110,7 +112,49 @@ 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 + /// [`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 + /// [integrations.prebid.bid_param_overrides.bidder-name] + /// param1 = 12345 + /// param2 = "value" + /// ``` + /// + /// Example via environment variable: + /// ```text + /// TRUSTED_SERVER__INTEGRATIONS__PREBID__BID_PARAM_OVERRIDES='{"bidder-name":{"param1":12345,"param2":"value"}}' + /// ``` + #[serde(default)] + 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 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. + /// + /// 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 @@ -126,6 +170,33 @@ impl IntegrationConfig for PrebidIntegrationConfig { } } +/// Canonical bidder-param override rule. +/// +/// A rule matches against the request-time facts in [`BidParamOverrideWhen`] +/// 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 deep-merged into bidder params when the rule matches. + pub set: serde_json::Map, +} + +/// 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 } @@ -158,8 +229,17 @@ 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 })) + } + + #[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 { @@ -263,7 +343,7 @@ fn build( } } - Ok(Some(PrebidIntegration::new(config))) + Ok(Some(PrebidIntegration::try_new(config)?)) } /// Register the Prebid integration when enabled. @@ -418,6 +498,223 @@ fn expand_trusted_server_bidders( }) .collect() } + +/// Deep-merges `override_obj` into `params`. +/// +/// 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) => { + 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()); + } + } +} + +// ============================================================================ +// Generic bid-parameter override engine +// ============================================================================ + +#[derive(Debug, Default)] +struct BidParamOverrideEngine { + rules: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct CompiledBidParamOverrideRule { + bidder: Option, + zone: Option, + set: serde_json::Map, +} + +#[derive(Debug, Copy, Clone)] +struct BidParamOverrideFacts<'a> { + bidder: &'a str, + zone: Option<&'a str>, +} + +impl BidParamOverrideEngine { + fn try_from_config( + config: &PrebidIntegrationConfig, + ) -> Result> { + let mut rules = Vec::new(); + + 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, + )?); + } + + 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(), + set, + )?); + } + } + + for rule in &config.bid_param_override_rules { + rules.push(CompiledBidParamOverrideRule::try_from(rule.clone())?); + } + + Ok(Self { rules }) + } + + 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); + } + } + } +} + +impl CompiledBidParamOverrideRule { + fn from_bidder_override( + bidder: &str, + set: &serde_json::Map, + ) -> Result> { + Self::new( + Some(bidder), + None, + set, + &format!("integrations.prebid.bid_param_overrides.{bidder}"), + ) + } + + fn from_zone_override( + bidder: &str, + zone: &str, + set: &serde_json::Map, + ) -> Result> { + Self::new( + Some(bidder), + Some(zone), + set, + &format!("integrations.prebid.bid_param_zone_overrides.{bidder}.{zone}"), + ) + } + + fn new( + bidder: Option<&str>, + zone: Option<&str>, + set: &serde_json::Map, + 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, + set: non_empty_override_object(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 TryFrom for CompiledBidParamOverrideRule { + type Error = Report; + + fn try_from(rule: BidParamOverrideRule) -> Result { + Self::new( + rule.when.bidder.as_deref(), + rule.when.zone.as_deref(), + &rule.set, + "integrations.prebid.bid_param_override_rules[*]", + ) + } +} + +fn validate_override_matcher_string( + value: &str, + field: &str, + source: &str, +) -> Result> { + let trimmed = value.trim(); + + if trimmed.is_empty() { + return Err(Report::new(TrustedServerError::Configuration { + message: format!("{source}.{field} must not be empty"), + })); + } + + Ok(trimmed.to_string()) +} + +fn non_empty_override_object( + value: &serde_json::Map, + source: &str, +) -> Result, Report> { + if value.is_empty() { + return Err(Report::new(TrustedServerError::Configuration { + message: format!("{source}.set must not be empty"), + })); + } + + Ok(value.clone()) +} + /// Copies browser headers to the outgoing Prebid Server request. /// /// In [`ConsentForwardingMode::OpenrtbOnly`] mode, consent cookies are @@ -464,13 +761,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. @@ -540,26 +855,10 @@ impl PrebidAuctionProvider { } } - // Apply zone-specific bid param overrides when configured. + // Apply canonical and compatibility-derived rules in normalized order. 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 { - 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()))); - } - } + self.bid_param_override_engine + .apply(BidParamOverrideFacts { bidder: name, zone }, params); } Some(Imp { @@ -1194,7 +1493,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"); @@ -1244,7 +1543,9 @@ mod tests { debug_query_params: None, script_patterns: default_script_patterns(), client_side_bidders: Vec::new(), - bid_param_zone_overrides: HashMap::new(), + bid_param_zone_overrides: HashMap::default(), + bid_param_overrides: HashMap::default(), + bid_param_override_rules: Vec::new(), consent_forwarding: ConsentForwardingMode::Both, } } @@ -1331,6 +1632,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 config should be present and 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 { @@ -2688,6 +3007,102 @@ server_url = "https://prebid.example" serde_json::from_value(ext["prebid"].clone()).expect("should deserialise ext.prebid") } + // ======================================================================== + // bid_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.bid_param_overrides.criteo] +networkId = 99999 +pubid = "server-pub" +"#, + ); + + let slot = make_ts_slot( + "ad-header-0", + &json!({ + "criteo": { + "networkId": 11111, + "pubid": "client-pub", + "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"], 99999, + "override should replace the client-side networkId" + ); + assert_eq!( + params["criteo"]["pubid"], "server-pub", + "override should replace the client-side pubid" + ); + assert_eq!( + params["criteo"]["keep"], "present", + "override should preserve unrelated bidder params" + ); + } + + #[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 // ======================================================================== @@ -2713,7 +3128,7 @@ server_url = "https://prebid.example" "kargo".to_string(), HashMap::from([( "header".to_string(), - json!({ "placementId": "s2s_header_id" }), + json_object(json!({ "placementId": "s2s_header_id" })), )]), ); @@ -2740,7 +3155,7 @@ server_url = "https://prebid.example" "kargo".to_string(), HashMap::from([( "header".to_string(), - json!({ "placementId": "zone_header_id" }), + json_object(json!({ "placementId": "zone_header_id" })), )]), ); @@ -2768,7 +3183,7 @@ server_url = "https://prebid.example" "kargo".to_string(), HashMap::from([( "header".to_string(), - json!({ "placementId": "zone_header_id" }), + json_object(json!({ "placementId": "zone_header_id" })), )]), ); @@ -2796,7 +3211,7 @@ server_url = "https://prebid.example" "kargo".to_string(), HashMap::from([( "header".to_string(), - json!({ "placementId": "s2s_header_id" }), + json_object(json!({ "placementId": "s2s_header_id" })), )]), ); @@ -2828,7 +3243,10 @@ server_url = "https://prebid.example" 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 @@ -2883,6 +3301,490 @@ 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 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( + 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" + ); + } + + // ======================================================================== + // BidParamOverrideEngine unit tests + // ======================================================================== + + mod bid_param_override_engine { + 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) + } + + 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(); + 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"); + + assert_eq!(engine.rules.len(), 1, "should compile one static rule"); + assert_eq!( + engine.rules[0].bidder.as_deref(), + Some("criteo"), + "should set bidder matcher" + ); + assert_eq!( + 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 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_object(json!({ "placementId": "zone-header" })), + )]), + ); + + let engine = BidParamOverrideEngine::try_from_config(&config) + .expect("should compile zone compatibility overrides"); + + 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" + ); + } + + #[test] + fn engine_applies_bidder_only_rule() { + let mut config = base_config(); + 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"); + let mut params = empty_params(); + + engine.apply( + BidParamOverrideFacts { + bidder: "criteo", + zone: Some("header"), + }, + &mut params, + ); + + assert_eq!( + params["networkId"], 42, + "bidder-only override should apply regardless of zone" + ); + } + + #[test] + 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_object(json!({ "placementId": "s2s_h" })), + )]), + ); + + let engine = BidParamOverrideEngine::try_from_config(&config) + .expect("should compile bidder-and-zone override"); + let mut params = params_with("placementId", json!("client")); + engine.apply( + BidParamOverrideFacts { + bidder: "kargo", + zone: Some("header"), + }, + &mut params, + ); + assert_eq!( + params["placementId"], "s2s_h", + "bidder-and-zone override should apply when both facts match" + ); + } + + #[test] + 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_object(json!({ "placementId": "s2s_h" })), + )]), + ); + + let engine = BidParamOverrideEngine::try_from_config(&config) + .expect("should compile unmatched override"); + let mut params = params_with("placementId", json!("client")); + + engine.apply( + BidParamOverrideFacts { + bidder: "kargo", + zone: Some("sidebar"), + }, + &mut params, + ); + + assert_eq!( + params["placementId"], "client", + "should leave params unchanged when facts do not match" + ); + } + + #[test] + fn engine_applies_later_rule_last_write_wins() { + let mut config = base_config(); + 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_object(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, + ); + + assert_eq!( + params["placementId"], "explicit", + "later canonical rule should override earlier compatibility rule" + ); + assert_eq!( + params["extra"], "x", + "should merge additional explicit keys" + ); + 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 { + when: BidParamOverrideWhen::default(), + 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_empty_object_set() { + let rule = BidParamOverrideRule { + when: BidParamOverrideWhen { + bidder: Some("kargo".to_string()), + zone: None, + }, + set: serde_json::Map::new(), + }; + + let result = CompiledBidParamOverrideRule::try_from(rule); + assert!(result.is_err(), "should reject empty set object"); + } + } + #[test] fn enrich_response_metadata_attaches_always_on_fields() { let provider = PrebidAuctionProvider::new(base_config()); diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index 78549262..1c8ae790 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -1031,6 +1031,113 @@ mod tests { ); } + #[test] + fn test_prebid_bid_param_overrides_override_with_json_env() { + let toml_str = crate_test_settings_str(); + let env_key = format!( + "{}{}INTEGRATIONS{}PREBID{}BID_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":99999,"pubid":"server-pub"}}"#), + || { + 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["bid_param_overrides"]["criteo"]["networkId"], + json!(99999), + "should deserialize networkId override from env JSON" + ); + assert_eq!( + cfg_json["bid_param_overrides"]["criteo"]["pubid"], + json!("server-pub"), + "should deserialize pubid override from env JSON" + ); + }, + ); + }, + ); + } + + #[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/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. 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..3e49e9bf --- /dev/null +++ b/docs/superpowers/plans/2026-04-08-prebid-generic-bid-param-override-rules.md @@ -0,0 +1,466 @@ +# 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/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. diff --git a/trusted-server.toml b/trusted-server.toml index d9189aaa..9ed1e591 100644 --- a/trusted-server.toml +++ b/trusted-server.toml @@ -47,12 +47,28 @@ 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.bid_param_overrides] +# 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" + +# 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"] @@ -189,5 +205,3 @@ timeout_ms = 1000 # query parameter name. Arrays are joined with commas. [integrations.adserver_mock.context_query_params] permutive_segments = "permutive" - -