Skip to content
Open
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 @@ -21,6 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed

- 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.

### Added

Expand Down
1 change: 1 addition & 0 deletions crates/trusted-server-core/src/auction/formats.rs
Original file line number Diff line number Diff line change
Expand Up @@ -439,6 +439,7 @@ mod tests {
height: 250,
nurl: None,
burl: None,
bid_id: None,
ad_id: None,
cache_id: None,
cache_host: None,
Expand Down
6 changes: 6 additions & 0 deletions crates/trusted-server-core/src/auction/orchestrator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1700,6 +1700,7 @@ mod tests {
height: 90,
nurl: nurl.clone(),
burl: nurl,
bid_id: None,
ad_id: Some("creative-123".to_string()),
cache_id: Some("cache-abc".to_string()),
cache_host: None,
Expand Down Expand Up @@ -2039,6 +2040,7 @@ mod tests {
height: 250,
nurl: None,
burl: None,
bid_id: None,
ad_id: None,
cache_id: None,
cache_host: None,
Expand All @@ -2059,6 +2061,7 @@ mod tests {
height: 250,
nurl: None,
burl: None,
bid_id: None,
ad_id: None,
cache_id: None,
cache_host: None,
Expand Down Expand Up @@ -3154,6 +3157,7 @@ mod tests {
height: 250,
nurl: None,
burl: None,
bid_id: None,
ad_id: None,
cache_id: None,
cache_host: None,
Expand Down Expand Up @@ -3204,6 +3208,7 @@ mod tests {
height: 250,
nurl: None,
burl: None,
bid_id: None,
ad_id: None,
cache_id: None,
cache_host: None,
Expand Down Expand Up @@ -3240,6 +3245,7 @@ mod tests {
height: 250,
nurl: None,
burl: None,
bid_id: None,
ad_id: None,
cache_id: None,
cache_host: None,
Expand Down
1 change: 1 addition & 0 deletions crates/trusted-server-core/src/auction/telemetry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -960,6 +960,7 @@ mod tests {
height: 250,
nurl: None,
burl: None,
bid_id: None,
ad_id: ad_id.map(str::to_owned),
cache_id: None,
cache_host: None,
Expand Down
11 changes: 11 additions & 0 deletions crates/trusted-server-core/src/auction/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,14 @@ pub struct Bid {
pub nurl: Option<String>,
/// Billing notification URL
pub burl: Option<String>,
/// `OpenRTB` bid identifier — the `id` of the bid object itself.
///
/// Distinct from [`ad_id`](Self::ad_id): unique per bid instance rather
/// than a creative identifier. Always present per the `OpenRTB` spec, so it
/// is the last-resort `hb_adid` source for bidders that return neither a
/// Prebid Cache UUID nor `adid`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bid_id: Option<String>,
/// Ad ID from the bidder
pub ad_id: Option<String>,
/// Prebid Cache UUID for this bid.
Expand Down Expand Up @@ -338,6 +346,7 @@ mod tests {
height: 250,
nurl: None,
burl: None,
bid_id: None,
ad_id: None,
cache_id: None,
cache_host: None,
Expand Down Expand Up @@ -467,6 +476,7 @@ mod tests {
height: 250,
nurl: None,
burl: None,
bid_id: None,
ad_id: Some("bid-id".to_string()),
cache_id: Some("cache-uuid".to_string()),
cache_host: Some("cache.example.com".to_string()),
Expand Down Expand Up @@ -514,6 +524,7 @@ mod tests {
height: 250,
nurl: None,
burl: None,
bid_id: None,
ad_id: Some("prebid-ad-id-abc".to_string()),
cache_id: None,
cache_host: None,
Expand Down
5 changes: 5 additions & 0 deletions crates/trusted-server-core/src/integrations/adserver_mock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,7 @@ impl AdServerMockProvider {
}),
nurl: original.and_then(|b| b.nurl.clone()),
burl: original.and_then(|b| b.burl.clone()),
bid_id: None,
ad_id: original.and_then(|b| b.ad_id.clone()),
cache_id: original.and_then(|b| b.cache_id.clone()),
cache_host: original.and_then(|b| b.cache_host.clone()),
Expand Down Expand Up @@ -642,6 +643,7 @@ mod tests {
adomain: Some(vec!["amazon.com".to_string()]),
nurl: None,
burl: None,
bid_id: None,
ad_id: None,
cache_id: None,
cache_host: None,
Expand All @@ -665,6 +667,7 @@ mod tests {
adomain: None,
nurl: Some("https://ssp.example/win?id=mock-bid-001".to_string()),
burl: Some("https://ssp.example/bill?id=mock-bid-001".to_string()),
bid_id: None,
ad_id: Some("mock-bid-001".to_string()),
cache_id: None,
cache_host: None,
Expand Down Expand Up @@ -784,6 +787,7 @@ mod tests {
height: 90,
nurl: Some("https://ssp.example/win".to_string()),
burl: Some("https://ssp.example/bill".to_string()),
bid_id: None,
ad_id: Some("bid-impression-id".to_string()),
cache_id: Some("cache-uuid".to_string()),
cache_host: Some("cache.example".to_string()),
Expand Down Expand Up @@ -903,6 +907,7 @@ mod tests {
adomain: Some(vec!["amazon.com".to_string()]),
nurl: None,
burl: None,
bid_id: None,
ad_id: None,
cache_id: None,
cache_host: None,
Expand Down
1 change: 1 addition & 0 deletions crates/trusted-server-core/src/integrations/aps.rs
Original file line number Diff line number Diff line change
Expand Up @@ -481,6 +481,7 @@ impl ApsAuctionProvider {
height,
nurl: None, // Real APS uses client-side event tracking
burl: None,
bid_id: None,
ad_id: None,
cache_id: None,
cache_host: None,
Expand Down
36 changes: 36 additions & 0 deletions crates/trusted-server-core/src/integrations/prebid.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2223,6 +2223,8 @@ impl PrebidAuctionProvider {
// not an ad ID, so it is not used as a fallback: surfacing it as `ad_id`
// (which is exposed raw in the debug bid) would mislead any consumer that
// treats `ad_id` as a creative identifier. Absent `adid`, `ad_id` is None.
// The bid ID is carried separately in `bid_id` instead.
let bid_id = bid_obj.get("id").and_then(|v| v.as_str()).map(String::from);
let ad_id = bid_obj
.get("adid")
.and_then(|v| v.as_str())
Expand Down Expand Up @@ -2291,6 +2293,7 @@ impl PrebidAuctionProvider {
height,
nurl,
burl,
bid_id,
ad_id,
cache_id,
cache_host,
Expand Down Expand Up @@ -7307,6 +7310,39 @@ set = { networkId = 42 }
Some("cache-uuid-xyz"),
"should extract cache UUID separately"
);
assert_eq!(
bid.bid_id.as_deref(),
Some("bid-impression-id"),
"should keep the OpenRTB bid id separate from ad_id"
);
}

#[test]
fn parse_bid_keeps_bid_id_when_adid_and_cache_are_absent() {
// The shape that leaves hb_adid with no other source: `id` is mandatory
// per OpenRTB, `adid` is optional and omitted by some bidders, and no
// Prebid Cache entry exists because the creative ships inline.
let bid_json = serde_json::json!({
"id": "019f7e2a-b45b-70b0-a2d1-b651c430700b",
"impid": "atf_sidebar_ad",
"price": 1.0,
"w": 300,
"h": 250,
});
let provider = PrebidAuctionProvider::new(base_config());
let bid = provider
.parse_bid(&bid_json, "example-bidder")
.expect("should parse bid");
assert_eq!(
bid.bid_id.as_deref(),
Some("019f7e2a-b45b-70b0-a2d1-b651c430700b"),
"should populate bid_id from the OpenRTB bid id"
);
assert!(bid.ad_id.is_none(), "should not synthesize ad_id from id");
assert!(
bid.cache_id.is_none(),
"should not synthesize cache_id from id"
);
}

#[test]
Expand Down
81 changes: 76 additions & 5 deletions crates/trusted-server-core/src/publisher.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3277,7 +3277,20 @@ pub(crate) fn build_bid_map(
// hb_adid: use PBS Cache UUID when present — the Prebid Universal Creative uses
// this as the cache lookup key, NOT the OpenRTB bid ID (bid.ad_id). Fall back to
// bid.ad_id for APS and other non-PBS providers.
let hb_adid = bid.cache_id.as_deref().or(bid.ad_id.as_deref());
//
// `bid.bid_id` (the OpenRTB bid's own `id`) is the last resort: it is
// always present per spec but only unique per bid instance, not a
// creative identifier. It still satisfies what hb_adid needs here —
// a stable value GAM's Universal Creative echoes back verbatim so
// the render bridge can find this exact winning bid — for bidders
// that return neither a cache UUID nor `adid`. Without it those
// bids carry no hb_adid at all, so no targeting key reaches GAM and
// the render handshake can never start.
let hb_adid = bid
.cache_id
.as_deref()
.or(bid.ad_id.as_deref())
.or(bid.bid_id.as_deref());
if let Some(id) = hb_adid {
obj.insert(
"hb_adid".to_string(),
Expand Down Expand Up @@ -4017,6 +4030,7 @@ mod tests {
height: 250,
nurl: None,
burl: None,
bid_id: None,
ad_id: None,
cache_id: None,
cache_host: None,
Expand Down Expand Up @@ -8120,6 +8134,7 @@ mod tests {
height: 250,
nurl: Some(nurl.to_string()),
burl: Some(burl.to_string()),
bid_id: None,
ad_id: Some(ad_id.to_string()),
cache_id: None,
cache_host: None,
Expand Down Expand Up @@ -8858,6 +8873,9 @@ mod tests {
height: 250,
nurl: None,
burl: None,
// Present alongside cache_id/ad_id to prove cache_id still wins
// — bid_id is the last resort, not a co-equal fallback.
bid_id: Some("should-be-ignored-bid-id".to_string()),
ad_id: Some("bid-impression-id".to_string()),
cache_id: Some("f47447a0-b759-4f2f-9887-af458b79b570".to_string()),
cache_host: Some("openads.adsrvr.org".to_string()),
Expand All @@ -8880,7 +8898,7 @@ mod tests {
assert_eq!(
obj.get("hb_adid").and_then(|v| v.as_str()),
Some("f47447a0-b759-4f2f-9887-af458b79b570"),
"should use cache_id for hb_adid, not ad_id"
"should use cache_id for hb_adid, not ad_id or bid_id"
);
assert_eq!(
obj.get("hb_cache_host").and_then(|v| v.as_str()),
Expand Down Expand Up @@ -8910,6 +8928,9 @@ mod tests {
height: 250,
nurl: None,
burl: None,
// Present alongside ad_id to prove ad_id still wins — bid_id
// is the last resort, not a co-equal fallback.
bid_id: Some("should-be-ignored-bid-id".to_string()),
ad_id: Some("aps-bid-token".to_string()),
cache_id: None,
cache_host: None,
Expand All @@ -8932,7 +8953,7 @@ mod tests {
assert_eq!(
obj.get("hb_adid").and_then(|v| v.as_str()),
Some("aps-bid-token"),
"should fall back to ad_id when cache_id absent"
"should fall back to ad_id when cache_id absent, ignoring bid_id"
);
assert!(
obj.get("hb_cache_host").is_none(),
Expand All @@ -8945,7 +8966,55 @@ mod tests {
}

#[test]
fn bid_map_omits_hb_adid_when_both_cache_id_and_ad_id_absent() {
fn bid_map_falls_back_to_bid_id_when_cache_id_and_ad_id_absent() {
// Real shape for bidders that return neither a Prebid Cache UUID nor
// `adid` in the OpenRTB response, but always carry `id` (the bid's own
// identifier) per spec. Without this fallback the bid reaches the page
// with no hb_adid, so no targeting key is set and the render bridge
// never receives a matching `Prebid Request`.
let mut winning_bids = HashMap::new();
winning_bids.insert(
"atf_sidebar_ad".to_string(),
Bid {
slot_id: "atf_sidebar_ad".to_string(),
price: Some(1.00),
currency: "USD".to_string(),
creative: None,
adomain: None,
bidder: "example-bidder".to_string(),
width: 300,
height: 250,
nurl: None,
burl: None,
bid_id: Some("019f7e2a-b45b-70b0-a2d1-b651c430700b".to_string()),
ad_id: None,
cache_id: None,
cache_host: None,
cache_path: None,
metadata: Default::default(),
},
);
let map = build_bid_map(
&winning_bids,
PriceGranularity::Dense,
&test_settings(),
"",
false,
);
let obj = map
.get("atf_sidebar_ad")
.expect("should have bid entry")
.as_object()
.expect("should be object");
assert_eq!(
obj.get("hb_adid").and_then(|v| v.as_str()),
Some("019f7e2a-b45b-70b0-a2d1-b651c430700b"),
"should fall back to bid_id when cache_id and ad_id are both absent"
);
}

#[test]
fn bid_map_omits_hb_adid_when_cache_id_ad_id_and_bid_id_all_absent() {
let mut winning_bids = HashMap::new();
winning_bids.insert(
"atf_sidebar_ad".to_string(),
Expand All @@ -8960,6 +9029,7 @@ mod tests {
height: 250,
nurl: None,
burl: None,
bid_id: None,
ad_id: None,
cache_id: None,
cache_host: None,
Expand All @@ -8981,7 +9051,7 @@ mod tests {
.expect("should be object");
assert!(
obj.get("hb_adid").is_none(),
"should omit hb_adid when no cache_id and no ad_id"
"should omit hb_adid when no cache_id, ad_id, or bid_id"
);
}

Expand All @@ -9001,6 +9071,7 @@ mod tests {
height: 250,
nurl: None,
burl: None,
bid_id: None,
ad_id: None,
cache_id: None,
cache_host: None,
Expand Down
Loading