diff --git a/crates/trusted-server-adapter-fastly/src/main.rs b/crates/trusted-server-adapter-fastly/src/main.rs index dee4105b..93c41deb 100644 --- a/crates/trusted-server-adapter-fastly/src/main.rs +++ b/crates/trusted-server-adapter-fastly/src/main.rs @@ -153,7 +153,16 @@ async fn route_request( // Unified auction endpoint (returns creative HTML inline) (Method::POST, "/auction") => { - handle_auction(settings, orchestrator, kv_graph.as_ref(), &ec_context, req).await + let partner_store = require_partner_store(settings).ok(); + handle_auction( + settings, + orchestrator, + kv_graph.as_ref(), + partner_store.as_ref(), + &ec_context, + req, + ) + .await } // tsjs endpoints diff --git a/crates/trusted-server-core/src/auction/endpoints.rs b/crates/trusted-server-core/src/auction/endpoints.rs index b747e0af..6cbc58cb 100644 --- a/crates/trusted-server-core/src/auction/endpoints.rs +++ b/crates/trusted-server-core/src/auction/endpoints.rs @@ -4,9 +4,13 @@ use error_stack::{Report, ResultExt}; use fastly::{Request, Response}; use crate::auction::formats::AdRequest; +use crate::consent::gate_eids_by_consent; +use crate::ec::eids::{resolve_partner_ids, to_eids}; use crate::ec::kv::KvIdentityGraph; +use crate::ec::partner::PartnerStore; use crate::ec::EcContext; use crate::error::TrustedServerError; +use crate::openrtb::Eid; use crate::settings::Settings; use super::formats::{convert_to_openrtb_response, convert_tsjs_to_auction_request}; @@ -29,7 +33,8 @@ use super::AuctionOrchestrator; pub async fn handle_auction( settings: &Settings, orchestrator: &AuctionOrchestrator, - _kv: Option<&KvIdentityGraph>, + kv: Option<&KvIdentityGraph>, + partner_store: Option<&PartnerStore>, ec_context: &EcContext, mut req: Request, ) -> Result> { @@ -58,10 +63,22 @@ pub async fn handle_auction( }; let consent_context = ec_context.consent().clone(); + // Resolve partner EIDs from the KV identity graph when the user has + // a valid EC and both KV and partner stores are available. + let eids = resolve_auction_eids(kv, partner_store, ec_context); + // Convert tsjs request format to auction request - let auction_request = + let mut auction_request = convert_tsjs_to_auction_request(&body, settings, &req, consent_context, ec_id)?; + // Apply consent gating to the resolved EIDs before attaching them to the + // auction request. `gate_eids_by_consent` checks TCF Purpose 1 + 4. + let had_eids = eids.as_ref().is_some_and(|v| !v.is_empty()); + auction_request.user.eids = gate_eids_by_consent(eids, auction_request.user.consent.as_ref()); + if had_eids && auction_request.user.eids.is_none() { + log::debug!("Auction EIDs stripped by TCF consent gating"); + } + // Create auction context let context = AuctionContext { settings, @@ -86,5 +103,126 @@ pub async fn handle_auction( ); // Convert to OpenRTB response format with inline creative HTML - convert_to_openrtb_response(&result, settings, &auction_request) + convert_to_openrtb_response(&result, settings, &auction_request, ec_context.ec_allowed()) +} + +/// Resolves partner EIDs from the KV identity graph for bidstream decoration. +/// +/// Returns `None` when any prerequisite is missing (no KV store, no partner +/// store, no EC, consent denied). On KV or partner-resolution errors, logs a +/// warning and returns empty EIDs so the auction can proceed in degraded mode. +fn resolve_auction_eids( + kv: Option<&KvIdentityGraph>, + partner_store: Option<&PartnerStore>, + ec_context: &EcContext, +) -> Option> { + let kv = kv?; + let partner_store = partner_store?; + + if !ec_context.ec_allowed() { + return None; + } + + let ec_hash = ec_context.ec_hash()?; + + let entry = match kv.get(ec_hash) { + Ok(Some((entry, _generation))) => entry, + Ok(None) => return Some(Vec::new()), + Err(err) => { + log::warn!("Auction KV read failed for EC hash '{ec_hash}': {err:?}"); + return Some(Vec::new()); + } + }; + + match resolve_partner_ids(partner_store, &entry) { + Ok(resolved) => Some(to_eids(&resolved)), + Err(err) => { + log::warn!("Auction partner resolution failed: {err:?}"); + Some(Vec::new()) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::consent::jurisdiction::Jurisdiction; + use crate::consent::types::ConsentContext; + + fn make_ec_context(jurisdiction: Jurisdiction, ec_value: Option<&str>) -> EcContext { + EcContext::new_for_test( + ec_value.map(str::to_owned), + ConsentContext { + jurisdiction, + ..ConsentContext::default() + }, + ) + } + + #[test] + fn resolve_auction_eids_returns_none_without_kv() { + let partner_store = PartnerStore::new("test_store"); + let ec_id = format!("{}.ABC123", "a".repeat(64)); + let ec_context = make_ec_context(Jurisdiction::NonRegulated, Some(&ec_id)); + + let result = resolve_auction_eids(None, Some(&partner_store), &ec_context); + assert!(result.is_none(), "should return None when KV is missing"); + } + + #[test] + fn resolve_auction_eids_returns_none_without_partner_store() { + let kv = KvIdentityGraph::new("test_store"); + let ec_id = format!("{}.ABC123", "a".repeat(64)); + let ec_context = make_ec_context(Jurisdiction::NonRegulated, Some(&ec_id)); + + let result = resolve_auction_eids(Some(&kv), None, &ec_context); + assert!( + result.is_none(), + "should return None when partner store is missing" + ); + } + + #[test] + fn resolve_auction_eids_returns_none_when_consent_denied() { + let kv = KvIdentityGraph::new("test_store"); + let partner_store = PartnerStore::new("test_store"); + let ec_id = format!("{}.ABC123", "a".repeat(64)); + let ec_context = make_ec_context(Jurisdiction::Unknown, Some(&ec_id)); + + let result = resolve_auction_eids(Some(&kv), Some(&partner_store), &ec_context); + assert!( + result.is_none(), + "should return None when consent is denied" + ); + } + + #[test] + fn resolve_auction_eids_returns_none_when_no_ec() { + let kv = KvIdentityGraph::new("test_store"); + let partner_store = PartnerStore::new("test_store"); + let ec_context = make_ec_context(Jurisdiction::NonRegulated, None); + + let result = resolve_auction_eids(Some(&kv), Some(&partner_store), &ec_context); + assert!( + result.is_none(), + "should return None when no EC value is present" + ); + } + + #[test] + fn resolve_auction_eids_returns_empty_on_kv_miss() { + let kv = KvIdentityGraph::new("nonexistent_store"); + let partner_store = PartnerStore::new("nonexistent_store"); + let ec_id = format!("{}.ABC123", "a".repeat(64)); + let ec_context = make_ec_context(Jurisdiction::NonRegulated, Some(&ec_id)); + + // KV store doesn't exist, so the get() call will error — should return + // empty Vec (degraded mode), not None. + let result = resolve_auction_eids(Some(&kv), Some(&partner_store), &ec_context); + let eids = result.expect("should return Some on KV error (degraded mode)"); + assert!( + eids.is_empty(), + "should return empty vec on KV error (degraded mode)" + ); + } } diff --git a/crates/trusted-server-core/src/auction/formats.rs b/crates/trusted-server-core/src/auction/formats.rs index 37f38c01..4fdb40ce 100644 --- a/crates/trusted-server-core/src/auction/formats.rs +++ b/crates/trusted-server-core/src/auction/formats.rs @@ -14,8 +14,11 @@ use uuid::Uuid; use crate::auction::context::ContextValue; use crate::consent::ConsentContext; -use crate::constants::HEADER_X_TS_EC; +use crate::constants::{ + HEADER_X_TS_EC, HEADER_X_TS_EC_CONSENT, HEADER_X_TS_EIDS, HEADER_X_TS_EIDS_TRUNCATED, +}; use crate::creative; +use crate::ec::eids::encode_eids_header; use crate::error::TrustedServerError; use crate::geo::GeoInfo; use crate::openrtb::{to_openrtb_i32, OpenRtbBid, OpenRtbResponse, ResponseExt, SeatBid, ToExt}; @@ -181,6 +184,7 @@ pub fn convert_tsjs_to_auction_request( user: UserInfo { id: ec_id, consent: Some(consent), + eids: None, }, device, site: Some(SiteInfo { @@ -204,6 +208,7 @@ pub fn convert_to_openrtb_response( result: &OrchestrationResult, settings: &Settings, auction_request: &AuctionRequest, + ec_allowed: bool, ) -> Result> { // Build OpenRTB-style seatbid array let mut seatbids = Vec::with_capacity(result.winning_bids.len()); @@ -304,8 +309,157 @@ pub fn convert_to_openrtb_response( message: "Failed to serialize auction response".to_string(), })?; - Ok(Response::from_status(StatusCode::OK) + let mut response = Response::from_status(StatusCode::OK) .with_header(header::CONTENT_TYPE, "application/json") .with_header(HEADER_X_TS_EC, &auction_request.user.id) - .with_body(body_bytes)) + .with_body(body_bytes); + + // Signal consent status independently of whether EIDs were resolved. + // A user may have granted consent but have no partner syncs yet; + // downstream clients rely on this header to know consent was verified. + if ec_allowed { + response.set_header(HEADER_X_TS_EC_CONSENT, "ok"); + } + + // Attach EID response headers when consent-gated EIDs are available. + // `Some(empty)` means "we looked and found no synced partners" — the + // header is still set (with an encoded empty array) so clients can + // distinguish this from `None` (EIDs not checked / consent denied). + if let Some(ref eids) = auction_request.user.eids { + let (encoded, truncated) = encode_eids_header(eids)?; + response.set_header(HEADER_X_TS_EIDS, encoded); + if truncated { + response.set_header(HEADER_X_TS_EIDS_TRUNCATED, "true"); + } + } + + Ok(response) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::auction::orchestrator::OrchestrationResult; + use crate::auction::types::{AdFormat, AdSlot, MediaType}; + use crate::constants::{HEADER_X_TS_EC_CONSENT, HEADER_X_TS_EIDS, HEADER_X_TS_EIDS_TRUNCATED}; + use crate::openrtb::{Eid, Uid}; + + fn make_minimal_auction_request() -> AuctionRequest { + AuctionRequest { + id: "test-auction".to_owned(), + slots: vec![AdSlot { + id: "slot-1".to_owned(), + formats: vec![AdFormat { + media_type: MediaType::Banner, + width: 300, + height: 250, + }], + floor_price: None, + targeting: HashMap::new(), + bidders: HashMap::new(), + }], + publisher: PublisherInfo { + domain: "test.com".to_owned(), + page_url: None, + }, + user: UserInfo { + id: "test-ec-id".to_owned(), + consent: None, + eids: None, + }, + device: None, + site: None, + context: HashMap::new(), + } + } + + fn make_empty_result() -> OrchestrationResult { + OrchestrationResult { + winning_bids: HashMap::new(), + provider_responses: Vec::new(), + mediator_response: None, + total_time_ms: 10, + metadata: HashMap::new(), + } + } + + fn make_settings() -> Settings { + crate::test_support::tests::create_test_settings() + } + + #[test] + fn response_includes_eid_headers_when_eids_present() { + let mut request = make_minimal_auction_request(); + request.user.eids = Some(vec![Eid { + source: "ssp.com".to_owned(), + uids: vec![Uid { + id: "uid-1".to_owned(), + atype: Some(3), + ext: None, + }], + }]); + + let settings = make_settings(); + let result = make_empty_result(); + + let response = convert_to_openrtb_response(&result, &settings, &request, true) + .expect("should build response"); + + assert!( + response.get_header(HEADER_X_TS_EIDS).is_some(), + "should include x-ts-eids header when EIDs are present" + ); + assert_eq!( + response + .get_header(HEADER_X_TS_EC_CONSENT) + .and_then(|v| v.to_str().ok()), + Some("ok"), + "should include x-ts-ec-consent: ok when ec_allowed is true" + ); + assert!( + response.get_header(HEADER_X_TS_EIDS_TRUNCATED).is_none(), + "should not include truncated header for small payload" + ); + } + + #[test] + fn response_sets_consent_header_even_without_eids() { + let request = make_minimal_auction_request(); + let settings = make_settings(); + let result = make_empty_result(); + + let response = convert_to_openrtb_response(&result, &settings, &request, true) + .expect("should build response"); + + assert_eq!( + response + .get_header(HEADER_X_TS_EC_CONSENT) + .and_then(|v| v.to_str().ok()), + Some("ok"), + "should set x-ts-ec-consent: ok based on consent, not EID presence" + ); + assert!( + response.get_header(HEADER_X_TS_EIDS).is_none(), + "should omit x-ts-eids when no EIDs available" + ); + } + + #[test] + fn response_omits_consent_header_when_not_allowed() { + let request = make_minimal_auction_request(); + let settings = make_settings(); + let result = make_empty_result(); + + let response = convert_to_openrtb_response(&result, &settings, &request, false) + .expect("should build response"); + + assert!( + response.get_header(HEADER_X_TS_EC_CONSENT).is_none(), + "should omit x-ts-ec-consent when ec_allowed is false" + ); + assert!( + response.get_header(HEADER_X_TS_EIDS).is_none(), + "should omit x-ts-eids when no EIDs available" + ); + } } diff --git a/crates/trusted-server-core/src/auction/orchestrator.rs b/crates/trusted-server-core/src/auction/orchestrator.rs index 23116256..475e7ce1 100644 --- a/crates/trusted-server-core/src/auction/orchestrator.rs +++ b/crates/trusted-server-core/src/auction/orchestrator.rs @@ -627,6 +627,7 @@ mod tests { user: UserInfo { id: "user-123".to_string(), consent: None, + eids: None, }, device: None, site: None, diff --git a/crates/trusted-server-core/src/auction/types.rs b/crates/trusted-server-core/src/auction/types.rs index 0ac5b978..360da1d7 100644 --- a/crates/trusted-server-core/src/auction/types.rs +++ b/crates/trusted-server-core/src/auction/types.rs @@ -79,6 +79,13 @@ pub struct UserInfo { /// cookies/headers, not from stored data. #[serde(skip)] pub consent: Option, + /// Consent-gated Extended User IDs resolved from the KV identity graph. + /// + /// Populated by the auction handler from partner data when the user has + /// a valid EC and consent permits EID transmission. `None` when no EIDs + /// are available (no EC, consent denied, or KV read failure). + #[serde(skip)] + pub eids: Option>, } /// Device information from request. diff --git a/crates/trusted-server-core/src/consent/mod.rs b/crates/trusted-server-core/src/consent/mod.rs index 07cd3f9c..96168496 100644 --- a/crates/trusted-server-core/src/consent/mod.rs +++ b/crates/trusted-server-core/src/consent/mod.rs @@ -429,9 +429,8 @@ pub fn build_us_privacy_from_gpc(config: &ConsentConfig) -> Option( eids: Option>, diff --git a/crates/trusted-server-core/src/ec/eids.rs b/crates/trusted-server-core/src/ec/eids.rs new file mode 100644 index 00000000..5af322c0 --- /dev/null +++ b/crates/trusted-server-core/src/ec/eids.rs @@ -0,0 +1,206 @@ +//! Shared EID resolution and formatting helpers. +//! +//! Used by both `/identify` and `/auction` to resolve partner IDs from KV +//! entries, convert them to `OpenRTB` EID structures, and build base64-encoded +//! response headers. + +use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _}; +use error_stack::{Report, ResultExt}; + +use crate::error::TrustedServerError; +use crate::openrtb::{Eid, Uid}; + +use super::kv_types::KvEntry; +use super::partner::PartnerStore; + +/// Maximum size (in bytes) for the base64-encoded `x-ts-eids` header value. +pub const MAX_EIDS_HEADER_BYTES: usize = 4096; + +/// A partner ID resolved from a KV entry against the partner registry. +/// +/// Only includes partners with `bidstream_enabled = true` and a non-empty UID. +pub struct ResolvedPartnerId { + /// Partner namespace key (e.g. `"liveramp"`). + pub partner_id: String, + /// The synced user ID value. + pub uid: String, + /// Epoch timestamp of the last sync. + pub synced: u64, + /// The partner's identity source domain (e.g. `"liveramp.com"`). + pub source_domain: String, + /// `OpenRTB` agent type for this partner's identifiers. + pub openrtb_atype: u8, +} + +/// Resolves partner IDs from a KV entry against the partner registry. +/// +/// Filters to partners with `bidstream_enabled = true` and non-empty UIDs, +/// sorted by `synced` timestamp descending (most recent first). +/// +/// # Errors +/// +/// Returns [`TrustedServerError::KvStore`] if a partner registry lookup fails. +pub fn resolve_partner_ids( + partner_store: &PartnerStore, + entry: &KvEntry, +) -> Result, Report> { + let mut resolved = Vec::new(); + + for (partner_id, partner_uid) in &entry.ids { + if partner_uid.uid.is_empty() { + continue; + } + + let Some(partner) = partner_store.get(partner_id)? else { + continue; + }; + if !partner.bidstream_enabled { + continue; + } + + resolved.push(ResolvedPartnerId { + partner_id: partner_id.clone(), + uid: partner_uid.uid.clone(), + synced: partner_uid.synced, + source_domain: partner.source_domain, + openrtb_atype: partner.openrtb_atype, + }); + } + + resolved.sort_by(|a, b| b.synced.cmp(&a.synced)); + Ok(resolved) +} + +/// Converts resolved partner IDs to `OpenRTB` `Eid` entries. +#[must_use] +pub fn to_eids(resolved: &[ResolvedPartnerId]) -> Vec { + resolved + .iter() + .map(|item| Eid { + source: item.source_domain.clone(), + uids: vec![Uid { + id: item.uid.clone(), + atype: Some(item.openrtb_atype), + ext: None, + }], + }) + .collect() +} + +/// Builds a base64-encoded EID header value, truncating if needed. +/// +/// Returns `(encoded_value, was_truncated)`. If the full set of EIDs exceeds +/// [`MAX_EIDS_HEADER_BYTES`] after base64 encoding, partners are removed +/// (least recently synced first) until it fits. +/// +/// # Errors +/// +/// Returns an error if JSON serialization fails. +pub fn build_eids_header( + resolved: &[ResolvedPartnerId], +) -> Result<(String, bool), Report> { + let eids = to_eids(resolved); + encode_eids_header(&eids) +} + +/// Encodes a pre-built EID slice into a base64 header value with truncation. +/// +/// Like [`build_eids_header`] but operates on already-constructed `Eid` values +/// (e.g., from `UserInfo.eids` in the auction response path). +/// +/// Returns `(encoded_value, was_truncated)`. +/// +/// # Errors +/// +/// Returns an error if JSON serialization fails. +pub fn encode_eids_header(eids: &[Eid]) -> Result<(String, bool), Report> { + for size in (0..=eids.len()).rev() { + let json = serde_json::to_vec(&eids[..size]).change_context( + TrustedServerError::Configuration { + message: "Failed to serialize eids header payload".to_owned(), + }, + )?; + let encoded = BASE64.encode(json); + + if encoded.len() <= MAX_EIDS_HEADER_BYTES { + return Ok((encoded, size != eids.len())); + } + } + + Ok((BASE64.encode("[]"), true)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn to_eids_maps_resolved_ids_correctly() { + let resolved = vec![ + ResolvedPartnerId { + partner_id: "liveramp".to_owned(), + uid: "LR_xyz".to_owned(), + synced: 100, + source_domain: "liveramp.com".to_owned(), + openrtb_atype: 3, + }, + ResolvedPartnerId { + partner_id: "id5".to_owned(), + uid: "ID5_abc".to_owned(), + synced: 50, + source_domain: "id5-sync.com".to_owned(), + openrtb_atype: 1, + }, + ]; + + let eids = to_eids(&resolved); + + assert_eq!(eids.len(), 2, "should produce one EID per resolved partner"); + assert_eq!(eids[0].source, "liveramp.com"); + assert_eq!(eids[0].uids[0].id, "LR_xyz"); + assert_eq!(eids[0].uids[0].atype, Some(3)); + assert_eq!(eids[1].source, "id5-sync.com"); + assert_eq!(eids[1].uids[0].id, "ID5_abc"); + assert_eq!(eids[1].uids[0].atype, Some(1)); + } + + #[test] + fn build_eids_header_truncates_when_too_large() { + let mut resolved = Vec::new(); + for idx in 0..64 { + resolved.push(ResolvedPartnerId { + partner_id: format!("partner_{idx}"), + uid: format!("uid_{}", "x".repeat(100)), + synced: idx as u64, + source_domain: format!("partner-{idx}.example.com"), + openrtb_atype: 3, + }); + } + + let (encoded, truncated) = + build_eids_header(&resolved).expect("should build truncated header"); + + assert!(truncated, "should report truncation for large payload"); + assert!( + encoded.len() <= MAX_EIDS_HEADER_BYTES, + "should cap encoded header bytes" + ); + } + + #[test] + fn build_eids_header_fits_without_truncation() { + let resolved = vec![ResolvedPartnerId { + partner_id: "ssp".to_owned(), + uid: "u1".to_owned(), + synced: 100, + source_domain: "ssp.com".to_owned(), + openrtb_atype: 3, + }]; + + let (encoded, truncated) = + build_eids_header(&resolved).expect("should build header without truncation"); + + assert!(!truncated, "should not truncate small payload"); + assert!(!encoded.is_empty(), "should produce non-empty value"); + } +} diff --git a/crates/trusted-server-core/src/ec/identify.rs b/crates/trusted-server-core/src/ec/identify.rs index d25fdd1c..62f755e9 100644 --- a/crates/trusted-server-core/src/ec/identify.rs +++ b/crates/trusted-server-core/src/ec/identify.rs @@ -2,7 +2,6 @@ use std::collections::HashMap; -use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _}; use error_stack::{Report, ResultExt}; use fastly::http::{header, StatusCode}; use fastly::{Request, Response}; @@ -13,16 +12,15 @@ use crate::constants::{ HEADER_X_TS_EC, HEADER_X_TS_EC_CONSENT, HEADER_X_TS_EIDS, HEADER_X_TS_EIDS_TRUNCATED, }; use crate::error::TrustedServerError; -use crate::openrtb::{Eid, Uid}; +use crate::openrtb::Eid; use crate::settings::Settings; +use super::eids::{build_eids_header, resolve_partner_ids, to_eids}; use super::kv::KvIdentityGraph; -use super::kv_types::KvEntry; use super::partner::PartnerStore; use super::EcContext; const MAX_EXPOSE_PARTNER_HEADERS: usize = 20; -const MAX_EIDS_HEADER_BYTES: usize = 4096; /// Handles `GET /identify`. /// @@ -162,77 +160,6 @@ struct IdentifyResponse { eids: Vec, } -struct ResolvedPartnerId { - partner_id: String, - uid: String, - synced: u64, - source_domain: String, - openrtb_atype: u8, -} - -fn resolve_partner_ids( - partner_store: &PartnerStore, - entry: &KvEntry, -) -> Result, Report> { - let mut resolved = Vec::new(); - - for (partner_id, partner_uid) in &entry.ids { - if partner_uid.uid.is_empty() { - continue; - } - - let Some(partner) = partner_store.get(partner_id)? else { - continue; - }; - if !partner.bidstream_enabled { - continue; - } - - resolved.push(ResolvedPartnerId { - partner_id: partner_id.clone(), - uid: partner_uid.uid.clone(), - synced: partner_uid.synced, - source_domain: partner.source_domain, - openrtb_atype: partner.openrtb_atype, - }); - } - - resolved.sort_by(|a, b| b.synced.cmp(&a.synced)); - Ok(resolved) -} - -fn to_eids(resolved: &[ResolvedPartnerId]) -> Vec { - resolved - .iter() - .map(|item| Eid { - source: item.source_domain.clone(), - uids: vec![Uid { - id: item.uid.clone(), - atype: Some(item.openrtb_atype), - ext: None, - }], - }) - .collect() -} - -fn build_eids_header( - resolved: &[ResolvedPartnerId], -) -> Result<(String, bool), Report> { - for size in (0..=resolved.len()).rev() { - let eids = to_eids(&resolved[..size]); - let json = serde_json::to_vec(&eids).change_context(TrustedServerError::Configuration { - message: "Failed to serialize identify eids header payload".to_owned(), - })?; - let encoded = BASE64.encode(json); - - if encoded.len() <= MAX_EIDS_HEADER_BYTES { - return Ok((encoded, size != resolved.len())); - } - } - - Ok((BASE64.encode("[]"), true)) -} - fn json_response( status: StatusCode, body: &T, @@ -295,6 +222,7 @@ mod tests { use super::*; use crate::consent::jurisdiction::Jurisdiction; use crate::consent::types::{ConsentContext, ConsentSource}; + use crate::ec::eids::{ResolvedPartnerId, MAX_EIDS_HEADER_BYTES}; use crate::test_support::tests::create_test_settings; fn make_ec_context(jurisdiction: Jurisdiction, ec_value: Option<&str>) -> EcContext { diff --git a/crates/trusted-server-core/src/ec/mod.rs b/crates/trusted-server-core/src/ec/mod.rs index d7dfe480..a8eab5b7 100644 --- a/crates/trusted-server-core/src/ec/mod.rs +++ b/crates/trusted-server-core/src/ec/mod.rs @@ -22,10 +22,12 @@ //! - [`admin`] — Admin endpoints for partner management //! - [`sync_pixel`] — Pixel sync write endpoint (`GET /sync`) //! - [`identify`] — Browser identity read endpoint (`GET /identify`) +//! - [`eids`] — Shared EID resolution and formatting helpers pub mod admin; pub mod consent; pub mod cookies; +pub mod eids; pub mod finalize; pub mod generation; pub mod identify; @@ -343,6 +345,21 @@ impl EcContext { pub fn ec_hash(&self) -> Option<&str> { self.ec_value.as_deref().map(generation::ec_hash) } + + /// Creates a test-only `EcContext` with explicit field values. + #[cfg(test)] + #[must_use] + pub fn new_for_test(ec_value: Option, consent: ConsentContext) -> Self { + Self { + ec_was_present: ec_value.is_some(), + cookie_ec_value: ec_value.clone(), + ec_value, + ec_generated: false, + consent, + client_ip: None, + geo_info: None, + } + } } fn current_timestamp() -> u64 { diff --git a/crates/trusted-server-core/src/integrations/adserver_mock.rs b/crates/trusted-server-core/src/integrations/adserver_mock.rs index 9810147e..4dc818d1 100644 --- a/crates/trusted-server-core/src/integrations/adserver_mock.rs +++ b/crates/trusted-server-core/src/integrations/adserver_mock.rs @@ -460,6 +460,7 @@ mod tests { user: UserInfo { id: "user-123".to_string(), consent: None, + eids: None, }, device: Some(DeviceInfo { user_agent: Some("Mozilla/5.0".to_string()), @@ -633,6 +634,7 @@ mod tests { user: UserInfo { id: "user-1".to_string(), consent: None, + eids: None, }, device: None, site: None, diff --git a/crates/trusted-server-core/src/integrations/aps.rs b/crates/trusted-server-core/src/integrations/aps.rs index 4a3aea1c..f3383bfe 100644 --- a/crates/trusted-server-core/src/integrations/aps.rs +++ b/crates/trusted-server-core/src/integrations/aps.rs @@ -672,6 +672,7 @@ mod tests { user: UserInfo { id: "user-123".to_string(), consent: None, + eids: None, }, device: Some(DeviceInfo { user_agent: Some("Mozilla/5.0".to_string()), diff --git a/crates/trusted-server-core/src/integrations/prebid.rs b/crates/trusted-server-core/src/integrations/prebid.rs index bdd7e19b..5dd0d4f8 100644 --- a/crates/trusted-server-core/src/integrations/prebid.rs +++ b/crates/trusted-server-core/src/integrations/prebid.rs @@ -696,9 +696,9 @@ impl PrebidAuctionProvider { .map(|ac| ConsentedProvidersSettings { consented_providers: Some(ac.clone()), }), - // EIDs will be populated by identity providers; consent gating - // is applied via `gate_eids_by_consent` before they are set here. - eids: None, + // EIDs resolved from the KV identity graph and consent-gated + // in `handle_auction` via `gate_eids_by_consent`. + eids: request.user.eids.clone(), } .to_ext(), ..Default::default() @@ -1364,8 +1364,8 @@ mod tests { }, user: UserInfo { id: "user-123".to_string(), - consent: None, + eids: None, }, device: None, site: None, @@ -2678,6 +2678,67 @@ server_url = "https://prebid.example" ); } + #[test] + fn to_openrtb_includes_eids_from_auction_request() { + let provider = PrebidAuctionProvider::new(base_config()); + let mut auction_request = create_test_auction_request(); + auction_request.user.eids = Some(vec![ + crate::openrtb::Eid { + source: "liveramp.com".to_owned(), + uids: vec![crate::openrtb::Uid { + id: "LR_xyz".to_owned(), + atype: Some(3), + ext: None, + }], + }, + crate::openrtb::Eid { + source: "id5-sync.com".to_owned(), + uids: vec![crate::openrtb::Uid { + id: "ID5_abc".to_owned(), + atype: Some(1), + ext: None, + }], + }, + ]); + + let settings = make_settings(); + let request = Request::get("https://pub.example/auction"); + let context = create_test_auction_context(&settings, &request); + + let openrtb = provider.to_openrtb(&auction_request, &context, None); + + let serialized = serde_json::to_value(&openrtb).expect("should serialize OpenRTB request"); + let ext_eids = &serialized["user"]["ext"]["eids"]; + assert!(ext_eids.is_array(), "should populate user.ext.eids"); + assert_eq!(ext_eids.as_array().unwrap().len(), 2, "should have 2 EIDs"); + assert_eq!( + ext_eids[0]["source"], "liveramp.com", + "should include liveramp EID" + ); + assert_eq!( + ext_eids[1]["source"], "id5-sync.com", + "should include id5 EID" + ); + } + + #[test] + fn to_openrtb_omits_eids_when_none() { + let provider = PrebidAuctionProvider::new(base_config()); + let auction_request = create_test_auction_request(); + + let settings = make_settings(); + let request = Request::get("https://pub.example/auction"); + let context = create_test_auction_context(&settings, &request); + + let openrtb = provider.to_openrtb(&auction_request, &context, None); + + let serialized = serde_json::to_value(&openrtb).expect("should serialize OpenRTB request"); + assert!( + serialized["user"]["ext"]["eids"].is_null(), + "should omit user.ext.eids when no EIDs available" + ); + } + #[test] fn expand_trusted_server_bidders_uses_per_bidder_map_when_present() { let params = json!({ @@ -2780,8 +2841,8 @@ server_url = "https://prebid.example" }, user: UserInfo { id: "synth-123".to_string(), - consent: None, + eids: None, }, device: Some(DeviceInfo { user_agent: Some("test-agent".to_string()), diff --git a/crates/trusted-server-core/src/openrtb.rs b/crates/trusted-server-core/src/openrtb.rs index 37c3d675..3ea837d4 100644 --- a/crates/trusted-server-core/src/openrtb.rs +++ b/crates/trusted-server-core/src/openrtb.rs @@ -69,7 +69,7 @@ pub struct ConsentedProvidersSettings { } /// An Extended User ID entry from an identity provider. -#[derive(Debug, Serialize)] +#[derive(Debug, Clone, Serialize)] pub struct Eid { /// Identity provider domain (e.g. `"id5-sync.com"`). pub source: String, @@ -78,7 +78,7 @@ pub struct Eid { } /// A single user identifier within an [`Eid`] entry. -#[derive(Debug, Serialize)] +#[derive(Debug, Clone, Serialize)] pub struct Uid { /// The identifier value. pub id: String,