Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- TSJS-generated envelopes now send `trustedServer.params.storedRequest: false`, preventing accidental PBS stored lookups without suppressing eligible non-PBS demand. PBS filters unusable impressions after overrides; explicit `true` and legacy omission retain inline-first stored fallback. Publisher intent survives repeated and refresh auctions. Deploy compatible server admission everywhere before serving the new JS, and retain it during rollback while cached clients remain. See the Prebid deployment guide.
- Protocol-relative creative URLs now honor `rewrite.exclude_domains`, so excluded creative assets stay direct and excluded absolute or protocol-relative URLs submitted to `/first-party/sign` are rejected.
- Server-side ad template bids now always carry `hb_adid` in `window.tsjs.bids`. Bidders that return neither a Prebid Cache UUID nor an `adid` previously produced no `hb_adid` at all, so no `hb_adid` GPT targeting key was set and the Universal Creative render bridge had nothing to match — the winning creative never rendered. The OpenRTB bid `id`, which is mandatory per spec, is now the last-resort source; `cache_id` and `adid` still take priority where present. Blank `cacheId`/`adid` values no longer win that precedence and emit an unusable empty `hb_adid`, and `hb_cache_host`/`hb_cache_path` are now emitted only alongside a real Prebid Cache UUID — without one they pointed the Universal Creative at a guaranteed cache miss instead of letting it fall through to the inline creative.

Expand Down
28 changes: 24 additions & 4 deletions crates/trusted-server-core/src/auction/formats.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,10 +51,12 @@ pub struct AdRequest {
/// `code` identifies the slot (e.g. `"atf_sidebar_ad"`) and becomes the
/// impression ID in the outgoing `OpenRTB` request.
///
/// `bids` is optional. When absent or empty the PBS provider falls back to
/// a stored-request keyed by `code` (`imp.ext.prebid.storedrequest.id`).
/// When present, each entry's params are forwarded inline to PBS as
/// `imp.ext.prebid.bidder.<bidder>`.
/// `bids` is optional. Absent or empty bids retain legacy PBS stored fallback
/// keyed by `code` (`imp.ext.prebid.storedrequest.id`). Bidder params route through
/// the server-owned auction plan. The reserved `trustedServer` entry accepts
/// `bidderParams`, `zone`, and boolean `storedRequest` inside its params. False
/// disables stored fallback, true permits it, and omission retains legacy
/// inference. Usable inline PBS params take precedence after provider overrides.
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AdUnit {
Expand Down Expand Up @@ -743,6 +745,24 @@ mod tests {
.expect("should convert banner request")
}

#[test]
fn tsjs_wire_stored_intent_survives_conversion_and_atomic_admission() {
for (intent, expected_inputs, malformed) in [
(json!(true), 1, 0),
(json!(false), 0, 0),
(json!(null), 0, 1),
] {
let body: AdRequest = serde_json::from_value(json!({
"adUnits":[{"code":"example-slot","mediaTypes":{"banner":{"sizes":[[300,250]]}},
"bids":[{"bidder":"trustedServer","params":{"bidderParams":{},"storedRequest":intent}}]}]
})).expect("should deserialize wire request");
let request = convert_body_to_auction_request(&body, &make_settings());
let routed = route_auction(request, &make_request(), &single_prebid_plan(), None);
assert_eq!(routed.inputs().len(), expected_inputs);
assert_eq!(routed.diagnostics().malformed_envelope_count(), malformed);
}
}

#[test]
fn canonical_tsjs_request_without_bids_feeds_stored_request_router() {
let body = AdRequest {
Expand Down
69 changes: 40 additions & 29 deletions crates/trusted-server-core/src/auction/openrtb.rs
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,9 @@ pub(crate) fn build_request(
return Ok(OpenRtbBuildOutcome::NoImpressions);
}
policy.augment_request(&mut request, input, routed)?;
if request.imp.is_empty() {
return Ok(OpenRtbBuildOutcome::NoImpressions);
}
finalize_request(&mut request, policy, finalization)?;
Ok(OpenRtbBuildOutcome::Ready(request))
}
Expand Down Expand Up @@ -482,35 +485,43 @@ fn apply_prebid(
input.slots().len(),
"should keep one impression per routed slot"
);
for (imp, slot) in request.imp.iter_mut().zip(input.slots()) {
let bidder = slot
.bidder_params()
.iter()
.filter_map(|(bidder, params)| {
let mut params = params.clone();
plan.override_engine
.apply_routed(bidder.as_str(), slot.prebid_zone(), &mut params);
params
.as_object()
.is_some_and(|params| !params.is_empty())
.then(|| (bidder.as_str().to_string(), params))
})
.collect::<Map<_, _>>();
let mut prebid = Map::new();
if !bidder.is_empty() {
prebid.insert("bidder".to_string(), Value::Object(bidder));
} else if slot.has_trusted_stored_request() || !slot.bidder_params().is_empty() {
prebid.insert("storedrequest".to_string(), json!({"id": slot.slot().id}));
}
debug_assert!(
!prebid.is_empty(),
"should never route a demandless slot to prebid-server"
);
imp.ext = Some(Map::from_iter([(
"prebid".to_string(),
Value::Object(prebid),
)]));
}
// Filter paired impressions and slots together so later demand keeps its slot ID.
request.imp = std::mem::take(&mut request.imp)
.into_iter()
.zip(input.slots())
.filter_map(|(mut imp, slot)| {
let bidder = slot
.bidder_params()
.iter()
.filter_map(|(bidder, params)| {
let mut params = params.clone();
plan.override_engine.apply_routed(
bidder.as_str(),
slot.prebid_zone(),
&mut params,
);
params
.as_object()
.is_some_and(|params| !params.is_empty())
.then(|| (bidder.as_str().to_string(), params))
})
.collect::<Map<_, _>>();
let mut prebid = Map::new();
if !bidder.is_empty() {
prebid.insert("bidder".to_string(), Value::Object(bidder));
} else if slot.allows_stored_fallback() {
prebid.insert("storedrequest".to_string(), json!({"id": slot.slot().id}));
}
if prebid.is_empty() {
return None;
}
imp.ext = Some(Map::from_iter([(
"prebid".to_string(),
Value::Object(prebid),
)]));
Some(imp)
})
.collect();
let mut prebid_request = Map::new();
if plan.debug {
prebid_request.insert("debug".to_string(), Value::Bool(true));
Expand Down
174 changes: 174 additions & 0 deletions crates/trusted-server-core/src/auction/openrtb/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -546,6 +546,180 @@ fn pbs_pairs_each_impression_with_its_routed_slot_params() {
);
}

#[test]
fn pbs_stored_intent_is_applied_after_overrides_with_inline_first() {
for intent in [None, Some(false), Some(true)] {
for inline in [false, true] {
for fill_override in [false, true] {
let profile = if fill_override {
json!({"bid_param_overrides":{"exampleBidder":{"filled":1}}})
} else {
json!({})
};
let plan = AuctionPlan::compile(config("prebid-server", profile))
.expect("should compile plan");
let mut request = canonical_parity_auction_request();
let mut envelope = json!({"bidderParams":{"exampleBidder":if inline { json!({"original":1}) } else { json!({}) }}});
if let Some(intent) = intent {
envelope["storedRequest"] = json!(intent);
}
request.slots[0].bidders = HashMap::from([("trustedServer".to_string(), envelope)]);
let inbound = Request::new(EdgeBody::empty());
let routed = route_auction(request, &inbound, &plan, None);
let result = build_request(
&routed.inputs()[0],
&routed,
&plan.providers()[0],
321,
&finalization(None),
)
.expect("should build request");
if !inline && !fill_override && intent == Some(false) {
assert!(matches!(result, OpenRtbBuildOutcome::NoImpressions));
continue;
}
let OpenRtbBuildOutcome::Ready(request) = result else {
panic!("should retain demand")
};
let wire = serde_json::to_value(request).expect("should serialize request");
let prebid = &wire["imp"][0]["ext"]["prebid"];
if inline || fill_override {
assert!(prebid.get("storedrequest").is_none());
assert_eq!(
prebid["bidder"]["exampleBidder"].get("original"),
inline.then_some(&json!(1))
);
assert_eq!(
prebid["bidder"]["exampleBidder"].get("filled"),
fill_override.then_some(&json!(1))
);
} else {
assert_eq!(prebid["storedrequest"]["id"], "fictional-slot");
}
}
}
}
}

#[test]
fn pbs_filtering_keeps_slot_pairs_and_drops_demandless_trusted_routes() {
let plan =
AuctionPlan::compile(config("prebid-server", json!({}))).expect("should compile plan");
let mut request = canonical_parity_auction_request();
let template = request.slots[0].clone();
request.slots = [
(
"drop-first",
json!({"storedRequest":false,"bidderParams":{"exampleBidder":{}}}),
),
(
"keep-inline",
json!({"storedRequest":false,"bidderParams":{"exampleBidder":{"id":2}}}),
),
(
"drop-trusted",
json!({"storedRequest":false,"bidderParams":{}}),
),
(
"keep-stored",
json!({"storedRequest":true,"bidderParams":{}}),
),
]
.into_iter()
.map(|(id, envelope)| AdSlot {
id: id.to_string(),
bidders: HashMap::from([("trustedServer".to_string(), envelope)]),
..template.clone()
})
.collect();
let inbound = Request::new(EdgeBody::empty());
let routes = crate::auction::routing::TrustedProviderRoutes::new(vec![
vec![],
vec![],
vec![plan.providers()[0].id.clone()],
vec![],
]);
let routed = crate::auction::routing::route_auction_with_trusted_routes(
request, &inbound, &plan, None, &routes,
);
assert_eq!(
routed.inputs()[0].slots().len(),
4,
"should preserve server-owned route admission"
);
let result = build_request(
&routed.inputs()[0],
&routed,
&plan.providers()[0],
321,
&finalization(None),
)
.expect("should build request");
let OpenRtbBuildOutcome::Ready(request) = result else {
panic!("should retain siblings")
};
let wire = serde_json::to_value(request).expect("should serialize request");
assert_eq!(
wire["imp"]
.as_array()
.expect("should have impressions")
.len(),
2
);
assert_eq!(wire["imp"][0]["id"], "keep-inline");
assert_eq!(
wire["imp"][0]["ext"]["prebid"]["bidder"]["exampleBidder"],
json!({"id":2})
);
assert_eq!(wire["imp"][1]["id"], "keep-stored");
assert_eq!(
wire["imp"][1]["ext"]["prebid"]["storedrequest"]["id"],
"keep-stored"
);
}

#[test]
fn pbs_disabled_empty_candidate_does_not_become_stored_demand() {
let mut raw = config("prebid-server", json!({}));
raw.providers
.get_mut(&ProviderId::from_str("fictional-provider").expect("should parse provider"))
.expect("should find provider")
.routing = RoutingMode::Explicit;
raw.bidders.insert(
crate::auction::plan::BidderId::from_str("exampleBidder").expect("should parse bidder"),
BidderRouteConfig {
provider: ProviderId::from_str("fictional-provider").expect("should parse provider"),
},
);
let plan = AuctionPlan::compile(raw).expect("should compile PBS plan");
let mut request = canonical_parity_auction_request();
request.slots[0].bidders = HashMap::from([(
"trustedServer".to_string(),
json!({"bidderParams":{"exampleBidder":{}}, "storedRequest": false}),
)]);
let inbound = Request::builder()
.uri("https://publisher.example.com/auction")
.body(EdgeBody::empty())
.expect("should build inbound request");
let routed = route_auction(request, &inbound, &plan, None);
assert_eq!(
routed.inputs().len(),
1,
"should route candidate for overrides"
);
assert!(matches!(
build_request(
&routed.inputs()[0],
&routed,
&plan.providers()[0],
321,
&finalization(None),
)
.expect("should build request"),
OpenRtbBuildOutcome::NoImpressions
));
}

#[test]
fn pbs_empty_params_without_matching_override_fall_back_to_stored_request() {
let mut raw = config("prebid-server", json!({}));
Expand Down
Loading
Loading