diff --git a/Cargo.toml b/Cargo.toml index 72fecdf6b5..d29e1da5b4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -102,7 +102,7 @@ rustls = "0.23" rustls-pemfile = "2" scraper = "0.24.0" serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0.149" +serde_json = { version = "1.0.149", features = ["raw_value"] } serde_yaml_ng = "0.10" sha2 = "0.11" similar = "2.7" diff --git a/crates/trusted-server-adapter-fastly/src/app.rs b/crates/trusted-server-adapter-fastly/src/app.rs index efb8508447..1b5ee07bba 100644 --- a/crates/trusted-server-adapter-fastly/src/app.rs +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -2301,6 +2301,38 @@ mod tests { .eid_sync_source() } + #[test] + fn auction_route_carries_the_raw_ts_eids_cookie_to_finalization() { + let router = test_router(); + let eids_cookie = base64::engine::general_purpose::STANDARD.encode( + serde_json::json!([{ "source": "ids.example.com", "uids": [{ "id": "example-uid" }] }]) + .to_string(), + ); + let mut request = browser_request(Method::POST, "/auction", "empty"); + request.headers_mut().insert( + header::COOKIE, + HeaderValue::from_str(&format!("ts-eids={eids_cookie}")) + .expect("should build cookie header"), + ); + + let response = route(&router, request); + + let state = response + .extensions() + .get::() + .expect("auction responses should carry EC finalization state"); + assert_eq!( + state.eids_cookie.as_deref(), + Some(eids_cookie.as_str()), + "the adapter should hand finalization the raw ts-eids cookie" + ); + assert_eq!( + state.ec_context.eid_sync_source(), + Some(EidSyncSource::Auction), + "finalization should know this was an /auction request" + ); + } + #[test] fn dispatch_limits_returning_user_eid_sync_to_eligible_routes() { let router = test_router(); diff --git a/crates/trusted-server-core/src/auction/endpoints.rs b/crates/trusted-server-core/src/auction/endpoints.rs index f364464561..6a56f1bf03 100644 --- a/crates/trusted-server-core/src/auction/endpoints.rs +++ b/crates/trusted-server-core/src/auction/endpoints.rs @@ -1,11 +1,11 @@ //! HTTP endpoint handlers for auction requests. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use edgezero_core::body::Body as EdgeBody; use error_stack::{Report, ResultExt}; use http::{Request, Response, StatusCode, header}; -use serde_json::Value as JsonValue; +use serde_json::value::RawValue; use crate::auction::formats::AdRequest; use crate::auction::orchestrator::OrchestrationResult; @@ -14,13 +14,14 @@ use crate::constants::COOKIE_TS_EIDS; use crate::cookies::extract_cookie_value; use crate::ec::EcContext; use crate::ec::EcKvSnapshot; +use crate::ec::client_eids::parse_client_eids; use crate::ec::eids::{resolve_partner_ids, to_eids}; use crate::ec::kv::KvIdentityGraph; -use crate::ec::kv_types::MAX_UID_LENGTH; +use crate::ec::partner::normalize_partner_source_domain; use crate::ec::prebid_eids::parse_prebid_eids_cookie; use crate::ec::registry::PartnerRegistry; use crate::error::TrustedServerError; -use crate::openrtb::{Eid, Uid}; +use crate::openrtb::Eid; use crate::platform::RuntimeServices; use crate::settings::Settings; @@ -35,10 +36,6 @@ use super::telemetry::{ }; use super::types::AuctionContext; -const MAX_CLIENT_EID_SOURCES: usize = 64; -const MAX_CLIENT_UIDS_PER_SOURCE: usize = 32; -const MAX_CLIENT_EID_SOURCE_BYTES: usize = 255; - /// Maximum accepted JSON body size for `/auction`. Picked to comfortably fit /// the largest realistic Prebid-derived auction request (hundreds of ad units /// with EID arrays) while preventing an authenticated client from consuming @@ -292,7 +289,7 @@ pub async fn handle_auction( // `ec_id.is_some()`. let client_eids = if ec_id.is_some() { resolve_client_auction_eids( - body.eids.as_ref(), + body.eids.as_deref(), extract_cookie_value(&http_req, COOKIE_TS_EIDS).as_deref(), ) } else { @@ -342,8 +339,8 @@ pub async fn handle_auction( geo, )?; - // Merge current-request client EIDs with KV-resolved EIDs, then apply - // consent gating before attaching them to the auction request. + // Forward the browser entries as sent, append KV entries for registered + // sources the browser did not send, then apply consent gating. let merged_eids = merge_auction_eids(client_eids, eids); let had_eids = merged_eids.as_ref().is_some_and(|v| !v.is_empty()); auction_request.user.eids = @@ -469,11 +466,18 @@ pub(crate) fn resolve_auction_eids( Some(to_eids(&resolved)) } +/// Resolves the request's browser EIDs: the `/auction` body when it has a +/// valid entry, else the `ts-eids` cookie. +/// +/// Both go through the shared client EID parser, so the same rules and caps +/// apply to each, and a malformed entry costs only itself. pub(crate) fn resolve_client_auction_eids( - raw: Option<&JsonValue>, + raw: Option<&RawValue>, cookie_value: Option<&str>, ) -> Option> { - parse_client_auction_eids(raw).or_else(|| parse_cookie_auction_eids(cookie_value)) + raw.map(parse_client_eids) + .filter(|eids| !eids.is_empty()) + .or_else(|| parse_cookie_auction_eids(cookie_value)) } fn parse_cookie_auction_eids(cookie_value: Option<&str>) -> Option> { @@ -488,132 +492,41 @@ fn parse_cookie_auction_eids(cookie_value: Option<&str>) -> Option> { } } -fn parse_client_auction_eids(raw: Option<&JsonValue>) -> Option> { - let Some(JsonValue::Array(entries)) = raw else { - return None; - }; - - let mut eids = Vec::new(); - - for entry in entries { - if eids.len() >= MAX_CLIENT_EID_SOURCES { - log::debug!( - "Auction EIDs: reached max client EID source count ({MAX_CLIENT_EID_SOURCES})" - ); - break; - } - let JsonValue::Object(entry) = entry else { - log::debug!("Auction EIDs: dropping malformed client EID entry"); - continue; - }; - - let Some(source) = entry - .get("source") - .and_then(JsonValue::as_str) - .filter(|source| !source.trim().is_empty()) - .filter(|source| source.len() <= MAX_CLIENT_EID_SOURCE_BYTES) - .map(str::to_owned) - else { - continue; - }; - - let Some(JsonValue::Array(raw_uids)) = entry.get("uids") else { - continue; - }; - - let uids: Vec<_> = raw_uids - .iter() - .filter_map(parse_client_auction_uid) - .take(MAX_CLIENT_UIDS_PER_SOURCE) - .collect(); - if uids.is_empty() { - continue; - } - - eids.push(Eid { source, uids }); - } - - if eids.is_empty() { None } else { Some(eids) } -} - -fn parse_client_auction_uid(raw: &JsonValue) -> Option { - let JsonValue::Object(uid) = raw else { - return None; - }; - - let id = uid - .get("id") - .and_then(JsonValue::as_str) - .filter(|id| !id.trim().is_empty()) - .filter(|id| id.len() <= MAX_UID_LENGTH)? - .to_owned(); - - let atype = uid - .get("atype") - .and_then(JsonValue::as_u64) - .and_then(|atype| i32::try_from(atype).ok()); - - let ext = match uid.get("ext") { - Some(JsonValue::Object(_)) => uid.get("ext").cloned(), - _ => None, - }; - - Some(Uid { id, atype, ext }) -} - +/// Merges the request's browser EIDs with KV-resolved partner EIDs. +/// +/// Browser entries are forwarded as parsed, in wire order, never altered or +/// combined: they are what Prebid sent the `trustedServer` adapter, and a +/// source may repeat with different EID-level fields. KV entries are appended +/// after them, only for registered sources no browser entry carries. Sources +/// are compared as the partner registry normalizes them (lowercase, trailing +/// dot removed). For a source the browser sends, KV's UIDs, `atype` and `ext` +/// are not used: KV keeps the first value it stored, so it is the staler copy. +/// Entries without UIDs are dropped. pub(crate) fn merge_auction_eids( client_eids: Option>, resolved_eids: Option>, ) -> Option> { - let mut merged = Vec::new(); - - for eid in resolved_eids + let mut merged: Vec = client_eids .into_iter() .flatten() - .chain(client_eids.into_iter().flatten()) - { - if eid.source.is_empty() { + .filter(|eid| !eid.uids.is_empty()) + .collect(); + let browser_sources: HashSet = merged + .iter() + .filter_map(|eid| normalize_partner_source_domain(&eid.source).ok()) + .collect(); + + for eid in resolved_eids.into_iter().flatten() { + if eid.uids.is_empty() { continue; } - - let source_index = match merged - .iter() - .position(|existing: &Eid| existing.source == eid.source) - { - Some(index) => index, - None => { - merged.push(Eid { - source: eid.source.clone(), - uids: Vec::new(), - }); - merged.len() - 1 - } - }; - - for uid in eid.uids { - if uid.id.trim().is_empty() || uid.id.len() > MAX_UID_LENGTH { - continue; - } - - if let Some(existing_uid) = merged[source_index] - .uids - .iter_mut() - .find(|existing| existing.id == uid.id) - { - if existing_uid.atype.is_none() { - existing_uid.atype = uid.atype; - } - if existing_uid.ext.is_none() { - existing_uid.ext = uid.ext; - } - } else { - merged[source_index].uids.push(uid); - } + let carried_by_browser = normalize_partner_source_domain(&eid.source) + .is_ok_and(|source| browser_sources.contains(&source)); + if !carried_by_browser { + merged.push(eid); } } - merged.retain(|eid| !eid.uids.is_empty()); - if merged.is_empty() { None } else { @@ -630,6 +543,7 @@ mod tests { use crate::auction::types::{AuctionRequest, AuctionResponse}; use crate::consent::jurisdiction::Jurisdiction; use crate::consent::types::ConsentContext; + use crate::ec::kv_types::MAX_UID_LENGTH; use crate::error::IntoHttpResponse as _; use crate::openrtb::Uid; use crate::platform::test_support::{ @@ -640,6 +554,7 @@ mod tests { use crate::test_support::tests::{crate_test_settings_str, create_test_settings}; use base64::Engine as _; use base64::engine::general_purpose::STANDARD as BASE64; + use serde_json::Value as JsonValue; use serde_json::json; use std::sync::{Arc, Mutex}; @@ -803,6 +718,183 @@ mod tests { ); } + const COOKIE_ONLY_SOURCE: &str = "cookie-only.example.com"; + + fn ingestion_registry(extra_source: &str) -> PartnerRegistry { + PartnerRegistry::from_config(&[ + counting_test_partner(COOKIE_ONLY_SOURCE), + counting_test_partner(extra_source), + ]) + .expect("should build partner registry") + } + + fn cookie_only_ts_eids() -> String { + BASE64.encode( + serde_json::to_vec(&json!([ + { "source": COOKIE_ONLY_SOURCE, "uids": [{ "id": "cookie-uid" }] } + ])) + .expect("should serialize cookie"), + ) + } + + fn seeded_graph(ec_id: &str) -> KvIdentityGraph { + let graph = KvIdentityGraph::in_memory("test_store"); + let mut live = crate::ec::kv_types::KvEntry::tombstone(1000); + live.consent.ok = true; + graph.create(ec_id, &live).expect("should seed live row"); + graph + } + + fn stored_ids(graph: &KvIdentityGraph, ec_id: &str) -> Vec<(String, String)> { + let (stored, _) = graph + .get(ec_id) + .expect("should read store") + .expect("row should exist"); + stored + .ids + .iter() + .map(|(source, id)| (source.clone(), id.uid.clone())) + .collect() + } + + #[tokio::test] + async fn auction_ingests_its_body_eids_and_not_a_cookie_only_source() { + let settings = create_test_settings(); + let mut orchestrator = AuctionOrchestrator::new(AuctionConfig { + enabled: true, + providers: AuctionConfig::legacy_provider_map(&["eid_capturing_provider"]), + timeout_ms: 2000, + mediator: None, + ..Default::default() + }); + orchestrator.register_provider(Arc::new(EidCapturingProvider { + had_eids: Arc::new(std::sync::Mutex::new(None)), + })); + let registry = ingestion_registry("body.example.com"); + let ec_id = format!("{}.ingbdy", "a".repeat(64)); + let graph = seeded_graph(&ec_id); + let mut ec_context = make_ec_context(Jurisdiction::NonRegulated, Some(&ec_id)); + ec_context.set_eid_sync_source(crate::ec::EidSyncSource::Auction); + let cookie = cookie_only_ts_eids(); + let req = Request::builder() + .method("POST") + .uri("https://test-publisher.com/auction") + .header(header::COOKIE, format!("{COOKIE_TS_EIDS}={cookie}")) + .body(EdgeBody::from( + serde_json::to_vec(&json!({ + "adUnits": [{ "code": "div-gpt-ad-1", "mediaTypes": { "banner": { "sizes": [[300, 250]] } } }], + "eids": [{ "source": "body.example.com", "uids": [{ "id": "body-uid" }] }] + })) + .expect("should serialize body"), + )) + .expect("should build auction request"); + + let _ = handle_auction( + &settings, + &orchestrator, + Some(&graph), + Some(®istry), + &mut ec_context, + &noop_services(), + req, + ) + .await; + let mut response = http::Response::new(EdgeBody::empty()); + crate::ec::finalize::ec_finalize_response( + &settings, + &mut ec_context, + Some(&graph), + ®istry, + Some(&cookie), + None, + &mut response, + ); + + assert_eq!( + stored_ids(&graph, &ec_id), + vec![("body.example.com".to_owned(), "body-uid".to_owned())], + "KV should learn the body's source and not the cookie-only source" + ); + } + + #[tokio::test] + async fn disabled_auction_ingests_nothing_from_the_cookie_but_keeps_shared_id() { + let settings = create_test_settings(); + let orchestrator = AuctionOrchestrator::new(AuctionConfig { + enabled: false, + ..Default::default() + }); + let registry = ingestion_registry("sharedid.org"); + let ec_id = format!("{}.ingdis", "a".repeat(64)); + let graph = seeded_graph(&ec_id); + let mut ec_context = make_ec_context(Jurisdiction::NonRegulated, Some(&ec_id)); + ec_context.set_eid_sync_source(crate::ec::EidSyncSource::Auction); + let cookie = cookie_only_ts_eids(); + let req = Request::builder() + .method("POST") + .uri("https://test-publisher.com/auction") + .header(header::COOKIE, format!("{COOKIE_TS_EIDS}={cookie}")) + .body(EdgeBody::from(ad_units_only_body())) + .expect("should build auction request"); + + let _ = handle_auction( + &settings, + &orchestrator, + Some(&graph), + Some(®istry), + &mut ec_context, + &noop_services(), + req, + ) + .await; + let mut response = http::Response::new(EdgeBody::empty()); + crate::ec::finalize::ec_finalize_response( + &settings, + &mut ec_context, + Some(&graph), + ®istry, + Some(&cookie), + Some("shared-cookie-id"), + &mut response, + ); + + assert_eq!( + stored_ids(&graph, &ec_id), + vec![("sharedid.org".to_owned(), "shared-cookie-id".to_owned())], + "an /auction that resolved no client set should ingest only the sharedId cookie" + ); + } + + #[test] + fn navigation_ingests_the_cookie_only_source() { + // Control for the two tests above: the registry does match the + // cookie-only source when the route reads the cookie. + let settings = create_test_settings(); + let registry = ingestion_registry("body.example.com"); + let ec_id = format!("{}.ingnav", "a".repeat(64)); + let graph = seeded_graph(&ec_id); + let mut ec_context = make_ec_context(Jurisdiction::NonRegulated, Some(&ec_id)); + ec_context.set_eid_sync_source(crate::ec::EidSyncSource::Navigation); + let cookie = cookie_only_ts_eids(); + let mut response = http::Response::new(EdgeBody::empty()); + + crate::ec::finalize::ec_finalize_response( + &settings, + &mut ec_context, + Some(&graph), + ®istry, + Some(&cookie), + None, + &mut response, + ); + + assert_eq!( + stored_ids(&graph, &ec_id), + vec![(COOKIE_ONLY_SOURCE.to_owned(), "cookie-uid".to_owned())], + "a navigation should ingest the cookie's registered source" + ); + } + #[tokio::test] async fn auction_body_eids_reach_kv_even_when_the_ts_eids_cookie_is_absent() { // Regression test for #1184: `/auction` sends every EID it has in the @@ -1295,6 +1387,197 @@ mod tests { } } + const AUCTION_BODY_FIXTURE: &str = + include_str!("../../../trusted-server-js/lib/test/fixtures/auction-body-eids.txt"); + const TS_EIDS_COOKIE_FIXTURE: &str = + include_str!("../../../trusted-server-js/lib/test/fixtures/ts-eids-cookie.txt"); + + /// Provider that records the full EID set its auction request carried, + /// then fails its launch so no real transport handle is needed. + struct EidRecordingProvider { + eids: Arc>>, + } + + #[async_trait::async_trait(?Send)] + impl AuctionProvider for EidRecordingProvider { + fn provider_name(&self) -> &str { + "eid_recording_provider" + } + + async fn request_bids( + &self, + request: &AuctionRequest, + _context: &AuctionContext<'_>, + ) -> Result> { + *self.eids.lock().expect("should lock recorded eids") = Some( + serde_json::to_value(&request.user.eids).expect("should serialize recorded eids"), + ); + Err(Report::new(TrustedServerError::Auction { + message: "record only".to_string(), + })) + } + + async fn parse_response( + &self, + _response: PlatformResponse, + _response_time_ms: u64, + ) -> Result> { + panic!("parse_response must not run when the launch fails"); + } + + fn timeout_ms(&self) -> u32 { + 100 + } + + fn backend_name(&self, _services: &RuntimeServices, _timeout_ms: u32) -> Option { + Some("record-backend".to_string()) + } + } + + /// Runs `/auction` with an allowed EC and returns the EIDs the provider + /// received: `None` when no provider was called (the request was + /// rejected), `Some(Value::Null)` when it carried no EIDs. + async fn recorded_auction_eids( + body: Vec, + ts_eids_cookie: Option<&str>, + ) -> Option { + let settings = create_test_settings(); + let mut orchestrator = AuctionOrchestrator::new(AuctionConfig { + enabled: true, + providers: AuctionConfig::legacy_provider_map(&["eid_recording_provider"]), + timeout_ms: 2000, + mediator: None, + ..Default::default() + }); + let recorded = Arc::new(Mutex::new(None)); + orchestrator.register_provider(Arc::new(EidRecordingProvider { + eids: Arc::clone(&recorded), + })); + let ec_id = format!("{}.fixtur", "a".repeat(64)); + let mut ec_context = make_ec_context(Jurisdiction::NonRegulated, Some(&ec_id)); + let mut builder = Request::builder() + .method("POST") + .uri("https://test-publisher.com/auction"); + if let Some(cookie) = ts_eids_cookie { + builder = builder.header(header::COOKIE, format!("{COOKIE_TS_EIDS}={cookie}")); + } + let req = builder + .body(EdgeBody::from(body)) + .expect("should build auction request"); + + // The recording provider fails its launch; the assertion is on what + // it received, not on the auction result. + let _ = handle_auction( + &settings, + &orchestrator, + None, + None, + &mut ec_context, + &noop_services(), + req, + ) + .await; + + recorded.lock().expect("should lock recorded eids").clone() + } + + /// The two parsable entries of the fixture input, as providers receive + /// them. The cookie copy has no EID-level `ext` on the last entry. + fn fixture_good_entries(with_eid_ext: bool) -> JsonValue { + let mut last = json!({ + "source": "last.example.com", + "uids": [{ "id": "last-uid", "atype": 3 }] + }); + if with_eid_ext { + last["ext"] = json!({ "segments": ["s1"] }); + } + json!([ + { + "source": "first.example.com", + "inserter": "inserter-€.example.com", + "matcher": "first.example.com", + "mm": 3, + "uids": [{ "id": "first-uid", "atype": 1, "ext": { "label": "日本" } }] + }, + last + ]) + } + + #[tokio::test] + async fn tsjs_auction_body_fixture_drops_only_the_unparsable_entry() { + let recorded = + recorded_auction_eids(AUCTION_BODY_FIXTURE.trim_end().as_bytes().to_vec(), None).await; + + assert_eq!( + recorded, + Some(fixture_good_entries(true)), + "the request should be accepted and carry the two good entries unchanged" + ); + } + + #[tokio::test] + async fn tsjs_cookie_fixture_drops_only_the_unparsable_entry() { + let recorded = recorded_auction_eids( + ad_units_only_body(), + Some(TS_EIDS_COOKIE_FIXTURE.trim_end()), + ) + .await; + + assert_eq!( + recorded, + Some(fixture_good_entries(false)), + "the cookie's good entries should reach the provider without the bad one" + ); + } + + #[tokio::test] + async fn auction_body_eid_level_fields_reach_the_auction_request() { + let eids = json!([{ + "source": "adserver.org", + "inserter": "adserver.org", + "matcher": "adserver.org", + "mm": 4, + "uids": [{ "id": "fictional-tdid", "atype": 1, "ext": { "rtiPartner": "TDID" } }] + }]); + let body = serde_json::to_vec(&json!({ + "adUnits": [{ "code": "div-gpt-1", "mediaTypes": { "banner": { "sizes": [[300, 250]] } } }], + "eids": eids + })) + .expect("should serialize body"); + + assert_eq!( + recorded_auction_eids(body, None).await, + Some(eids), + "EID-level fields from the body should reach the provider unchanged" + ); + } + + fn ad_units_only_body() -> Vec { + serde_json::to_vec(&json!({ + "adUnits": [{ "code": "div-gpt-1", "mediaTypes": { "banner": { "sizes": [[300, 250]] } } }] + })) + .expect("should serialize body") + } + + #[tokio::test] + async fn auction_uses_the_cookie_when_no_body_entry_is_valid() { + let cookie_entries = + json!([{ "source": "cookie.example.com", "uids": [{ "id": "cookie-uid" }] }]); + let cookie = + BASE64.encode(serde_json::to_vec(&cookie_entries).expect("should serialize cookie")); + let body = serde_json::to_vec(&json!({ + "adUnits": [{ "code": "div-gpt-1", "mediaTypes": { "banner": { "sizes": [[300, 250]] } } }], + "eids": [{ "source": "", "uids": [{ "id": "body-uid" }] }] + })) + .expect("should serialize body"); + + assert_eq!( + recorded_auction_eids(body, Some(&cookie)).await, + Some(cookie_entries), + "a body whose entries are all invalid should count as absent" + ); + } + #[tokio::test] async fn auction_strips_client_eids_when_ec_identity_denied() { // US-state opt-out via GPC: the server-side auction consent gate still @@ -1482,12 +1765,13 @@ mod tests { #[test] fn resolve_client_auction_eids_prefers_request_body_over_cookie() { - let raw = json!([ + let raw = serde_json::value::to_raw_value(&json!([ { "source": "id5-sync.com", "uids": [{ "id": "body_uid", "atype": 1 }] } - ]); + ])) + .expect("should capture raw EIDs"); let cookie_payload = json!([ { "source": "sharedid.org", @@ -1497,258 +1781,303 @@ mod tests { let encoded = BASE64 .encode(serde_json::to_vec(&cookie_payload).expect("should serialize cookie payload")); - let resolved = resolve_client_auction_eids(Some(&raw), Some(&encoded)) + let resolved = resolve_client_auction_eids(Some(raw.as_ref()), Some(&encoded)) .expect("should prefer request body EIDs"); - assert_eq!(resolved.len(), 1, "should use request body when present"); - assert_eq!(resolved[0].source, "id5-sync.com"); - assert_eq!(resolved[0].uids[0].id, "body_uid"); + assert_eq!( + serde_json::to_value(&resolved).expect("should serialize resolved EIDs"), + json!([{ "source": "id5-sync.com", "uids": [{ "id": "body_uid", "atype": 1 }] }]), + "should use the request body when it has valid entries" + ); } - #[test] - fn parse_client_auction_eids_ignores_malformed_entries() { - let raw = json!([ - { - "source": "id5-sync.com", - "uids": [{ "id": "ID5_abc", "atype": 1 }] - }, - { - "source": "broken.example", - "uids": "not-an-array" - }, - { - "source": "sharedid.org", - "uids": [{ "id": "shared_123" }, { "id": "" }] - } - ]); + const SOURCE_S: &str = "ids.example.com"; + const SOURCE_T: &str = "other.example.com"; - let parsed = parse_client_auction_eids(Some(&raw)).expect("should parse valid EIDs"); + fn kv_eid(source: &str, id: &str, atype: i32) -> Eid { + Eid::new( + source.to_owned(), + vec![Uid { + id: id.to_owned(), + atype: Some(atype), + ext: None, + }], + ) + } + + /// A browser entry, every field spelled out (spec 7.1 rule 9). + fn browser_eid( + source: &str, + id: &str, + atype: Option, + inserter: Option<&str>, + mm: Option, + ) -> Eid { + Eid { + source: source.to_owned(), + uids: vec![Uid { + id: id.to_owned(), + atype, + ext: None, + }], + inserter: inserter.map(str::to_owned), + matcher: None, + mm, + ext: None, + } + } - assert_eq!(parsed.len(), 2, "should keep only valid EID entries"); - assert_eq!(parsed[0].source, "id5-sync.com"); - assert_eq!(parsed[0].uids.len(), 1, "should keep valid UID"); - assert_eq!(parsed[1].source, "sharedid.org"); - assert_eq!(parsed[1].uids.len(), 1, "should drop empty UID values"); + fn merged_json(client: Option>, kv: Option>) -> JsonValue { + serde_json::to_value(merge_auction_eids(client, kv)).expect("should serialize merged EIDs") } #[test] - fn parse_client_auction_eids_caps_sources_and_uids() { - let entries: Vec<_> = (0..(MAX_CLIENT_EID_SOURCES + 5)) - .map(|source_index| { - let uids: Vec<_> = (0..(MAX_CLIENT_UIDS_PER_SOURCE + 5)) - .map(|uid_index| json!({ "id": format!("uid-{source_index}-{uid_index}") })) - .collect(); - json!({ - "source": format!("source-{source_index}.example.com"), - "uids": uids, - }) - }) - .collect(); - let raw = JsonValue::Array(entries); - - let parsed = parse_client_auction_eids(Some(&raw)).expect("should parse capped EIDs"); + fn merge_case_1_forwards_a_browser_entry_with_its_fields() { + let merged = merged_json( + Some(vec![browser_eid( + SOURCE_S, + "u1", + None, + Some("inserter-1.example.com"), + Some(1), + )]), + None, + ); assert_eq!( - parsed.len(), - MAX_CLIENT_EID_SOURCES, - "should cap client EID sources" - ); - assert!( - parsed - .iter() - .all(|eid| eid.uids.len() == MAX_CLIENT_UIDS_PER_SOURCE), - "should cap UIDs per source" + merged, + json!([{ "source": SOURCE_S, "inserter": "inserter-1.example.com", "mm": 1, "uids": [{ "id": "u1" }] }]), + "a browser entry should go out with its EID-level fields" ); } #[test] - fn parse_client_auction_eids_drops_whitespace_and_oversized_uids() { - let raw = json!([ - { - "source": "id5-sync.com", - "uids": [ - { "id": " " }, - { "id": "x".repeat(MAX_UID_LENGTH + 1) }, - { "id": "valid" } - ] - } - ]); - - let parsed = parse_client_auction_eids(Some(&raw)).expect("should parse valid UID"); + fn merge_case_2_keeps_the_browser_atype_for_a_shared_uid() { + let merged = merged_json( + Some(vec![browser_eid( + SOURCE_S, + "u1", + Some(1), + Some("inserter-1.example.com"), + Some(1), + )]), + Some(vec![kv_eid(SOURCE_S, "u1", 3)]), + ); - assert_eq!(parsed.len(), 1, "should retain source with valid UID"); - assert_eq!(parsed[0].uids.len(), 1, "should drop invalid UIDs"); - assert_eq!(parsed[0].uids[0].id, "valid", "should keep valid UID"); + assert_eq!( + merged, + json!([{ "source": SOURCE_S, "inserter": "inserter-1.example.com", "mm": 1, "uids": [{ "id": "u1", "atype": 1 }] }]), + "KV's operator-configured atype should not override the browser's" + ); } #[test] - fn parse_client_auction_eids_preserves_pair_atype() { - let raw = json!([ - { - "source": "google.com", - "uids": [{ "id": "pair-id", "atype": 571187 }] - } - ]); + fn merge_case_3_drops_the_stale_kv_uid_for_a_browser_source() { + let merged = merged_json( + Some(vec![browser_eid( + SOURCE_S, + "u1", + None, + Some("inserter-1.example.com"), + Some(1), + )]), + Some(vec![kv_eid(SOURCE_S, "u0", 3)]), + ); + + assert_eq!( + merged, + json!([{ "source": SOURCE_S, "inserter": "inserter-1.example.com", "mm": 1, "uids": [{ "id": "u1" }] }]), + "KV's value for a source the browser sends should not be forwarded" + ); + } - let parsed = parse_client_auction_eids(Some(&raw)).expect("should parse PAIR EID"); + #[test] + fn merge_case_4_appends_kv_sources_the_browser_did_not_send() { + let merged = merged_json( + Some(vec![browser_eid(SOURCE_S, "u1", None, None, None)]), + Some(vec![kv_eid(SOURCE_T, "t1", 3)]), + ); assert_eq!( - parsed[0].uids[0].atype, - Some(571187), - "should preserve PAIR's vendor-specific atype" + merged, + json!([ + { "source": SOURCE_S, "uids": [{ "id": "u1" }] }, + { "source": SOURCE_T, "uids": [{ "id": "t1", "atype": 3 }] } + ]), + "browser entries should come first and KV-only sources after" ); } #[test] - fn parse_client_auction_eids_preserves_uid_ext_and_sanitizes_invalid_atype() { - let raw = json!([ - { - "source": "adserver.org", - "uids": [ - { - "id": "uid-with-ext", - "atype": 1, - "ext": { "provider": "liveintent.com", "rtiPartner": "TDID" } - }, - { - "id": "uid-bad-atype", - "atype": 2_147_483_648_u64, - "ext": { "keep": true } - }, - { - "id": "uid-float-atype", - "atype": 1.5 - } - ] - } - ]); + fn invalid_legacy_cookie_uids_neither_reach_providers_nor_displace_kv() { + for (case, invalid_id) in [ + ("whitespace-only", " ".to_owned()), + ("oversized", "x".repeat(MAX_UID_LENGTH + 1)), + ] { + let cookie_payload = json!([{ "source": SOURCE_S, "id": invalid_id, "atype": 1 }]); + let encoded = BASE64.encode( + serde_json::to_vec(&cookie_payload).expect("should serialize legacy cookie"), + ); - let parsed = parse_client_auction_eids(Some(&raw)).expect("should parse valid EIDs"); + let client = resolve_client_auction_eids(None, Some(&encoded)); + let merged = merged_json(client.clone(), Some(vec![kv_eid(SOURCE_S, "kv-uid", 3)])); - assert_eq!(parsed.len(), 1, "should keep valid source"); - assert_eq!(parsed[0].uids.len(), 3, "should keep valid UIDs"); - assert_eq!( - parsed[0].uids[0].atype, - Some(1), - "should preserve valid atype" + assert_eq!(client, None, "should drop the {case} legacy cookie UID"); + assert_eq!( + merged, + json!([{ "source": SOURCE_S, "uids": [{ "id": "kv-uid", "atype": 3 }] }]), + "should keep the KV identity when the {case} legacy UID is dropped" + ); + } + } + + #[test] + fn merge_case_5_keeps_same_source_entries_with_different_fields_apart() { + let merged = merged_json( + Some(vec![ + browser_eid( + SOURCE_S, + "u1", + None, + Some("inserter-1.example.com"), + Some(1), + ), + browser_eid( + SOURCE_S, + "u1", + None, + Some("inserter-2.example.com"), + Some(2), + ), + ]), + None, ); + assert_eq!( - parsed[0].uids[0].ext, - Some(json!({ "provider": "liveintent.com", "rtiPartner": "TDID" })), - "should preserve uid ext" + merged, + json!([ + { "source": SOURCE_S, "inserter": "inserter-1.example.com", "mm": 1, "uids": [{ "id": "u1" }] }, + { "source": SOURCE_S, "inserter": "inserter-2.example.com", "mm": 2, "uids": [{ "id": "u1" }] } + ]), + "two browser entries for one source should stay two entries" + ); + } + + #[test] + fn merge_case_6_matches_sources_as_the_registry_normalizes_them() { + let merged = merged_json( + Some(vec![browser_eid( + "IDS.Example.com.", + "u1", + None, + None, + None, + )]), + Some(vec![kv_eid(SOURCE_S, "u0", 3)]), ); + assert_eq!( - parsed[0].uids[1].atype, None, - "should drop out-of-range atype without dropping uid" + merged, + json!([{ "source": "IDS.Example.com.", "uids": [{ "id": "u1" }] }]), + "a browser source differing only in case or a trailing dot should suppress KV's" ); + } + + #[test] + fn merge_case_7_forwards_kv_entries_when_the_browser_sent_none() { + let merged = merged_json( + None, + Some(vec![kv_eid(SOURCE_S, "u0", 3), kv_eid(SOURCE_T, "t1", 3)]), + ); + assert_eq!( - parsed[0].uids[1].ext, - Some(json!({ "keep": true })), - "should preserve ext when atype is invalid" + merged, + json!([ + { "source": SOURCE_S, "uids": [{ "id": "u0", "atype": 3 }] }, + { "source": SOURCE_T, "uids": [{ "id": "t1", "atype": 3 }] } + ]), + "KV entries should be forwarded unchanged without browser EIDs" ); + } + + #[test] + fn merge_drops_entries_without_uids() { + let empty_browser = Eid::new(SOURCE_S.to_owned(), Vec::new()); + let empty_kv = Eid::new(SOURCE_T.to_owned(), Vec::new()); + assert_eq!( - parsed[0].uids[2].atype, None, - "should drop non-integer atype without dropping uid" + merged_json(Some(vec![empty_browser]), Some(vec![empty_kv])), + JsonValue::Null, + "entries without UIDs should be dropped, leaving no EIDs" ); } #[test] fn merge_auction_eids_deduplicates_client_and_resolved_ids() { - let client_eids = Some(vec![Eid { - source: "id5-sync.com".to_string(), - uids: vec![Uid { - id: "ID5_abc".to_string(), - atype: Some(1), - ext: None, - }], - }]); - let resolved_eids = Some(vec![ - Eid { - source: "id5-sync.com".to_string(), - uids: vec![Uid { - id: "ID5_abc".to_string(), - atype: Some(1), - ext: None, - }], - }, - Eid { - source: "liveramp.com".to_string(), - uids: vec![Uid { - id: "LR_xyz".to_string(), - atype: Some(3), - ext: None, - }], - }, - ]); - - let merged = merge_auction_eids(client_eids, resolved_eids).expect("should merge EIDs"); + let merged = merged_json( + Some(vec![kv_eid("id5-sync.com", "ID5_abc", 1)]), + Some(vec![ + kv_eid("id5-sync.com", "ID5_abc", 1), + kv_eid("liveramp.com", "LR_xyz", 3), + ]), + ); - assert_eq!(merged.len(), 2, "should retain distinct EID sources"); - assert_eq!(merged[0].source, "id5-sync.com"); - assert_eq!(merged[0].uids.len(), 1, "should deduplicate matching UIDs"); - assert_eq!(merged[1].source, "liveramp.com"); - assert_eq!(merged[1].uids[0].id, "LR_xyz"); + assert_eq!( + merged, + json!([ + { "source": "id5-sync.com", "uids": [{ "id": "ID5_abc", "atype": 1 }] }, + { "source": "liveramp.com", "uids": [{ "id": "LR_xyz", "atype": 3 }] } + ]), + "the browser's id5 entry should go out once, followed by KV's liveramp" + ); } #[test] - fn merge_auction_eids_preserves_multiple_uids_per_source() { - let client_eids = Some(vec![Eid { - source: "sharedid.org".to_string(), - uids: vec![Uid { - id: "shared_client".to_string(), - atype: None, - ext: None, - }], - }]); - let resolved_eids = Some(vec![Eid { - source: "sharedid.org".to_string(), - uids: vec![Uid { - id: "shared_server".to_string(), - atype: Some(3), - ext: None, - }], - }]); - - let merged = merge_auction_eids(client_eids, resolved_eids).expect("should merge EIDs"); + fn merge_auction_eids_does_not_union_kv_uids_into_a_browser_source() { + let merged = merged_json( + Some(vec![browser_eid( + "sharedid.org", + "shared_client", + None, + None, + None, + )]), + Some(vec![kv_eid("sharedid.org", "shared_server", 3)]), + ); - assert_eq!(merged.len(), 1, "should merge same-source entries"); - assert_eq!(merged[0].uids.len(), 2, "should preserve distinct UIDs"); - assert_eq!(merged[0].uids[0].id, "shared_server"); - assert_eq!(merged[0].uids[1].id, "shared_client"); + assert_eq!( + merged, + json!([{ "source": "sharedid.org", "uids": [{ "id": "shared_client" }] }]), + "KV's UID for a source the browser sends should not be added to it" + ); } #[test] - fn merge_auction_eids_prefers_server_resolved_metadata_on_conflict() { - let client_eids = Some(vec![Eid { - source: "adserver.org".to_string(), - uids: vec![Uid { - id: "shared_uid".to_string(), + fn merge_auction_eids_keeps_browser_metadata_on_conflict() { + let client = Eid::new( + "adserver.org".to_owned(), + vec![Uid { + id: "shared_uid".to_owned(), atype: Some(1), ext: Some(json!({ "provider": "client" })), }], - }]); - let resolved_eids = Some(vec![Eid { - source: "adserver.org".to_string(), - uids: vec![Uid { - id: "shared_uid".to_string(), + ); + let resolved = Eid::new( + "adserver.org".to_owned(), + vec![Uid { + id: "shared_uid".to_owned(), atype: Some(3), ext: Some(json!({ "provider": "server" })), }], - }]); - - let merged = merge_auction_eids(client_eids, resolved_eids).expect("should merge EIDs"); - - assert_eq!(merged.len(), 1, "should merge duplicate source"); - assert_eq!(merged[0].uids.len(), 1, "should deduplicate duplicate uid"); - assert_eq!( - merged[0].uids[0].atype, - Some(3), - "should prefer resolved atype" ); + assert_eq!( - merged[0].uids[0].ext, - Some(json!({ "provider": "server" })), - "should prefer resolved ext" + merged_json(Some(vec![client]), Some(vec![resolved])), + json!([{ + "source": "adserver.org", + "uids": [{ "id": "shared_uid", "atype": 1, "ext": { "provider": "client" } }] + }]), + "the browser's atype and ext should win over KV's" ); } diff --git a/crates/trusted-server-core/src/auction/formats.rs b/crates/trusted-server-core/src/auction/formats.rs index a0df63d94a..cec4823005 100644 --- a/crates/trusted-server-core/src/auction/formats.rs +++ b/crates/trusted-server-core/src/auction/formats.rs @@ -9,6 +9,7 @@ use error_stack::{Report, ResultExt, ensure}; use http::{HeaderValue, Request, Response, StatusCode, header}; use serde::Deserialize; use serde_json::Value as JsonValue; +use serde_json::value::RawValue; use std::collections::{BTreeMap, HashMap, HashSet}; use url::Url; use uuid::Uuid; @@ -43,7 +44,10 @@ use super::types::{ pub struct AdRequest { pub ad_units: Vec, pub config: Option, - pub eids: Option, + /// Browser EIDs, captured as raw JSON so a malformed entry (for example a + /// lone surrogate escape) cannot fail the whole body. Each entry is + /// validated on its own by the shared client EID parser. + pub eids: Option>, } /// A single ad placement in an [`AdRequest`]. @@ -558,12 +562,16 @@ mod tests { AuctionPlan, AuctionPlanConfig, NotificationConfig, ProviderConfig, ProviderId, RoutingMode, }; use crate::auction::routing::route_auction; + use crate::auction::test_support::{ + repeated_source_provenance_eids, repeated_source_provenance_eids_json, + }; use crate::auction::types::{ ApsRendererV1, ApsTagType, AuctionResponse, Bid, BidRenderer, BidStatus, }; use crate::openrtb::{Eid, Uid}; use crate::platform::test_support::noop_services; use crate::test_support::tests::create_test_settings; + use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64}; use http::Method; use serde_json::json; use std::collections::{BTreeMap, BTreeSet, HashSet}; @@ -810,17 +818,44 @@ mod tests { ); } + #[test] + fn eid_header_carries_provenance_fields_and_repeated_sources() { + let mut request = make_auction_request(); + request.user.eids = Some(repeated_source_provenance_eids()); + + let response = + convert_to_openrtb_response(&make_empty_result(), &make_settings(), &request, true) + .expect("should build response"); + + let header = response + .headers() + .get(&HEADER_X_TS_EIDS) + .and_then(|value| value.to_str().ok()) + .expect("should include x-ts-eids"); + let decoded: serde_json::Value = serde_json::from_slice( + &BASE64 + .decode(header) + .expect("should decode x-ts-eids base64"), + ) + .expect("should parse x-ts-eids JSON"); + assert_eq!( + decoded, + repeated_source_provenance_eids_json(), + "x-ts-eids should show both entries with their own EID-level fields" + ); + } + #[test] fn response_includes_eid_headers_when_eids_present() { let mut request = make_auction_request(); - request.user.eids = Some(vec![Eid { - source: "ssp.com".to_owned(), - uids: vec![Uid { + request.user.eids = Some(vec![Eid::new( + "ssp.com".to_owned(), + vec![Uid { id: "uid-1".to_owned(), atype: Some(3), ext: None, }], - }]); + )]); let settings = make_settings(); let result = make_empty_result(); diff --git a/crates/trusted-server-core/src/auction/openrtb/tests.rs b/crates/trusted-server-core/src/auction/openrtb/tests.rs index f47efa475e..1ed5683169 100644 --- a/crates/trusted-server-core/src/auction/openrtb/tests.rs +++ b/crates/trusted-server-core/src/auction/openrtb/tests.rs @@ -1569,3 +1569,18 @@ fn malformed_top_level_standard_response_is_error() { assert_eq!(response.status, BidStatus::Error); assert_eq!(response.metadata["error_type"], "parse_response"); } + +#[test] +fn standard_profile_forwards_eid_provenance_fields_and_repeated_sources() { + let mut request = canonical_parity_auction_request(); + request.user.eids = Some(crate::auction::test_support::repeated_source_provenance_eids()); + + let built = build_with_request("standard", json!({}), request, None); + + let serialized = serde_json::to_value(&built).expect("should serialize request"); + assert_eq!( + serialized["user"]["ext"]["eids"], + crate::auction::test_support::repeated_source_provenance_eids_json(), + "config-first providers should receive both entries with their own EID-level fields" + ); +} diff --git a/crates/trusted-server-core/src/auction/test_support.rs b/crates/trusted-server-core/src/auction/test_support.rs index a83f8899a8..5d44d906a1 100644 --- a/crates/trusted-server-core/src/auction/test_support.rs +++ b/crates/trusted-server-core/src/auction/test_support.rs @@ -92,14 +92,14 @@ pub(crate) fn canonical_parity_auction_request() -> AuctionRequest { raw_ac_string: Some("fictional-ac".to_string()), ..Default::default() }), - eids: Some(vec![Eid { - source: "identity.example".to_string(), - uids: vec![Uid { + eids: Some(vec![Eid::new( + "identity.example".to_string(), + vec![Uid { id: "fictional-uid".to_string(), atype: Some(1), ext: None, }], - }]), + )]), }, device: Some(DeviceInfo { user_agent: Some("Fictional Browser".to_string()), @@ -119,3 +119,56 @@ pub(crate) fn canonical_parity_auction_request() -> AuctionRequest { context: HashMap::new(), } } + +/// Two entries for one source with different EID-level fields, the way +/// Prebid sends them (spec 3.2), for outbound serialization tests. +pub(crate) fn repeated_source_provenance_eids() -> Vec { + vec![ + Eid { + source: "ids.example.com".to_string(), + uids: vec![Uid { + id: "fictional-uid".to_string(), + atype: Some(1), + ext: None, + }], + inserter: Some("inserter-a.example.com".to_string()), + matcher: Some("ids.example.com".to_string()), + mm: Some(4), + ext: None, + }, + Eid { + source: "ids.example.com".to_string(), + uids: vec![Uid { + id: "fictional-uid".to_string(), + atype: Some(1), + ext: None, + }], + inserter: Some("inserter-b.example.com".to_string()), + matcher: Some("matcher-b.example.com".to_string()), + mm: Some(2), + ext: Some(json!({ "segments": ["s1"] })), + }, + ] +} + +/// [`repeated_source_provenance_eids`] as the JSON every outbound path must +/// emit in `user.ext.eids`. +pub(crate) fn repeated_source_provenance_eids_json() -> serde_json::Value { + json!([ + { + "source": "ids.example.com", + "uids": [{ "id": "fictional-uid", "atype": 1 }], + "inserter": "inserter-a.example.com", + "matcher": "ids.example.com", + "mm": 4 + }, + { + "source": "ids.example.com", + "uids": [{ "id": "fictional-uid", "atype": 1 }], + "inserter": "inserter-b.example.com", + "matcher": "matcher-b.example.com", + "mm": 2, + "ext": { "segments": ["s1"] } + } + ]) +} diff --git a/crates/trusted-server-core/src/ec/admin.rs b/crates/trusted-server-core/src/ec/admin.rs index e21fab702c..18b5563307 100644 --- a/crates/trusted-server-core/src/ec/admin.rs +++ b/crates/trusted-server-core/src/ec/admin.rs @@ -1478,6 +1478,33 @@ mod tests { ); } + #[test] + fn eids_lookup_reports_non_array_cookie_as_parse_error() { + let payload = BASE64.encode(br#"{"a":1}"#); + let req = get_request_with_cookie("/_ts/admin/eids", &format!("ts-eids={payload}")); + + let response = + handle_admin_eids_lookup(&test_registry(), &req).expect("should handle eids lookup"); + + let json = response_json(response); + assert_eq!(json["cookie_present"], true); + assert!( + json["parse_error"] + .as_str() + .expect("should have parse_error") + .contains("ts-eids"), + "should describe the parse failure" + ); + assert!(json.get("eids").is_none(), "should omit unparsed eids"); + assert!( + json["ingest"]["matched"] + .as_array() + .expect("should have matched list") + .is_empty(), + "non-array cookie should preview no matches" + ); + } + #[test] fn eids_lookup_reports_parse_error() { let req = get_request_with_cookie("/_ts/admin/eids", "ts-eids=!!!not-base64!!!"); diff --git a/crates/trusted-server-core/src/ec/client_eids.rs b/crates/trusted-server-core/src/ec/client_eids.rs new file mode 100644 index 0000000000..1743ac53e5 --- /dev/null +++ b/crates/trusted-server-core/src/ec/client_eids.rs @@ -0,0 +1,600 @@ +//! Parsing and validation of browser-supplied EIDs. +//! +//! The `/auction` request body and the `ts-eids` cookie carry the same +//! OpenRTB-style EID array, which TSJS copies from the EIDs Prebid placed in +//! the `trustedServer` bidder request. Both go through [`parse_client_eids`], +//! so a malformed entry or field costs only itself, whichever transport or +//! client sent it. +//! +//! Rules, per entry: +//! +//! - The entry parses on its own and is a JSON object, or it is dropped. +//! - `source` is a string, not blank, at most [`MAX_CLIENT_EID_LABEL_BYTES`] +//! UTF-8 bytes, or the entry is dropped. +//! - `uids` holds at least one valid UID, or the entry is dropped. The first +//! [`MAX_CLIENT_UIDS_PER_ENTRY`] valid UIDs are kept. +//! - `uids[].id` is a string, not blank, at most `MAX_UID_LENGTH` bytes, or +//! the UID is dropped. `uids[].atype` is an integer from 0 to `i32::MAX`; +//! `uids[].ext` is an object of at most [`MAX_CLIENT_EID_EXT_BYTES`] as +//! compact JSON. An invalid `atype` or `ext` drops only that field. +//! - `inserter` and `matcher` follow the `source` rule, `mm` the `atype` rule +//! and `ext` the UID `ext` rule. An invalid value drops only that field. +//! - Unknown keys are ignored. Order is preserved and a source may repeat. +//! +//! An entry equal to an earlier kept entry is dropped, and then the first +//! [`MAX_CLIENT_EID_ENTRIES`] entries are kept. "Blank" means empty or only +//! whitespace by `str::trim`. "Integer" means a JSON number with no fractional +//! part, so `4`, `4.0` and `4e0` are the same value. + +use serde_json::Value as JsonValue; +use serde_json::value::RawValue; + +use crate::openrtb::{Eid, Uid}; + +use super::kv_types::MAX_UID_LENGTH; + +/// Maximum number of entries kept from one browser EID array. +pub(crate) const MAX_CLIENT_EID_ENTRIES: usize = 64; + +/// Maximum number of valid UIDs kept per entry. +pub(crate) const MAX_CLIENT_UIDS_PER_ENTRY: usize = 32; + +/// Maximum UTF-8 length of `source`, `inserter` and `matcher`. +pub(crate) const MAX_CLIENT_EID_LABEL_BYTES: usize = 255; + +/// Maximum compact-JSON length of an EID-level or UID-level `ext` object. +pub(crate) const MAX_CLIENT_EID_EXT_BYTES: usize = 2048; + +/// Parses and validates browser-supplied EID entries from an `/auction` body +/// or a `ts-eids` cookie. +/// +/// See the module documentation for the rules. Anything other than a JSON +/// array yields no entries. +/// +/// # Performance +/// +/// Parses each element into a [`JsonValue`] once. Duplicate detection is +/// quadratic in the kept entries, which are capped at +/// [`MAX_CLIENT_EID_ENTRIES`]. +#[must_use] +pub(crate) fn parse_client_eids(raw: &RawValue) -> Vec { + // Capturing elements as raw JSON does not validate string escapes, so an + // element holding a lone surrogate fails only its own parse below. + let Ok(elements) = serde_json::from_str::>(raw.get()) else { + log::debug!("Client EIDs: payload is not a JSON array; ignoring it"); + return Vec::new(); + }; + + let mut eids: Vec = Vec::new(); + for element in &elements { + if eids.len() >= MAX_CLIENT_EID_ENTRIES { + log::debug!( + "Client EIDs: keeping the first {MAX_CLIENT_EID_ENTRIES} entries; dropping the rest" + ); + break; + } + let Some(eid) = parse_client_eid(element) else { + continue; + }; + if eids.contains(&eid) { + log::debug!( + "Client EIDs: dropping a duplicate entry for source '{}'", + eid.source + ); + continue; + } + eids.push(eid); + } + eids +} + +fn parse_client_eid(element: &RawValue) -> Option { + let entry = match serde_json::from_str::(element.get()) { + Ok(JsonValue::Object(entry)) => entry, + Ok(_) => { + log::debug!("Client EIDs: dropping an entry that is not a JSON object"); + return None; + } + Err(_) => { + log::debug!("Client EIDs: dropping an entry that does not parse"); + return None; + } + }; + + let Some(source) = entry.get("source").and_then(parse_client_eid_label) else { + log::debug!("Client EIDs: dropping an entry without a valid `source`"); + return None; + }; + + let uids: Vec = match entry.get("uids") { + Some(JsonValue::Array(raw_uids)) => raw_uids + .iter() + .filter_map(|raw_uid| parse_client_uid(raw_uid, &source)) + .take(MAX_CLIENT_UIDS_PER_ENTRY) + .collect(), + _ => Vec::new(), + }; + if uids.is_empty() { + log::debug!("Client EIDs: dropping source '{source}' without a valid UID"); + return None; + } + + let inserter = parse_optional_field(&entry, "EID", "inserter", &source, parse_client_eid_label); + let matcher = parse_optional_field(&entry, "EID", "matcher", &source, parse_client_eid_label); + let mm = parse_optional_field(&entry, "EID", "mm", &source, parse_client_eid_integer); + let ext = parse_optional_field(&entry, "EID", "ext", &source, parse_client_eid_ext); + + Some(Eid { + source, + uids, + inserter, + matcher, + mm, + ext, + }) +} + +fn parse_client_uid(raw: &JsonValue, source: &str) -> Option { + let JsonValue::Object(uid) = raw else { + log::debug!("Client EIDs: dropping a UID that is not an object for source '{source}'"); + return None; + }; + + let Some(id) = uid + .get("id") + .and_then(JsonValue::as_str) + .filter(|id| !id.trim().is_empty()) + .filter(|id| id.len() <= MAX_UID_LENGTH) + else { + log::debug!("Client EIDs: dropping a UID without a valid `id` for source '{source}'"); + return None; + }; + + Some(Uid { + id: id.to_owned(), + atype: parse_optional_field(uid, "UID", "atype", source, parse_client_eid_integer), + ext: parse_optional_field(uid, "UID", "ext", source, parse_client_eid_ext), + }) +} + +/// Applies `parse` to `key` when it is present, logging the field name and +/// source, never the value, when the value is invalid. +fn parse_optional_field( + object: &serde_json::Map, + scope: &str, + key: &str, + source: &str, + parse: impl Fn(&JsonValue) -> Option, +) -> Option { + let value = object.get(key)?; + let parsed = parse(value); + if parsed.is_none() { + log::debug!("Client EIDs: dropping invalid {scope} `{key}` for source '{source}'"); + } + parsed +} + +/// Reads `source`, `inserter` or `matcher`: a string that is not blank and at +/// most [`MAX_CLIENT_EID_LABEL_BYTES`] long, kept as supplied. +fn parse_client_eid_label(value: &JsonValue) -> Option { + value + .as_str() + .filter(|label| !label.trim().is_empty()) + .filter(|label| label.len() <= MAX_CLIENT_EID_LABEL_BYTES) + .map(str::to_owned) +} + +/// Reads `atype` or `mm`: a JSON number with no fractional part, from 0 to +/// `i32::MAX`. +fn parse_client_eid_integer(value: &JsonValue) -> Option { + let number = value.as_number()?; + if let Some(integer) = number.as_i64() { + return i32::try_from(integer).ok().filter(|integer| *integer >= 0); + } + if number.is_u64() { + // Above `i64::MAX`, so above the accepted range. + return None; + } + // serde_json reads `4.0`, `4e0` and `-0` as floats. + let float = number.as_f64()?; + if float.fract() != 0.0 || !(0.0..=f64::from(i32::MAX)).contains(&float) { + return None; + } + // Integral and within `0..=i32::MAX`, so the conversion is exact. + Some(float as i32) +} + +/// Reads an `ext`: a JSON object of at most [`MAX_CLIENT_EID_EXT_BYTES`] as +/// compact JSON. +fn parse_client_eid_ext(value: &JsonValue) -> Option { + if !value.is_object() { + return None; + } + let compact_len = serde_json::to_vec(value).ok()?.len(); + (compact_len <= MAX_CLIENT_EID_EXT_BYTES).then(|| value.clone()) +} + +#[cfg(test)] +mod tests { + use std::sync::{Mutex, Once}; + + use serde_json::json; + + use super::*; + + fn parse(text: &str) -> Vec { + let raw = RawValue::from_string(text.to_owned()).expect("should capture raw JSON"); + parse_client_eids(&raw) + } + + fn parsed_json(text: &str) -> JsonValue { + serde_json::to_value(parse(text)).expect("should serialize parsed EIDs") + } + + fn good(source: &str) -> String { + format!(r#"{{"source":"{source}","uids":[{{"id":"uid-{source}"}}]}}"#) + } + + fn good_json(source: &str) -> JsonValue { + json!({ "source": source, "uids": [{ "id": format!("uid-{source}") }] }) + } + + /// An `ext` object whose compact JSON is exactly `bytes` long. + fn ext_of_len(bytes: usize) -> String { + // `{"k":""}` is 8 bytes. + format!(r#"{{"k":"{}"}}"#, "x".repeat(bytes - 8)) + } + + static CAPTURED_LOGS: Mutex> = Mutex::new(Vec::new()); + static INSTALL_LOGGER: Once = Once::new(); + + struct CapturingLogger; + + impl log::Log for CapturingLogger { + fn enabled(&self, metadata: &log::Metadata<'_>) -> bool { + metadata.level() <= log::Level::Debug + } + + fn log(&self, record: &log::Record<'_>) { + if self.enabled(record.metadata()) { + CAPTURED_LOGS + .lock() + .expect("should lock captured logs") + .push(record.args().to_string()); + } + } + + fn flush(&self) {} + } + + fn install_capturing_logger() { + INSTALL_LOGGER.call_once(|| { + log::set_logger(&CapturingLogger).expect("should install the capturing test logger"); + log::set_max_level(log::LevelFilter::Debug); + }); + } + + fn captured_logs() -> Vec { + CAPTURED_LOGS + .lock() + .expect("should lock captured logs") + .clone() + } + + #[test] + fn keeps_a_valid_entry_with_every_field() { + let parsed = parsed_json( + r#"[{"source":"ids.example.com","inserter":"inserter.example.com","matcher":"matcher.example.com","mm":4,"ext":{"segments":["s1"]},"uids":[{"id":"uid-1","atype":1,"ext":{"rtiPartner":"TDID"}}]}]"#, + ); + + assert_eq!( + parsed, + json!([{ + "source": "ids.example.com", + "inserter": "inserter.example.com", + "matcher": "matcher.example.com", + "mm": 4, + "ext": { "segments": ["s1"] }, + "uids": [{ "id": "uid-1", "atype": 1, "ext": { "rtiPartner": "TDID" } }] + }]), + "should keep every valid field" + ); + } + + #[test] + fn yields_nothing_for_a_payload_that_is_not_an_array() { + for text in [ + r#"{"source":"ids.example.com"}"#, + r#""text""#, + "null", + "1", + "true", + ] { + assert_eq!( + parsed_json(text), + json!([]), + "should ignore non-array payload {text}" + ); + } + } + + #[test] + fn drops_only_the_entry_that_does_not_parse() { + // `\ud800` is a lone surrogate escape: serde_json rejects it when the + // element is parsed on its own. + let bad_entries = [ + r#"{"source":"bad-\ud800.example.com","uids":[{"id":"u"}]}"#, + r#"{"source":"bad.example.com","uids":[{"id":"u-\ud800"}]}"#, + r#"{"source":"bad.example.com","inserter":"i-\ud800","uids":[{"id":"u"}]}"#, + r#"{"source":"bad.example.com","matcher":"m-\ud800","uids":[{"id":"u"}]}"#, + r#"{"source":"bad.example.com","ext":{"k-\ud800":1},"uids":[{"id":"u"}]}"#, + r#"{"source":"bad.example.com","ext":{"k":"v-\ud800"},"uids":[{"id":"u"}]}"#, + r#"{"source":"bad.example.com","uids":[{"id":"u","ext":{"k-\ud800":1}}]}"#, + r#"{"source":"bad.example.com","uids":[{"id":"u","ext":{"k":"v-\ud800"}}]}"#, + ]; + for bad in bad_entries { + let text = format!( + "[{},{bad},{}]", + good("a.example.com"), + good("b.example.com") + ); + + assert_eq!( + parsed_json(&text), + json!([good_json("a.example.com"), good_json("b.example.com")]), + "should drop only the unparsable entry {bad}" + ); + } + } + + #[test] + fn drops_entries_that_are_not_objects() { + let text = format!(r#"[1,"text",null,[],true,{}]"#, good("a.example.com")); + + assert_eq!( + parsed_json(&text), + json!([good_json("a.example.com")]), + "should keep only the object entry" + ); + } + + #[test] + fn validates_source() { + let source_255 = format!("a{}", "é".repeat(127)); + let source_256 = "é".repeat(128); + let text = format!( + r#"[{{"uids":[{{"id":"u"}}]}},{{"source":7,"uids":[{{"id":"u"}}]}},{{"source":" ","uids":[{{"id":"u"}}]}},{{"source":"{source_256}","uids":[{{"id":"u"}}]}},{{"source":"{source_255}","uids":[{{"id":"u"}}]}}]"# + ); + + assert_eq!( + parsed_json(&text), + json!([{ "source": source_255, "uids": [{ "id": "u" }] }]), + "should keep only a non-blank string source of at most 255 UTF-8 bytes" + ); + } + + #[test] + fn drops_an_entry_without_a_valid_uid() { + let text = format!( + r#"[{{"source":"no-uids.example.com"}},{{"source":"string-uids.example.com","uids":"u"}},{{"source":"empty-uids.example.com","uids":[]}},{{"source":"bad-uids.example.com","uids":[{{"id":""}},7]}},{}]"#, + good("a.example.com") + ); + + assert_eq!( + parsed_json(&text), + json!([good_json("a.example.com")]), + "should drop entries whose uids hold no valid UID" + ); + } + + #[test] + fn validates_uid_ids() { + let id_512 = "x".repeat(512); + let id_513 = "x".repeat(513); + let text = format!( + r#"[{{"source":"ids.example.com","uids":[{{"id":7}},{{"id":" "}},{{"id":"{id_513}"}},{{"atype":1}},{{"id":"{id_512}"}}]}}]"# + ); + + assert_eq!( + parsed_json(&text), + json!([{ "source": "ids.example.com", "uids": [{ "id": id_512 }] }]), + "should keep only a non-blank string id of at most 512 bytes" + ); + } + + #[test] + fn keeps_the_first_32_valid_uids() { + let uids: Vec = [r#"{"id":""}"#.to_owned(), r#"{"id":7}"#.to_owned()] + .into_iter() + .chain((0..33).map(|index| format!(r#"{{"id":"u{index}"}}"#))) + .collect(); + let text = format!( + r#"[{{"source":"ids.example.com","uids":[{}]}}]"#, + uids.join(",") + ); + + let expected_uids: Vec = (0..32) + .map(|index| json!({ "id": format!("u{index}") })) + .collect(); + assert_eq!( + parsed_json(&text), + json!([{ "source": "ids.example.com", "uids": expected_uids }]), + "should keep the first 32 valid UIDs, not counting invalid ones" + ); + } + + #[test] + fn reads_atype_and_mm_as_integers_in_range() { + let accepted = [ + ("4", 4), + ("4.0", 4), + ("4e0", 4), + ("-0", 0), + ("0", 0), + ("2147483647", 2_147_483_647), + ("2147483647.0", 2_147_483_647), + ("571187", 571_187), + ]; + for (spelling, value) in accepted { + let text = format!( + r#"[{{"source":"ids.example.com","mm":{spelling},"uids":[{{"id":"u","atype":{spelling}}}]}}]"# + ); + + assert_eq!( + parsed_json(&text), + json!([{ "source": "ids.example.com", "mm": value, "uids": [{ "id": "u", "atype": value }] }]), + "should accept {spelling} as {value}" + ); + } + + let rejected = [ + "2147483648", + "9007199254740993", + "18446744073709551616", + "1.5", + "-1", + "1e300", + r#""1""#, + "null", + "true", + ]; + for spelling in rejected { + let text = format!( + r#"[{{"source":"ids.example.com","mm":{spelling},"uids":[{{"id":"u","atype":{spelling}}}]}}]"# + ); + + assert_eq!( + parsed_json(&text), + json!([{ "source": "ids.example.com", "uids": [{ "id": "u" }] }]), + "should drop only the fields for {spelling}" + ); + } + } + + #[test] + fn validates_ext_objects_and_their_size() { + let ext_2048 = ext_of_len(2048); + let ext_2049 = ext_of_len(2049); + let text = format!( + r#"[{{"source":"keep.example.com","ext":{ext_2048},"uids":[{{"id":"u","ext":{ext_2048}}}]}},{{"source":"big.example.com","ext":{ext_2049},"uids":[{{"id":"u","ext":{ext_2049}}}]}},{{"source":"shape.example.com","ext":[1],"uids":[{{"id":"u","ext":"text"}}]}}]"# + ); + let kept_ext: JsonValue = serde_json::from_str(&ext_2048).expect("should parse ext"); + + assert_eq!( + parsed_json(&text), + json!([ + { "source": "keep.example.com", "ext": kept_ext, "uids": [{ "id": "u", "ext": kept_ext }] }, + { "source": "big.example.com", "uids": [{ "id": "u" }] }, + { "source": "shape.example.com", "uids": [{ "id": "u" }] } + ]), + "should keep objects of at most 2048 compact bytes and drop only the bad fields" + ); + } + + #[test] + fn validates_inserter_and_matcher() { + let label_255 = format!("a{}", "é".repeat(127)); + let label_256 = "é".repeat(128); + let text = format!( + r#"[{{"source":"keep.example.com","inserter":"{label_255}","matcher":"{label_255}","uids":[{{"id":"u"}}]}},{{"source":"drop.example.com","inserter":"{label_256}","matcher":" ","uids":[{{"id":"u"}}]}},{{"source":"type.example.com","inserter":7,"matcher":null,"uids":[{{"id":"u"}}]}}]"# + ); + + assert_eq!( + parsed_json(&text), + json!([ + { "source": "keep.example.com", "inserter": label_255, "matcher": label_255, "uids": [{ "id": "u" }] }, + { "source": "drop.example.com", "uids": [{ "id": "u" }] }, + { "source": "type.example.com", "uids": [{ "id": "u" }] } + ]), + "should keep non-blank strings of at most 255 bytes and drop only the bad fields" + ); + } + + #[test] + fn drops_duplicates_before_the_entry_cap() { + let mut entries: Vec = (0..64) + .map(|index| good(&format!("s{index}.example.com"))) + .collect(); + // Equal to entry 0 once validated: unknown keys are ignored. + entries.insert( + 1, + r#"{"source":"s0.example.com","uids":[{"id":"uid-s0.example.com"}],"unknown":true}"# + .to_owned(), + ); + let text = format!("[{}]", entries.join(",")); + + let expected: Vec = (0..64) + .map(|index| good_json(&format!("s{index}.example.com"))) + .collect(); + assert_eq!( + parsed_json(&text), + JsonValue::Array(expected), + "a duplicate should not take one of the 64 places" + ); + } + + #[test] + fn keeps_the_first_64_entries() { + let entries: Vec = (0..70) + .map(|index| good(&format!("s{index}.example.com"))) + .collect(); + let text = format!("[{}]", entries.join(",")); + + let expected: Vec = (0..64) + .map(|index| good_json(&format!("s{index}.example.com"))) + .collect(); + assert_eq!( + parsed_json(&text), + JsonValue::Array(expected), + "should keep the first 64 entries" + ); + } + + #[test] + fn keeps_order_and_repeated_sources_and_ignores_unknown_keys() { + let parsed = parsed_json( + r#"[{"source":"b.example.com","uids":[{"id":"u","unknown":1}],"unknown":2},{"source":"a.example.com","mm":1,"uids":[{"id":"u"}]},{"source":"a.example.com","mm":2,"uids":[{"id":"u"}]}]"#, + ); + + assert_eq!( + parsed, + json!([ + { "source": "b.example.com", "uids": [{ "id": "u" }] }, + { "source": "a.example.com", "mm": 1, "uids": [{ "id": "u" }] }, + { "source": "a.example.com", "mm": 2, "uids": [{ "id": "u" }] } + ]), + "should keep wire order and both same-source entries" + ); + } + + #[test] + fn logs_dropped_entries_and_fields_by_name_and_source_without_values() { + install_capturing_logger(); + + let parsed = parsed_json( + r#"[{"source":"log-a.example.com","mm":-1,"uids":[{"id":"secret-uid-a","atype":"1"}]},{"source":"log-b.example.com","uids":[{"id":""}]}]"#, + ); + + assert_eq!( + parsed, + json!([{ "source": "log-a.example.com", "uids": [{ "id": "secret-uid-a" }] }]), + "should keep the entry and drop only its invalid fields" + ); + let logs = captured_logs(); + for (field, source) in [ + ("`mm`", "log-a.example.com"), + ("`atype`", "log-a.example.com"), + ("without a valid UID", "log-b.example.com"), + ] { + assert!( + logs.iter() + .any(|line| line.contains(field) && line.contains(source)), + "should log {field} for {source}; captured: {logs:?}" + ); + } + assert!( + !logs.iter().any(|line| line.contains("secret-uid-a")), + "should never log a UID value" + ); + } +} diff --git a/crates/trusted-server-core/src/ec/eids.rs b/crates/trusted-server-core/src/ec/eids.rs index 1359f75091..0493f5dec1 100644 --- a/crates/trusted-server-core/src/ec/eids.rs +++ b/crates/trusted-server-core/src/ec/eids.rs @@ -64,13 +64,15 @@ pub fn resolve_partner_ids(registry: &PartnerRegistry, entry: &KvEntry) -> Vec 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, - }], + .map(|item| { + Eid::new( + item.source_domain.clone(), + vec![Uid { + id: item.uid.clone(), + atype: Some(item.openrtb_atype), + ext: None, + }], + ) }) .collect() } diff --git a/crates/trusted-server-core/src/ec/finalize.rs b/crates/trusted-server-core/src/ec/finalize.rs index 019ef2aa40..e41a8180d4 100644 --- a/crates/trusted-server-core/src/ec/finalize.rs +++ b/crates/trusted-server-core/src/ec/finalize.rs @@ -10,6 +10,7 @@ use http::Response; use super::consent::{ec_consent_granted, ec_consent_withdrawn}; use crate::consent::{ConsentContext, allows_eid_persistence}; +use crate::openrtb::Eid; use crate::settings::Settings; use super::EcContext; @@ -43,7 +44,8 @@ const EC_RESPONSE_HEADERS: &[&str] = &[ /// revocation marker. There is no separate consent KV store to clean up. /// /// `eids_cookie` should be the raw value of the `ts-eids` cookie extracted -/// from the request *before* routing consumes it. +/// from the request *before* routing consumes it. `/auction` responses ignore +/// it in favor of the request's own client set. pub fn ec_finalize_response( settings: &Settings, ec_context: &mut EcContext, @@ -81,12 +83,8 @@ pub fn ec_finalize_response( let source = ec_context.eid_sync_source(); let collected = source .map(|_| { - collect_eid_updates( - eids_cookie, - sharedid_cookie, - ec_context.client_eids(), - registry, - ) + let (cookie_input, client_input) = browser_eid_input(ec_context, eids_cookie); + collect_eid_updates(cookie_input, sharedid_cookie, client_input, registry) }) .unwrap_or_default(); let had_eid_updates = !collected.is_empty(); @@ -138,13 +136,9 @@ pub fn ec_finalize_response( return; }; + let (cookie_input, client_input) = browser_eid_input(ec_context, eids_cookie); let updates = gate_eid_updates_by_consent( - collect_eid_updates( - eids_cookie, - sharedid_cookie, - ec_context.client_eids(), - registry, - ), + collect_eid_updates(cookie_input, sharedid_cookie, client_input, registry), ec_context.consent(), ); sync_eid_cookie_updates(graph, ec_context, &ec_id, &updates, EidSyncSource::NewEc); @@ -158,6 +152,23 @@ pub fn ec_finalize_response( reconcile_pull_sync_marker(settings, registry, ec_context, response); } +/// Chooses the browser EID input KV ingestion reads for this request. +/// +/// `/auction` ingests only the client set the auction itself used (its body, +/// else its `ts-eids` cookie, as `handle_auction` resolved it). A cookie from +/// an earlier page is then never ingested next to a scoped body, and an +/// `/auction` that resolved no client set ingests nothing from the cookie. +/// Every other route, and requests with no sync source, read the cookie. +fn browser_eid_input<'a>( + ec_context: &'a EcContext, + eids_cookie: Option<&'a str>, +) -> (Option<&'a str>, Option<&'a [Eid]>) { + match ec_context.eid_sync_source() { + Some(EidSyncSource::Auction) => (None, ec_context.client_eids()), + _ => (eids_cookie, None), + } +} + fn sync_eid_cookie_updates( graph: &KvIdentityGraph, ec_context: &mut EcContext, @@ -1115,17 +1126,15 @@ mod tests { ); ec_context.set_recovery_eligible(true); ec_context.set_eid_sync_source(EidSyncSource::Navigation); - ec_context.set_client_eids(vec![Eid { - source: "id5-sync.com".to_owned(), - uids: vec![Uid { - id: "ID5_should_not_persist".to_owned(), - atype: Some(1), - ext: None, - }], - }]); let partners = vec![make_partner("id5-sync.com")]; let registry = PartnerRegistry::from_config(&partners).expect("should build registry"); let graph = KvIdentityGraph::in_memory("test_store"); + let eids_cookie = base64::engine::general_purpose::STANDARD.encode( + serde_json::to_vec(&serde_json::json!([ + { "source": "id5-sync.com", "uids": [{ "id": "ID5_should_not_persist", "atype": 1 }] } + ])) + .expect("should serialize test cookie payload"), + ); let mut response = empty_response(); ec_finalize_response( @@ -1133,7 +1142,7 @@ mod tests { &mut ec_context, Some(&graph), ®istry, - None, + Some(&eids_cookie), None, &mut response, ); @@ -1293,6 +1302,58 @@ mod tests { } } + #[test] + fn finalize_generated_ec_without_sync_source_ingests_the_ts_eids_cookie() { + let settings = create_test_settings(); + let ec_id = sample_ec_id("genck1"); + let graph = KvIdentityGraph::in_memory("test_store"); + let live = KvEntry::new( + &granting_consent(), + None, + current_timestamp(), + &settings.publisher.domain, + ); + graph + .create(&ec_id, &live) + .expect("should seed generated row"); + let mut ec_context = + make_context(Some(&ec_id), None, false, true, Jurisdiction::NonRegulated); + ec_context.set_kv_snapshot(graph.load_snapshot(&ec_id)); + let registry = PartnerRegistry::from_config(&[make_partner("ids.example.com")]) + .expect("should build registry"); + let eids_cookie = base64::engine::general_purpose::STANDARD.encode( + serde_json::to_vec(&serde_json::json!([ + { "source": "ids.example.com", "uids": [{ "id": "cookie-uid" }] } + ])) + .expect("should serialize test cookie payload"), + ); + let mut response = empty_response(); + + ec_finalize_response( + &settings, + &mut ec_context, + Some(&graph), + ®istry, + Some(&eids_cookie), + None, + &mut response, + ); + + let (stored, _) = graph + .get(&ec_id) + .expect("should read store") + .expect("row should remain"); + assert_eq!( + stored + .ids + .iter() + .map(|(source, id)| (source.as_str(), id.uid.as_str())) + .collect::>(), + vec![("ids.example.com", "cookie-uid")], + "a new EC with no sync source should ingest the cookie" + ); + } + #[test] fn finalize_generated_ec_persists_eid_updates() { let settings = create_test_settings(); @@ -1517,22 +1578,22 @@ mod tests { .expect("should serialize test cookie payload"), ); ec_context.set_client_eids(vec![ - Eid { - source: "id5-sync.com".to_owned(), - uids: vec![Uid { + Eid::new( + "id5-sync.com".to_owned(), + vec![Uid { id: "ID5_from_body".to_owned(), atype: Some(1), ext: None, }], - }, - Eid { - source: "liveramp.com".to_owned(), - uids: vec![Uid { + ), + Eid::new( + "liveramp.com".to_owned(), + vec![Uid { id: "LR_from_body".to_owned(), atype: Some(3), ext: None, }], - }, + ), ]); let partners = vec![make_partner("id5-sync.com"), make_partner("liveramp.com")]; let registry = PartnerRegistry::from_config(&partners).expect("should build registry"); @@ -1595,14 +1656,14 @@ mod tests { ec_id: ec_id.clone(), }); ec_context.set_eid_sync_source(EidSyncSource::Auction); - ec_context.set_client_eids(vec![Eid { - source: "id5-sync.com".to_owned(), - uids: vec![Uid { + ec_context.set_client_eids(vec![Eid::new( + "id5-sync.com".to_owned(), + vec![Uid { id: "ID5_should_not_persist".to_owned(), atype: Some(1), ext: None, }], - }]); + )]); let partners = vec![make_partner("id5-sync.com")]; let registry = PartnerRegistry::from_config(&partners).expect("should build registry"); let mut response = empty_response(); diff --git a/crates/trusted-server-core/src/ec/identify.rs b/crates/trusted-server-core/src/ec/identify.rs index 94464d1a57..c22a592461 100644 --- a/crates/trusted-server-core/src/ec/identify.rs +++ b/crates/trusted-server-core/src/ec/identify.rs @@ -124,13 +124,15 @@ pub fn handle_identify( } } - let eid = uid.as_ref().map(|u| Eid { - source: partner.source_domain.clone(), - uids: vec![Uid { - id: u.clone(), - atype: Some(partner.openrtb_atype), - ext: None, - }], + let eid = uid.as_ref().map(|u| { + Eid::new( + partner.source_domain.clone(), + vec![Uid { + id: u.clone(), + atype: Some(partner.openrtb_atype), + ext: None, + }], + ) }); let body = IdentifyResponse { diff --git a/crates/trusted-server-core/src/ec/mod.rs b/crates/trusted-server-core/src/ec/mod.rs index f24c489edd..cc6705eaf8 100644 --- a/crates/trusted-server-core/src/ec/mod.rs +++ b/crates/trusted-server-core/src/ec/mod.rs @@ -33,6 +33,7 @@ mod auth; pub mod admin; pub mod batch_sync; +pub(crate) mod client_eids; pub mod consent; pub mod cookies; pub mod device; diff --git a/crates/trusted-server-core/src/ec/prebid_eids.rs b/crates/trusted-server-core/src/ec/prebid_eids.rs index d823cb32fb..7bd382ecee 100644 --- a/crates/trusted-server-core/src/ec/prebid_eids.rs +++ b/crates/trusted-server-core/src/ec/prebid_eids.rs @@ -3,17 +3,19 @@ //! Parses the `ts-eids` cookie written by the TSJS Prebid integration and //! syncs matched partner UIDs to the KV identity graph. //! -//! The current cookie format stores a base64-encoded JSON array of full -//! OpenRTB-style `Eid` objects (`{source, uids:[...]}`). For rollout -//! compatibility we also accept the earlier flattened payload shape -//! (`{source, id, atype}` per entry). +//! The current cookie holds a base64-encoded JSON array of OpenRTB-style EID +//! objects, validated entry by entry with the same parser as the `/auction` +//! body (`ec::client_eids`). The earlier flattened payload shape +//! (`{source, id, atype}` per entry) is still accepted. use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64}; use serde::Deserialize; use serde_json::Value as JsonValue; +use serde_json::value::RawValue; use crate::openrtb::{Eid, Uid}; +use super::client_eids::parse_client_eids; use super::kv::PartnerIdUpdate; use super::kv_types::MAX_UID_LENGTH; use super::registry::PartnerRegistry; @@ -29,26 +31,9 @@ struct LegacyCookieEid { atype: i32, } -/// OpenRTB-style `ts-eids` cookie entry. -#[derive(Debug, Deserialize)] -struct StructuredCookieEid { - source: String, - #[serde(default)] - uids: Vec, -} - -#[derive(Debug, Deserialize)] -struct StructuredCookieUid { - id: String, - #[serde(default)] - atype: Option, - #[serde(default)] - ext: Option, -} - enum DecodedCookieEids { Legacy(Vec), - Structured(Vec), + Structured(Box), } pub(crate) struct DiagnosticEidSource { @@ -91,8 +76,11 @@ fn decode_prebid_eids_cookie(cookie_value: &str) -> Result>(&bytes) + let structured = serde_json::from_slice::>(&bytes) .map_err(|e| format!("JSON parse failed: {e}"))?; + if !structured.get().trim_start().starts_with('[') { + return Err("JSON parse failed: ts-eids payload is not an array".to_owned()); + } Ok(DecodedCookieEids::Structured(structured)) } @@ -107,34 +95,59 @@ impl DecodedCookieEids { uids: vec![entry.id.clone()], }) .collect(), - Self::Structured(entries) => entries - .iter() - .filter(|entry| !entry.source.is_empty()) - .map(|entry| DiagnosticEidSource { - source: entry.source.clone(), - uids: entry.uids.iter().map(|uid| uid.id.clone()).collect(), - }) - .collect(), + Self::Structured(raw) => raw_diagnostic_sources(raw), } } fn into_openrtb(self) -> Vec { match self { Self::Legacy(entries) => legacy_cookie_eids_to_openrtb(entries), - Self::Structured(entries) => structured_cookie_eids_to_openrtb(entries), + Self::Structured(raw) => parse_client_eids(&raw), } } } +/// Reports every source and raw UID string in a structured cookie, before +/// validation, so admin diagnostics can tell "no partner" from "no valid UID". +fn raw_diagnostic_sources(raw: &RawValue) -> Vec { + let Ok(elements) = serde_json::from_str::>(raw.get()) else { + return Vec::new(); + }; + + elements + .iter() + .filter_map(|element| { + let JsonValue::Object(entry) = serde_json::from_str::(element.get()).ok()? + else { + return None; + }; + let source = entry + .get("source") + .and_then(JsonValue::as_str) + .filter(|source| !source.is_empty())? + .to_owned(); + let uids = entry + .get("uids") + .and_then(JsonValue::as_array) + .map(|uids| { + uids.iter() + .filter_map(|uid| uid.get("id").and_then(JsonValue::as_str)) + .map(str::to_owned) + .collect() + }) + .unwrap_or_default(); + Some(DiagnosticEidSource { source, uids }) + }) + .collect() +} + /// Collects validated request-local partner updates without performing KV I/O. /// -/// `body_eids` is the current request's own EIDs (e.g. an `/auction` JSON -/// body), when the route captured any. It is applied after the `ts-eids` -/// cookie so it wins on conflicts: the cookie is size-capped by the browser -/// (see `MAX_EID_COOKIE_BYTES` in the TSJS Prebid integration) and may be -/// missing partners the request body still carries in full. The `sharedId` -/// cookie is applied last, unaffected by `body_eids`, preserving its existing -/// override behavior. +/// Finalization passes either the `ts-eids` cookie or the request's own client +/// EIDs, never both: `/auction` passes the client set it used, every other +/// route the cookie. The `sharedId` cookie is applied last, so it wins over a +/// Prebid EID for the same partner. With repeated sources, the later entry's +/// first valid UID is the candidate for that partner. pub(crate) fn collect_eid_updates( eids_cookie: Option<&str>, sharedid_cookie: Option<&str>, @@ -277,61 +290,19 @@ fn sharedid_cookie_exceeds_size_limit(cookie_value: &str) -> bool { cookie_value.len() > MAX_UID_LENGTH } -fn structured_cookie_eids_to_openrtb(entries: Vec) -> Vec { - let mut eids = Vec::new(); - - for entry in entries { - if entry.source.is_empty() { - continue; - } - - let uids: Vec<_> = entry - .uids - .into_iter() - .filter_map(structured_cookie_uid_to_openrtb) - .collect(); - if uids.is_empty() { - continue; - } - - eids.push(Eid { - source: entry.source, - uids, - }); - } - - eids -} - -fn structured_cookie_uid_to_openrtb(uid: StructuredCookieUid) -> Option { - if uid.id.is_empty() { - return None; - } - - let atype = uid.atype.filter(|atype| *atype >= 0); - let ext = match uid.ext { - Some(JsonValue::Object(_)) => uid.ext, - _ => None, - }; - - Some(Uid { - id: uid.id, - atype, - ext, - }) -} - fn legacy_cookie_eids_to_openrtb(entries: Vec) -> Vec { entries .into_iter() - .filter(|entry| !entry.source.is_empty() && !entry.id.is_empty()) - .map(|entry| Eid { - source: entry.source, - uids: vec![Uid { - id: entry.id, - atype: (entry.atype >= 0).then_some(entry.atype), - ext: None, - }], + .filter(|entry| !entry.source.trim().is_empty() && is_valid_eid_uid(&entry.id)) + .map(|entry| { + Eid::new( + entry.source, + vec![Uid { + id: entry.id, + atype: (entry.atype >= 0).then_some(entry.atype), + ext: None, + }], + ) }) .collect() } @@ -377,6 +348,100 @@ mod tests { BASE64.encode(serde_json::to_vec(value).expect("should serialize EID JSON")) } + #[test] + fn structured_cookie_keeps_eid_level_fields() { + let entries = json!([{ + "source": "adserver.org", + "inserter": "adserver.org", + "matcher": "adserver.org", + "mm": 4, + "uids": [{ "id": "fictional-tdid", "atype": 1 }] + }]); + + let decoded = + parse_prebid_eids_cookie(&encode_json(&entries)).expect("should decode cookie"); + + assert_eq!( + serde_json::to_value(&decoded).expect("should serialize decoded EIDs"), + entries, + "should keep EID-level fields from the cookie" + ); + } + + #[test] + fn structured_cookie_drops_a_mistyped_field_instead_of_the_whole_cookie() { + let encoded = encode_json(&json!([ + { "source": "a.example.com", "uids": [{ "id": "a-uid", "atype": "1" }] }, + { "source": "b.example.com", "uids": [{ "id": "b-uid", "atype": 3 }] } + ])); + + let decoded = parse_prebid_eids_cookie(&encoded).expect("should decode cookie"); + + assert_eq!( + serde_json::to_value(&decoded).expect("should serialize decoded EIDs"), + json!([ + { "source": "a.example.com", "uids": [{ "id": "a-uid" }] }, + { "source": "b.example.com", "uids": [{ "id": "b-uid", "atype": 3 }] } + ]), + "a mistyped atype should drop only that field" + ); + } + + #[test] + fn structured_cookie_isolates_an_unparsable_entry() { + let raw = r#"[{"source":"a.example.com","uids":[{"id":"a-uid"}]},{"source":"bad.example.com","uids":[{"id":"bad-\ud800"}]}]"#; + + let decoded = parse_prebid_eids_cookie(&BASE64.encode(raw)).expect("should decode cookie"); + + assert_eq!( + serde_json::to_value(&decoded).expect("should serialize decoded EIDs"), + json!([{ "source": "a.example.com", "uids": [{ "id": "a-uid" }] }]), + "a lone surrogate should cost only its own entry" + ); + } + + #[test] + fn structured_cookie_applies_the_entry_cap() { + let entries: Vec = (0..65) + .map(|index| json!({ "source": format!("s{index}.example.com"), "uids": [{ "id": "u" }] })) + .collect(); + + let decoded = + parse_prebid_eids_cookie(&encode_json(&serde_json::Value::Array(entries.clone()))) + .expect("should decode cookie"); + + assert_eq!( + serde_json::to_value(&decoded).expect("should serialize decoded EIDs"), + serde_json::Value::Array(entries[..64].to_vec()), + "the body's 64-entry cap should apply to the cookie" + ); + } + + #[test] + fn structured_cookie_diagnostics_report_raw_uid_strings() { + let registry = make_registry(vec![("example", "example.com")]); + let oversized = "x".repeat(MAX_UID_LENGTH + 1); + let encoded = + encode_json(&json!([{ "source": "example.com", "uids": [{ "id": oversized }] }])); + + let analysis = + analyze_prebid_eids_cookie(&encoded, ®istry).expect("should decode cookie"); + + assert!( + analysis.eids.is_empty(), + "an oversized UID should not survive validation" + ); + assert_eq!( + analysis + .diagnostic_sources + .iter() + .map(|source| (source.source.clone(), source.uids.clone())) + .collect::>(), + vec![("example.com".to_owned(), vec![oversized])], + "diagnostics should still report the raw source and UID so admin can say no valid UID" + ); + } + #[test] fn parse_prebid_eids_cookie_parses_legacy_flat_payload() { let eids = vec![ @@ -463,6 +528,16 @@ mod tests { assert!(result.is_err(), "should reject invalid JSON"); } + #[test] + fn parse_prebid_eids_cookie_rejects_non_array_json() { + let result = parse_prebid_eids_cookie(&encode_json(&json!({"a": 1}))); + let error = result.expect_err("should reject a JSON object"); + assert!( + error.contains("not an array"), + "should explain that the payload is not an array" + ); + } + #[test] fn ts_eids_cookie_rejects_oversized_payloads() { let oversized = "x".repeat(MAX_EIDS_COOKIE_BYTES + 1); @@ -636,22 +711,22 @@ mod tests { {"source": "id5-sync.com", "uids": [{"id": "ID5_stale", "atype": 1}]} ])); let body_eids = vec![ - Eid { - source: "id5-sync.com".to_owned(), - uids: vec![Uid { + Eid::new( + "id5-sync.com".to_owned(), + vec![Uid { id: "ID5_fresh".to_owned(), atype: Some(1), ext: None, }], - }, - Eid { - source: "liveramp.com".to_owned(), - uids: vec![Uid { + ), + Eid::new( + "liveramp.com".to_owned(), + vec![Uid { id: "LR_xyz".to_owned(), atype: Some(3), ext: None, }], - }, + ), ]; let updates = collect_eid_updates(Some(&eids_cookie), None, Some(&body_eids), ®istry); diff --git a/crates/trusted-server-core/src/integrations/aps.rs b/crates/trusted-server-core/src/integrations/aps.rs index 4f45fbeb9c..51f6bc7804 100644 --- a/crates/trusted-server-core/src/integrations/aps.rs +++ b/crates/trusted-server-core/src/integrations/aps.rs @@ -1969,7 +1969,10 @@ pub fn register_providers( #[cfg(test)] mod tests { use super::*; - use crate::auction::test_support::canonical_parity_auction_request; + use crate::auction::test_support::{ + canonical_parity_auction_request, repeated_source_provenance_eids, + repeated_source_provenance_eids_json, + }; use crate::auction::types::{ AdFormat, AdSlot, AuctionContext, AuctionRequest, BidStatus, PublisherInfo, UserInfo, }; @@ -2305,6 +2308,36 @@ mod tests { ); } + #[test] + fn aps_request_forwards_eid_provenance_fields_and_repeated_sources() { + let provider = ApsAuctionProvider::new(config()); + let mut auction_request = canonical_parity_auction_request(); + auction_request.user.eids = Some(repeated_source_provenance_eids()); + let settings = create_test_settings(); + let services = noop_services(); + let downstream = http::Request::builder() + .uri("https://publisher.example/auction") + .body(EdgeBody::empty()) + .expect("should build downstream request"); + let context = AuctionContext { + settings: &settings, + request: &downstream, + timeout_ms: 321, + transport_timeout_ms: 321, + provider_responses: None, + services: &services, + }; + + let openrtb = provider.build_openrtb_request(&auction_request, &context); + + let serialized = serde_json::to_value(&openrtb).expect("should serialize request"); + assert_eq!( + serialized["user"]["ext"]["eids"], + repeated_source_provenance_eids_json(), + "APS should receive both entries with their own EID-level fields" + ); + } + #[test] fn builds_aps_openrtb_request_with_explicit_privacy_policy() { let provider = ApsAuctionProvider::new(config()); diff --git a/crates/trusted-server-core/src/integrations/prebid.rs b/crates/trusted-server-core/src/integrations/prebid.rs index f1d1d0b509..a7db8624e4 100644 --- a/crates/trusted-server-core/src/integrations/prebid.rs +++ b/crates/trusted-server-core/src/integrations/prebid.rs @@ -3590,6 +3590,7 @@ mod tests { use crate::auction::test_support::{ canonical_parity_auction_request, create_test_auction_context as shared_test_auction_context, + repeated_source_provenance_eids, repeated_source_provenance_eids_json, }; use crate::auction::types::{ AdFormat, AdSlot, AuctionContext, AuctionRequest, DeviceInfo, PublisherInfo, UserInfo, @@ -6858,35 +6859,59 @@ external_bundle_sri = "sha384-AAAA" ); } + #[test] + fn to_openrtb_forwards_eid_provenance_fields_and_repeated_sources() { + let provider = PrebidAuctionProvider::new(base_config()); + let mut auction_request = create_test_auction_request(); + auction_request.user.eids = Some(repeated_source_provenance_eids()); + let settings = make_settings(); + let request = build_test_request(); + let context = create_test_auction_context(&settings, &request); + + let openrtb = provider.to_openrtb( + &auction_request, + &context, + None, + make_request_info(&context), + ); + + let serialized = serde_json::to_value(&openrtb).expect("should serialize OpenRTB request"); + assert_eq!( + serialized["user"]["ext"]["eids"], + repeated_source_provenance_eids_json(), + "Prebid Server should receive both entries with their own EID-level fields" + ); + } + #[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 { + crate::openrtb::Eid::new( + "liveramp.com".to_owned(), + 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 { + ), + crate::openrtb::Eid::new( + "id5-sync.com".to_owned(), + vec![crate::openrtb::Uid { id: "ID5_abc".to_owned(), atype: Some(1), ext: None, }], - }, - crate::openrtb::Eid { - source: "google.com".to_owned(), - uids: vec![crate::openrtb::Uid { + ), + crate::openrtb::Eid::new( + "google.com".to_owned(), + vec![crate::openrtb::Uid { id: "pair-id".to_owned(), atype: Some(571187), ext: None, }], - }, + ), ]); let settings = make_settings(); diff --git a/crates/trusted-server-core/src/migration_guards.rs b/crates/trusted-server-core/src/migration_guards.rs index 17b3d624f6..683fbbc138 100644 --- a/crates/trusted-server-core/src/migration_guards.rs +++ b/crates/trusted-server-core/src/migration_guards.rs @@ -75,6 +75,7 @@ fn checked_sources() -> &'static [(&'static str, &'static str)] { ("creative.rs", include_str!("creative.rs")), ("ec/auth.rs", include_str!("ec/auth.rs")), ("ec/batch_sync.rs", include_str!("ec/batch_sync.rs")), + ("ec/client_eids.rs", include_str!("ec/client_eids.rs")), ("ec/consent.rs", include_str!("ec/consent.rs")), ("ec/cookies.rs", include_str!("ec/cookies.rs")), ("ec/device.rs", include_str!("ec/device.rs")), diff --git a/crates/trusted-server-core/src/openrtb.rs b/crates/trusted-server-core/src/openrtb.rs index 4aded74882..086bb6ce8f 100644 --- a/crates/trusted-server-core/src/openrtb.rs +++ b/crates/trusted-server-core/src/openrtb.rs @@ -65,17 +65,62 @@ pub struct ConsentedProvidersSettings { } /// An Extended User ID entry from an identity provider. -#[derive(Debug, Clone, Serialize, Deserialize)] +/// +/// Mirrors `OpenRTB` 2.6 §3.2.27. Browser entries keep the EID-level fields +/// Prebid set; KV-resolved entries leave them empty. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct Eid { /// Identity provider domain (e.g. `"id5-sync.com"`). pub source: String, /// One or more user IDs from this provider. pub uids: Vec, + /// Entity that inserted the IDs into the request (`eid.inserter`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub inserter: Option, + /// Entity that matched the IDs (`eid.matcher`). Absent means `source`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub matcher: Option, + /// Match method (`eid.mm`). Vendor values from 500 up are passed through. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub mm: Option, + /// Provider-specific extension data, e.g. `LiveIntent` `segments`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ext: Option, +} + +impl Eid { + /// Builds an [`Eid`] without EID-level fields. + /// + /// Use this for EIDs that never carry provenance, such as KV-resolved + /// partner IDs. + /// + /// # Examples + /// + /// ``` + /// use trusted_server_core::openrtb::{Eid, Uid}; + /// + /// let eid = Eid::new( + /// "ids.example.com".to_owned(), + /// vec![Uid { id: "uid-1".to_owned(), atype: Some(1), ext: None }], + /// ); + /// assert_eq!(eid.inserter, None); + /// ``` + #[must_use] + pub fn new(source: String, uids: Vec) -> Self { + Self { + source, + uids, + inserter: None, + matcher: None, + mm: None, + ext: None, + } + } } /// A single user identifier within an [`Eid`] entry. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct Uid { /// The identifier value. @@ -194,6 +239,87 @@ impl ToExt for ResponseExt {} mod tests { use super::*; use crate::auction::types::OrchestratorExt; + use serde_json::json; + + #[test] + fn eid_serializes_openrtb_provenance_fields_when_present() { + let eid = Eid { + source: "ids.example.com".to_owned(), + uids: vec![Uid { + id: "uid-1".to_owned(), + atype: Some(1), + ext: Some(json!({ "rtiPartner": "TDID" })), + }], + inserter: Some("inserter.example.com".to_owned()), + matcher: Some("matcher.example.com".to_owned()), + mm: Some(4), + ext: Some(json!({ "segments": ["s1"] })), + }; + + let serialized = serde_json::to_value(&eid).expect("should serialize EID"); + + assert_eq!( + serialized, + json!({ + "source": "ids.example.com", + "uids": [{ "id": "uid-1", "atype": 1, "ext": { "rtiPartner": "TDID" } }], + "inserter": "inserter.example.com", + "matcher": "matcher.example.com", + "mm": 4, + "ext": { "segments": ["s1"] } + }), + "should serialize every EID-level field" + ); + } + + #[test] + fn eid_without_provenance_fields_serializes_as_before() { + let eid = Eid::new( + "ids.example.com".to_owned(), + vec![Uid { + id: "uid-1".to_owned(), + atype: Some(1), + ext: None, + }], + ); + + let serialized = serde_json::to_string(&eid).expect("should serialize EID"); + + assert_eq!( + serialized, r#"{"source":"ids.example.com","uids":[{"id":"uid-1","atype":1}]}"#, + "an EID without EID-level fields should serialize byte-identically to before" + ); + } + + #[test] + fn eid_deserializes_provenance_fields_and_keeps_rejecting_unknown_keys() { + let parsed: Eid = serde_json::from_str( + r#"{"source":"ids.example.com","uids":[{"id":"uid-1"}],"inserter":"inserter.example.com","matcher":"matcher.example.com","mm":3,"ext":{"k":"v"}}"#, + ) + .expect("should deserialize EID-level fields"); + + assert_eq!( + parsed, + Eid { + source: "ids.example.com".to_owned(), + uids: vec![Uid { + id: "uid-1".to_owned(), + atype: None, + ext: None, + }], + inserter: Some("inserter.example.com".to_owned()), + matcher: Some("matcher.example.com".to_owned()), + mm: Some(3), + ext: Some(json!({ "k": "v" })), + }, + "should read every EID-level field" + ); + assert!( + serde_json::from_str::(r#"{"source":"ids.example.com","uids":[],"unknown":1}"#) + .is_err(), + "should keep deny_unknown_fields" + ); + } #[test] fn openrtb_response_round_trips_with_struct_literals() { @@ -393,14 +519,14 @@ mod tests { #[test] fn eid_serializes_correctly() { - let eid = Eid { - source: "id5-sync.com".to_owned(), - uids: vec![Uid { + let eid = Eid::new( + "id5-sync.com".to_owned(), + vec![Uid { id: "ID5-abc123".to_owned(), atype: Some(1), ext: None, }], - }; + ); let serialized = serde_json::to_value(&eid).expect("should serialize"); assert_eq!(serialized["source"], "id5-sync.com", "source should match"); @@ -417,14 +543,14 @@ mod tests { #[test] fn eid_serializes_vendor_specific_atype() { - let eid = Eid { - source: "google.com".to_owned(), - uids: vec![Uid { + let eid = Eid::new( + "google.com".to_owned(), + vec![Uid { id: "pair-id".to_owned(), atype: Some(571187), ext: None, }], - }; + ); let serialized = serde_json::to_value(&eid).expect("should serialize"); diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 84b74d51db..e7c6ebdfc8 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -7909,10 +7909,15 @@ mod tests { ..marker_only })); } + use crate::auction::test_support::canonical_parity_auction_request; use crate::auction::types::{AdFormat, AdSlot, MediaType}; use crate::consent::ConsentContext; + use crate::consent::jurisdiction::Jurisdiction; + use crate::cookies::parse_cookies_to_jar; + use crate::ec::EcKvSnapshot; use crate::ec::kv_backend::test_support::InMemoryEcKv; use crate::ec::kv_backend::{EcKvLookup, EcKvStore, EcKvWrite, EcKvWriteOutcome}; + use crate::ec::kv_types::{KvEntry, KvPartnerId}; use crate::integrations::IntegrationRegistry; use crate::platform::test_support::{ NoopSecretStore, StubHttpClient, build_services_with_http_client, @@ -7920,6 +7925,7 @@ mod tests { noop_services_with_telemetry_sink, }; use crate::test_support::tests::{crate_test_settings_str, create_test_settings}; + use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64}; use edgezero_core::body::Body as EdgeBody; use http::{Method, Request as HttpRequest, StatusCode, header}; use std::sync::Arc; @@ -8307,6 +8313,80 @@ mod tests { } } + #[test] + fn body_less_auction_eids_put_cookie_entries_first_and_kv_only_sources_after() { + let ec_id = format!("{}.navmrg", "a".repeat(64)); + let registry = PartnerRegistry::from_config(&[ + marker_partner("ids.example.com"), + marker_partner("other.example.com"), + ]) + .expect("should build partner registry"); + let mut entry = KvEntry::minimal("ids.example.com", "kv-stale", 1000); + entry.ids.insert( + "other.example.com".to_owned(), + KvPartnerId { + uid: "kv-other".to_owned(), + }, + ); + let snapshot = EcKvSnapshot::Present { + ec_id: ec_id.clone(), + entry: Box::new(entry), + generation: None, + }; + let ec_context = EcContext::new_for_test( + Some(ec_id.clone()), + ConsentContext { + jurisdiction: Jurisdiction::NonRegulated, + ..ConsentContext::default() + }, + ); + let cookie_entries = serde_json::json!([{ + "source": "ids.example.com", + "inserter": "inserter-1.example.com", + "mm": 1, + "uids": [{ "id": "browser-fresh", "atype": 1 }] + }]); + let cookie = + BASE64.encode(serde_json::to_vec(&cookie_entries).expect("should serialize cookie")); + let jar = parse_cookies_to_jar(&format!("{COOKIE_TS_EIDS}={cookie}")); + let services = noop_services(); + let mut auction_request = canonical_parity_auction_request(); + // No TCF signal and no GDPR, so the consent gate passes the merged set. + auction_request.user.consent = None; + auction_request.user.eids = None; + + apply_auction_eids_and_device( + &mut auction_request, + &AuctionEidTargeting { + cookie_jar: Some(&jar), + ec_id: Some(ec_id.as_str()), + kv_snapshot: &snapshot, + partner_registry: Some(®istry), + ec_context: &ec_context, + services: &services, + geo: None, + path_label: "Test", + }, + ); + + assert_eq!( + serde_json::to_value(&auction_request.user.eids).expect("should serialize EIDs"), + serde_json::json!([ + { + "source": "ids.example.com", + "inserter": "inserter-1.example.com", + "mm": 1, + "uids": [{ "id": "browser-fresh", "atype": 1 }] + }, + { + "source": "other.example.com", + "uids": [{ "id": "kv-other", "atype": crate::settings::EcPartner::default_openrtb_atype() }] + } + ]), + "navigation and page-bids auctions should forward the cookie entry as sent, then KV-only sources" + ); + } + async fn run_marker_lookup_probe( probe: MarkerProbe, has_eid_cookie: bool, diff --git a/crates/trusted-server-js/lib/src/core/auction.ts b/crates/trusted-server-js/lib/src/core/auction.ts index a026843624..0bd2f16b73 100644 --- a/crates/trusted-server-js/lib/src/core/auction.ts +++ b/crates/trusted-server-js/lib/src/core/auction.ts @@ -20,18 +20,14 @@ export interface AdRequestUnit { bids: Array<{ bidder: string; params: Record }>; } -/** A user identifier within an auction-level EID entry. */ -export interface AuctionUid { - id: string; - atype?: number; - ext?: Record; -} - -/** An auction-level EID entry forwarded to the server. */ -export interface AuctionEid { - source: string; - uids: AuctionUid[]; -} +/** + * An auction-level EID entry forwarded to the server. + * + * TSJS forwards the entries Prebid placed in the `trustedServer` bidder + * request as plain data and the server validates each one, so the server + * defines the contract. + */ +export type AuctionEid = unknown; /** The payload POSTed to the /auction orchestrator. */ export interface AdRequest { diff --git a/crates/trusted-server-js/lib/src/integrations/prebid/eids.ts b/crates/trusted-server-js/lib/src/integrations/prebid/eids.ts new file mode 100644 index 0000000000..fd25b2e689 --- /dev/null +++ b/crates/trusted-server-js/lib/src/integrations/prebid/eids.ts @@ -0,0 +1,226 @@ +// Plain-data copies of the EIDs Prebid placed in the `trustedServer` bidder +// request, and the `ts-eids` cookie value built from them. +// +// Pure functions: nothing here reads `pbjs`, the DOM or the clock. The server +// validates every entry of the body and the cookie alike (spec 4.4), so this +// module checks nothing beyond what serialization needs. + +/** UTF-8 byte budget for the EIDs forwarded in one `/auction` body. */ +export const MAX_AUCTION_EIDS_BYTES = 65_536; + +/** Maximum length of the base64 `ts-eids` cookie value. */ +export const MAX_EID_COOKIE_BYTES = 3072; + +/** Counts reported by {@link collectBidderEids} for debug logging. */ +export interface EidCollectionStats { + /** Entries copied into the result. */ + forwarded: number; + /** Entries dropped because they could not be serialized. */ + unserializable: number; + /** Entries dropped because the byte budget was exhausted. */ + overBudget: number; +} + +/** A fitted `ts-eids` cookie value and the sources trimming touched. */ +export interface FittedEidCookie { + /** Base64 cookie value, or `undefined` when nothing fits. */ + value: string | undefined; + /** Sources removed whole. */ + droppedSources: string[]; + /** Sources that lost UIDs but stayed in the cookie. */ + trimmedSources: string[]; +} + +const utf8Encoder = new TextEncoder(); + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +/** + * Returns the arrays Prebid may hold EIDs in, in reading order: + * `ortb2.user.ext.eids`, then `ortb2.user.eids`. RTD modules write after + * Prebid normalizes first-party data, so `user.eids` can hold entries too. + * Prebid redacts both paths for the bidder, so reading both does not widen + * what `trustedServer` receives. + */ +function bidderEidArrays(bidderRequest: unknown): unknown[][] { + if (!isRecord(bidderRequest) || !isRecord(bidderRequest.ortb2)) { + return []; + } + const user = bidderRequest.ortb2.user; + if (!isRecord(user)) { + return []; + } + const arrays: unknown[][] = []; + if (isRecord(user.ext) && Array.isArray(user.ext.eids)) { + arrays.push(user.ext.eids); + } + if (Array.isArray(user.eids)) { + arrays.push(user.eids); + } + return arrays; +} + +function serializeEntry(entry: unknown): string | undefined { + try { + // `undefined` and functions serialize to `undefined`; treat them as + // unserializable like a circular object or a BigInt. + return JSON.stringify(entry); + } catch { + return undefined; + } +} + +/** + * Copies the EIDs Prebid placed in a `trustedServer` bidder request. + * + * Reads `ortb2.user.ext.eids` then `ortb2.user.eids`, copies each entry + * through a JSON round trip so the result is plain data (`undefined` fields + * disappear), drops entries that cannot be serialized, and keeps entries in + * order while their serializations total at most + * {@link MAX_AUCTION_EIDS_BYTES} UTF-8 bytes, dropping the rest. Entries are + * not merged and not validated. + * + * @param bidderRequest the second `buildRequests` argument + * @param stats filled with counts for debug logging, when given + * @returns the copied entries, or `undefined` when none remain + */ +export function collectBidderEids( + bidderRequest: unknown, + stats?: EidCollectionStats +): unknown[] | undefined { + const collected: unknown[] = []; + let usedBytes = 0; + let budgetExhausted = false; + let unserializable = 0; + let overBudget = 0; + + for (const entries of bidderEidArrays(bidderRequest)) { + for (const entry of entries) { + const serialized = serializeEntry(entry); + if (serialized === undefined) { + unserializable += 1; + continue; + } + const bytes = utf8Encoder.encode(serialized).length; + if (budgetExhausted || usedBytes + bytes > MAX_AUCTION_EIDS_BYTES) { + budgetExhausted = true; + overBudget += 1; + continue; + } + usedBytes += bytes; + collected.push(JSON.parse(serialized)); + } + } + + if (stats) { + stats.forwarded = collected.length; + stats.unserializable = unserializable; + stats.overBudget = overBudget; + } + return collected.length > 0 ? collected : undefined; +} + +function withoutEidExt(entry: unknown): unknown { + if (!isRecord(entry) || !('ext' in entry)) { + return entry; + } + const copy: Record = {}; + for (const [key, value] of Object.entries(entry)) { + if (key !== 'ext') { + copy[key] = value; + } + } + return copy; +} + +/** JSON with object keys sorted at every level, for equality checks. */ +function canonicalJson(value: unknown): string { + if (Array.isArray(value)) { + return `[${value.map(canonicalJson).join(',')}]`; + } + if (isRecord(value)) { + return `{${Object.keys(value) + .sort() + .map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`) + .join(',')}}`; + } + return JSON.stringify(value) ?? 'null'; +} + +function cookieLabel(entry: unknown): string { + return isRecord(entry) && typeof entry.source === 'string' ? entry.source : '(malformed entry)'; +} + +function encodeUtf8Base64(text: string): string { + let binary = ''; + for (const byte of utf8Encoder.encode(text)) { + binary += String.fromCharCode(byte); + } + return btoa(binary); +} + +/** + * Builds the `ts-eids` cookie value from a request's collected EIDs. + * + * Removes each entry's EID-level `ext`, drops entries that become identical + * (compared with sorted keys), then trims from the tail until the standard + * padded base64 of the JSON array's UTF-8 bytes is at most + * {@link MAX_EID_COOKIE_BYTES} long: drop UIDs from the last entry, then the + * entry itself, and repeat. A last entry that is not an object with a `uids` + * array is removed whole. The input is not modified. + * + * @param eids the entries forwarded in the `/auction` body + * @returns the cookie value, or `undefined` when nothing fits, and the + * sources trimming touched + */ +export function fitEidCookie(eids: readonly unknown[]): FittedEidCookie { + const seen = new Set(); + const payload: unknown[] = []; + for (const entry of eids) { + const stripped = withoutEidExt(entry); + const key = canonicalJson(stripped); + if (seen.has(key)) { + continue; + } + seen.add(key); + payload.push( + isRecord(stripped) && Array.isArray(stripped.uids) + ? { ...stripped, uids: [...stripped.uids] } + : stripped + ); + } + + const dropped = new Set(); + const trimmed = new Set(); + while (payload.length > 0) { + const value = encodeUtf8Base64(JSON.stringify(payload)); + if (value.length <= MAX_EID_COOKIE_BYTES) { + return { + value, + droppedSources: [...dropped], + trimmedSources: [...trimmed], + }; + } + + const last = payload[payload.length - 1]; + if (isRecord(last) && Array.isArray(last.uids) && last.uids.length > 1) { + last.uids = last.uids.slice(0, -1); + trimmed.add(cookieLabel(last)); + continue; + } + + payload.pop(); + const label = cookieLabel(last); + dropped.add(label); + // A source trimmed and then dropped is reported once, as dropped. + trimmed.delete(label); + } + + return { + value: undefined, + droppedSources: [...dropped], + trimmedSources: [...trimmed], + }; +} diff --git a/crates/trusted-server-js/lib/src/integrations/prebid/index.ts b/crates/trusted-server-js/lib/src/integrations/prebid/index.ts index 82c0e8c6b7..9e749c6379 100644 --- a/crates/trusted-server-js/lib/src/integrations/prebid/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/prebid/index.ts @@ -25,24 +25,23 @@ import { import { log } from '../../core/log'; import { buildAdRequest, parseAuctionResponse } from '../../core/auction'; import { registerApsPrebidRenderer, validateApsRenderer } from '../aps/render'; -import type { AuctionBid, AuctionEid } from '../../core/auction'; +import type { AuctionBid } from '../../core/auction'; import type { AuctionSlot, TsjsApi } from '../../core/types'; +import { + collectBidderEids, + fitEidCookie, + MAX_EID_COOKIE_BYTES, + type EidCollectionStats, +} from './eids'; import { PREBID_USER_ID_MODULE_REGISTRY, userIdConfigNameAliases, userIdSubmoduleKey, } from './user_id_modules'; -/** - * Prebid.js public API surface (type-only; erased at build time). - * - * `getUserIdsAsEids` is added by the userId module at runtime, which the base - * package typing does not model. - */ -type PbjsGlobal = typeof _pbjsDefault & { - getUserIdsAsEids?: () => unknown[]; -}; +/** Prebid.js public API surface (type-only; erased at build time). */ +type PbjsGlobal = typeof _pbjsDefault; // Prebid.js itself is NOT bundled into this module. It is served as the // external bundle configured via `integrations.prebid.external_bundle_url` @@ -148,9 +147,6 @@ const APS_BIDDER_CODE = 'aps'; // both after the registration attempt. const APS_RENDERER_FIELD = 'trustedServerRenderer'; const APS_BID_RESPONSE_LISTENER_SENTINEL = '__tsApsBidResponseListenerInstalled'; -// OpenRTB permits vendor-specific agent types; PAIR uses 571187. -// Keep this range aligned with the signed 32-bit Rust/OpenRTB representation. -const MAX_OPENRTB_ATYPE = 2_147_483_647; const BIDDER_PARAMS_KEY = 'bidderParams'; const STORED_REQUEST_KEY = 'storedRequest'; const ZONE_KEY = 'zone'; @@ -1009,11 +1005,6 @@ type TrustedServerRequest = { tsjsBidRequests: TrustedServerBidRequest[]; }; -type PrebidUserIdEid = { - source?: unknown; - uids?: Array<{ id?: unknown; atype?: unknown; ext?: unknown }>; -}; - type RefreshGptSlot = { getSlotElementId?: () => string; getAdUnitPath?: () => string; @@ -1073,33 +1064,6 @@ const DEFAULT_REFRESH_SIZES: BannerSize[] = [ [300, 250], ]; -function sanitizeAuctionUid(uid: { - id?: unknown; - atype?: unknown; - ext?: unknown; -}): AuctionEid['uids'][number] | undefined { - if (typeof uid?.id !== 'string' || uid.id.length === 0) { - return undefined; - } - - const sanitizedUid: AuctionEid['uids'][number] = { id: uid.id }; - - if ( - typeof uid.atype === 'number' && - Number.isInteger(uid.atype) && - uid.atype >= 0 && - uid.atype <= MAX_OPENRTB_ATYPE - ) { - sanitizedUid.atype = uid.atype; - } - - if (uid.ext && typeof uid.ext === 'object' && !Array.isArray(uid.ext)) { - sanitizedUid.ext = uid.ext as Record; - } - - return sanitizedUid; -} - function isDefined(value: T | undefined): value is T { return value !== undefined; } @@ -1888,31 +1852,6 @@ function removePublisherState(adUnitCode?: string | string[]): void { } } -function collectAuctionEids(): AuctionEid[] | undefined { - if (typeof pbjs.getUserIdsAsEids !== 'function') { - return undefined; - } - - const rawEids = (pbjs.getUserIdsAsEids() ?? []) as PrebidUserIdEid[]; - const eids: AuctionEid[] = []; - - for (const eid of rawEids) { - if (typeof eid?.source !== 'string' || eid.source.length === 0) { - continue; - } - - const uids = Array.isArray(eid.uids) ? eid.uids.map(sanitizeAuctionUid).filter(isDefined) : []; - - if (uids.length === 0) { - continue; - } - - eids.push({ source: eid.source, uids }); - } - - return eids.length > 0 ? eids : undefined; -} - /** * Install the Prebid integration. * @@ -2354,14 +2293,13 @@ export function installPrebidNpm(config?: Partial): typeof pbjs return true; // All requests are valid — orchestrator handles filtering }, - buildRequests(validBidRequests: TrustedServerBidRequest[]): TrustedServerRequest { + buildRequests( + validBidRequests: TrustedServerBidRequest[], + bidderRequest?: unknown + ): TrustedServerRequest { log.debug('[tsjs-prebid] buildRequests', { count: validBidRequests.length }); const requestScopedBidRequests = [...validBidRequests]; - const hasUserIdApi = typeof pbjs.getUserIdsAsEids === 'function'; - const auctionEids = collectAuctionEids(); - if (hasUserIdApi && !auctionEids) { - clearPrebidEidsCookie(); - } + const auctionEids = forwardBidderEids(bidderRequest); const payload = buildAdRequest(validBidRequests, { eids: auctionEids }); return { method: 'POST', @@ -2388,6 +2326,22 @@ export function installPrebidNpm(config?: Partial): typeof pbjs }, }); + // Prebid never calls buildRequests for a bidder its TCF rules block, so + // without this a ts-eids cookie written before consent changed would live + // for its full day. Prebid emits the event at AUCTION_END, before + // bidsBackHandler. + const prebidEvents = pbjs as unknown as { + onEvent?: (event: string, handler: (payload: unknown) => void) => void; + }; + if (typeof prebidEvents.onEvent === 'function') { + prebidEvents.onEvent('tcf2Enforcement', (payload) => { + if (tcfBlocksTrustedServer(payload)) { + clearPrebidEidsCookie(); + log.debug('[tsjs-prebid] cleared ts-eids: Prebid TCF rules blocked trustedServer'); + } + }); + } + const originalRequestBids = pbjs.requestBids.bind(pbjs); // Browser demand ownership is explicit. Only validated auction-plan route @@ -2534,11 +2488,9 @@ export function installPrebidNpm(config?: Partial): typeof pbjs }, }; - // Chain a bidsBackHandler to collect Prebid User ID Module EIDs - // and persist them as a cookie for backend sync. + // Chain a bidsBackHandler to register this auction's publisher bids before the publisher's handler runs. const originalBidsBack = opts.bidsBackHandler; opts.bidsBackHandler = function (...args: unknown[]) { - syncPrebidEidsCookie(); const registrationId = isSyntheticRefresh ? undefined : registerPendingPublisherBids(publisherAdUnitCodes, args[0], firstImpressionTokens); @@ -2871,103 +2823,69 @@ export function installRefreshHandler(timeoutMs = 1500): void { }); } -/** - * Configure identity sync behavior for the generated Prebid User ID modules. - * - * The external bundle generator statically imports the selected modules into - * its generated entry. This post-window-load configuration controls when - * those modules synchronize identities; it does not select or register modules. - */ -export function installUserIdModules(): void { - try { - pbjs.setConfig({ - userSync: { - syncEnabled: true, - filterSettings: { - all: { bidders: '*', filter: 'include' }, - }, - auctionDelay: 0, - syncsPerBidder: 5, - syncDelay: 3000, - }, - }); - log.info('[tsjs-prebid] userID modules configured'); - } catch { - // pbjs not ready — userID modules will use defaults - } -} - // --------------------------------------------------------------------------- -// Prebid EID cookie sync +// Prebid EID forwarding and the ts-eids cookie // --------------------------------------------------------------------------- -/** Maximum cookie payload size in bytes (leave room for other cookies). */ -const MAX_EID_COOKIE_BYTES = 3072; - /** Cookie name for persisted Prebid EIDs. */ const EID_COOKIE_NAME = 'ts-eids'; /** Cookie max-age in seconds (1 day). */ const EID_COOKIE_MAX_AGE = 86400; -/** Clears any previously persisted Prebid EIDs cookie. */ -function clearPrebidEidsCookie(): void { - document.cookie = `${EID_COOKIE_NAME}=; Path=/; Secure; SameSite=Lax; Max-Age=0`; -} +/** What `buildRequests` did with `ts-eids`, for debug logs. */ +type EidCookieDecision = 'written' | 'trimmed' | 'cleared' | 'cleared: no consent'; /** - * Trims an EID payload so its base64-encoded cookie fits `MAX_EID_COOKIE_BYTES`, - * dropping UIDs then whole sources from the tail. `/auction` requests still - * forward the untrimmed set in the request body (see `buildAdRequest`); this - * cap only bounds what the `ts-eids` cookie carries for routes without a body - * (e.g. `GET /_ts/page-bids`) and for backend ingestion at response finalize. + * Whether the consent Prebid attached to this bidder request permits storing + * `ts-eids`. + * + * Where GDPR applies, writing the cookie needs TCF Purpose 1 (store and/or + * access information on a device) and Purpose 4 (personalized ads), the rule + * the server applies before it uses or stores these EIDs + * (`allows_eid_persistence`). A cookie written on Purpose 1 alone would hold + * IDs no server path reads. Prebid lets `trustedServer` see `ortb2` EIDs on + * any purpose from 2 to 10, so forwarding them does not establish either. + * GDPR data without a TCF purpose record fails closed. Without GDPR data, or + * with `gdprApplies: false`, nothing restricts the write. */ -function fitAuctionEidsToCookie(eids: AuctionEid[]): AuctionEid[] | undefined { - const payload = eids.map((eid) => ({ source: eid.source, uids: [...eid.uids] })); - const droppedSources = new Set(); - const trimmedSources = new Set(); - - while (payload.length > 0) { - const encoded = btoa(JSON.stringify(payload)); - if (encoded.length <= MAX_EID_COOKIE_BYTES) { - logTrimmedEidSources(droppedSources, trimmedSources); - return payload; - } - - const last = payload[payload.length - 1]; - if (last && last.uids.length > 1) { - last.uids = last.uids.slice(0, last.uids.length - 1); - trimmedSources.add(last.source); - continue; - } - - const dropped = payload.pop(); - if (dropped) { - droppedSources.add(dropped.source); - // A source trimmed and then dropped is reported once, as dropped. - trimmedSources.delete(dropped.source); - } +function hasEidPersistenceConsent(bidderRequest: object): boolean { + const { gdprConsent } = bidderRequest as { gdprConsent?: unknown }; + if (!isRecord(gdprConsent) || gdprConsent.gdprApplies !== true) { + return true; } + const { vendorData } = gdprConsent; + if (!isRecord(vendorData) || !isRecord(vendorData.purpose)) { + return false; + } + const { consents } = vendorData.purpose; + return isRecord(consents) && consents[1] === true && consents[4] === true; +} - logTrimmedEidSources(droppedSources, trimmedSources); - return undefined; +/** Clears any previously persisted Prebid EIDs cookie. */ +function clearPrebidEidsCookie(): void { + try { + document.cookie = `${EID_COOKIE_NAME}=; Path=/; Secure; SameSite=Lax; Max-Age=0`; + } catch { + log.warn('[tsjs-prebid] failed to clear the ts-eids cookie'); + } } /** - * Logs which EID sources `fitAuctionEidsToCookie` had to drop or trim UIDs - * from, if any. Debug level: `/auction` sends the untrimmed set in the request - * body, so a trimmed cookie is expected steady state rather than a warning. + * Logs which EID sources the cookie had to drop or trim UIDs from, if any. + * Debug level: `/auction` sends the untrimmed set in the request body, so a + * trimmed cookie is expected steady state rather than a warning. */ -function logTrimmedEidSources(droppedSources: Set, trimmedSources: Set): void { - if (droppedSources.size === 0 && trimmedSources.size === 0) { +function logTrimmedEidSources(droppedSources: string[], trimmedSources: string[]): void { + if (droppedSources.length === 0 && trimmedSources.length === 0) { return; } const parts: string[] = []; - if (droppedSources.size > 0) { - parts.push(`dropped sources: ${[...droppedSources].join(', ')}`); + if (droppedSources.length > 0) { + parts.push(`dropped sources: ${droppedSources.join(', ')}`); } - if (trimmedSources.size > 0) { - parts.push(`trimmed uids from sources: ${[...trimmedSources].join(', ')}`); + if (trimmedSources.length > 0) { + parts.push(`trimmed uids from sources: ${trimmedSources.join(', ')}`); } log.debug( `[tsjs-prebid] ts-eids cookie exceeded ${MAX_EID_COOKIE_BYTES} bytes; ${parts.join('; ')}` @@ -2975,37 +2893,84 @@ function logTrimmedEidSources(droppedSources: Set, trimmedSources: Set 0 || fitted.trimmedSources.length > 0 + ? 'trimmed' + : 'written'; + } catch { + log.warn('[tsjs-prebid] failed to write the ts-eids cookie; clearing it'); + clearPrebidEidsCookie(); + return 'cleared'; + } +} - const payload = fitAuctionEidsToCookie(eids); - if (!payload) { - clearPrebidEidsCookie(); - return; - } +/** + * Collects the EIDs Prebid placed in this `trustedServer` bidder request and + * mirrors them into `ts-eids` synchronously, before Prebid sends the request, + * so the server never falls back to a stale cookie for it. Without + * persistence consent the EIDs are still forwarded and `ts-eids` is cleared + * instead. + * + * `bidderRequest.ortb2` is what Prebid lets `trustedServer` see: global + * first-party data merged with its own bidder fragment, IDs restricted to + * other bidders excluded, activity controls applied. Nothing else is read: + * not `getUserIdsAsEids()`, not the `ortb2` config, not `auctionId`. + */ +function forwardBidderEids(bidderRequest: unknown): unknown[] | undefined { + if (bidderRequest === null || typeof bidderRequest !== 'object') { + log.debug( + '[tsjs-prebid] buildRequests got no bidder request; sending no EIDs and leaving ts-eids unchanged' + ); + return undefined; + } - const encoded = btoa(JSON.stringify(payload)); - document.cookie = `${EID_COOKIE_NAME}=${encoded}; Path=/; Secure; SameSite=Lax; Max-Age=${EID_COOKIE_MAX_AGE}`; + const stats: EidCollectionStats = { + forwarded: 0, + unserializable: 0, + overBudget: 0, + }; + const eids = collectBidderEids(bidderRequest, stats); + let cookie: EidCookieDecision; + if (!hasEidPersistenceConsent(bidderRequest)) { + // Forwarding still follows Prebid's transmit decision; only the copy that + // outlives the page needs persistence consent. + clearPrebidEidsCookie(); + cookie = 'cleared: no consent'; + } else if (eids) { + cookie = writePrebidEidsCookie(eids); + } else { + clearPrebidEidsCookie(); + cookie = 'cleared'; + } + log.debug('[tsjs-prebid] forwarding bidder EIDs', { ...stats, cookie }); + return eids; +} - log.debug(`[tsjs-prebid] synced ${payload.length} EID sources to cookie`); - } catch (err) { - log.warn('[tsjs-prebid] failed to sync EIDs cookie', err); +/** Whether a `tcf2Enforcement` payload says Prebid blocked `trustedServer` or its EIDs. */ +function tcfBlocksTrustedServer(payload: unknown): boolean { + if (payload === null || typeof payload !== 'object') { + return false; } + const { biddersBlocked, eidsBlocked } = payload as { + biddersBlocked?: unknown; + eidsBlocked?: unknown; + }; + return [biddersBlocked, eidsBlocked].some( + (blocked) => Array.isArray(blocked) && blocked.includes(ADAPTER_CODE) + ); } // Self-initialize when loaded in a browser (same pattern as other integrations). @@ -3017,21 +2982,10 @@ if (typeof window !== 'undefined') { // fail to run the replacement auction — leave GPT untouched instead. if (hasPrebidJsApi()) { installRefreshHandler(); - // The slim-Prebid lazy loader appends this bundle from a window.load - // handler, so `load` may already have fired by the time this code runs — - // waiting for it again would skip user ID setup entirely on that path. - if (document.readyState === 'complete') { - installUserIdModules(); - } else { - window.addEventListener( - 'load', - () => { - installUserIdModules(); - }, - { once: true } - ); - } } + // TS writes no userSync values beyond managed userIds (and autoRefresh on a + // late seed), so the publisher's or Prebid's auctionDelay applies from the + // first auction. } export { pbjs }; diff --git a/crates/trusted-server-js/lib/src/integrations/prebid/user_id_modules.json b/crates/trusted-server-js/lib/src/integrations/prebid/user_id_modules.json index 38061761d3..32f2db4b1d 100644 --- a/crates/trusted-server-js/lib/src/integrations/prebid/user_id_modules.json +++ b/crates/trusted-server-js/lib/src/integrations/prebid/user_id_modules.json @@ -13,75 +13,48 @@ "modules": [ { "moduleName": "connectIdSystem", - "configNames": ["connectId"], - "eidSources": ["yahoo.com"] + "configNames": ["connectId"] }, { "moduleName": "criteoIdSystem", - "configNames": ["criteo"], - "eidSources": ["criteo.com"] + "configNames": ["criteo"] }, { "moduleName": "id5IdSystem", - "configNames": ["id5Id"], - "eidSources": ["id5-sync.com"] + "configNames": ["id5Id"] }, { "moduleName": "identityLinkIdSystem", - "configNames": ["identityLink"], - "eidSources": ["liveramp.com"] + "configNames": ["identityLink"] }, { "moduleName": "liveIntentIdSystem", "configNames": ["liveIntentId"], - "eidSources": [ - "liveintent.com", - "bidswitch.net", - "liveintent.triplelift.com", - "rubiconproject.com", - "liveintent.indexexchange.com", - "openx.net", - "pubmatic.com", - "liveintent.sovrn.com", - "liveintent.unrulymedia.com", - "media.net", - "zeta-ssp.liveintent.com", - "fpid.liveintent.com", - "sharethrough.com", - "liveintent.sonobi.com", - "liveintent.vidazoo.com" - ], "notes": "Imported through a local ESM shim because the public Prebid wrapper contains CommonJS require()." }, { "moduleName": "lockrAIMIdSystem", - "configNames": ["lockrAIMId"], - "eidSources": [] + "configNames": ["lockrAIMId"] }, { "moduleName": "pairIdSystem", - "configNames": ["pairId"], - "eidSources": ["google.com"] + "configNames": ["pairId"] }, { "moduleName": "pubProvidedIdSystem", - "configNames": ["pubProvidedId"], - "eidSources": [] + "configNames": ["pubProvidedId"] }, { "moduleName": "sharedIdSystem", - "configNames": ["sharedId", "pubCommonId"], - "eidSources": ["pubcid.org"] + "configNames": ["sharedId", "pubCommonId"] }, { "moduleName": "uid2IdSystem", - "configNames": ["uid2"], - "eidSources": ["uidapi.com"] + "configNames": ["uid2"] }, { "moduleName": "unifiedIdSystem", - "configNames": ["unifiedId"], - "eidSources": ["adserver.org"] + "configNames": ["unifiedId"] } ] } diff --git a/crates/trusted-server-js/lib/src/integrations/prebid/user_id_modules.ts b/crates/trusted-server-js/lib/src/integrations/prebid/user_id_modules.ts index b463e22026..40da6b36aa 100644 --- a/crates/trusted-server-js/lib/src/integrations/prebid/user_id_modules.ts +++ b/crates/trusted-server-js/lib/src/integrations/prebid/user_id_modules.ts @@ -3,7 +3,6 @@ import registryJson from './user_id_modules.json'; export interface PrebidUserIdModuleRegistryEntry { moduleName: string; configNames: string[]; - eidSources: string[]; notes?: string; } @@ -12,54 +11,10 @@ interface PrebidUserIdModuleRegistryFile { modules: PrebidUserIdModuleRegistryEntry[]; } -export interface PrebidUserIdEidLike { - source?: unknown; - uids?: Array<{ id?: unknown; atype?: unknown; ext?: unknown }>; -} - -export interface PrebidUserIdModuleResolution { - modules: string[]; - missingSources: string[]; -} - const registry = registryJson as PrebidUserIdModuleRegistryFile; -const DEFAULT_PREBID_USER_ID_MODULES = [...registry.defaultPreset]; export const PREBID_USER_ID_MODULE_REGISTRY = [...registry.modules]; -const MODULE_SORT_ORDER = new Map( - ['userId', ...DEFAULT_PREBID_USER_ID_MODULES].map((moduleName, index) => [moduleName, index]) -); - -function stableModuleSort(a: string, b: string): number { - return ( - (MODULE_SORT_ORDER.get(a) ?? Number.MAX_SAFE_INTEGER) - - (MODULE_SORT_ORDER.get(b) ?? Number.MAX_SAFE_INTEGER) || a.localeCompare(b) - ); -} - -function normalizeSource(source: unknown): string | undefined { - if (typeof source !== 'string') { - return undefined; - } - const normalized = source.trim().toLowerCase(); - return normalized.length > 0 ? normalized : undefined; -} - -function hasLiveIntentProvider(eid: PrebidUserIdEidLike): boolean { - return Array.isArray(eid.uids) - ? eid.uids.some((uid) => { - const ext = uid.ext; - return ( - ext !== null && - typeof ext === 'object' && - !Array.isArray(ext) && - (ext as Record).provider === 'liveintent.com' - ); - }) - : false; -} - export function knownUserIdConfigNames(): string[] { return [...new Set(PREBID_USER_ID_MODULE_REGISTRY.flatMap((entry) => entry.configNames))].sort(); } @@ -100,46 +55,3 @@ export function userIdConfigNameAliases(configName: string): string[] { export function userIdSubmoduleKey(configName: string): string { return findUserIdModuleEntry(configName)?.moduleName ?? configName.toLowerCase(); } - -export function resolvePrebidUserIdModulesFromEids( - eids: PrebidUserIdEidLike[] -): PrebidUserIdModuleResolution { - const modules = new Set(); - const missingSources = new Set(); - const modulesBySource = new Map(); - - for (const entry of PREBID_USER_ID_MODULE_REGISTRY) { - for (const source of entry.eidSources) { - modulesBySource.set(source.toLowerCase(), entry.moduleName); - } - } - - for (const eid of eids) { - const source = normalizeSource(eid.source); - if (!source) { - continue; - } - - const moduleName = modulesBySource.get(source); - if (moduleName) { - modules.add(moduleName); - continue; - } - - if (hasLiveIntentProvider(eid)) { - modules.add('liveIntentIdSystem'); - continue; - } - - missingSources.add(source); - } - - if (modules.size > 0) { - modules.add('userId'); - } - - return { - modules: [...modules].sort(stableModuleSort), - missingSources: [...missingSources].sort(), - }; -} diff --git a/crates/trusted-server-js/lib/test/core/request.test.ts b/crates/trusted-server-js/lib/test/core/request.test.ts index e88ed520cd..ee33130af3 100644 --- a/crates/trusted-server-js/lib/test/core/request.test.ts +++ b/crates/trusted-server-js/lib/test/core/request.test.ts @@ -25,6 +25,36 @@ describe('request.requestAds', () => { vi.restoreAllMocks(); }); + it('sends no eids in the /auction body', async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + headers: { get: () => 'application/json' }, + json: async () => ({ seatbid: [] }), + }); + const { addAdUnits } = await import('../../src/core/registry'); + const { requestAds } = await import('../../src/core/request'); + document.body.innerHTML = '
'; + addAdUnits({ code: 'slot1', mediaTypes: { banner: { sizes: [[300, 250]] } } }); + + requestAds(); + await flushRequestAds(); + + const [, init] = (globalThis.fetch as ReturnType).mock.calls[0] as [ + string, + RequestInit, + ]; + const body = JSON.parse(String(init.body)) as Record; + expect(body.adUnits, 'the body should carry the ad unit').toEqual([ + { + code: 'slot1', + mediaTypes: { banner: { sizes: [[300, 250]] } }, + bids: [], + }, + ]); + expect(body, 'requestAds should leave EIDs to the ts-eids cookie').not.toHaveProperty('eids'); + }); + it('sends fetch and renders creatives via iframe from response', async () => { // mock fetch - returns creative HTML inline in adm field const creativeHtml = '
Test Creative
'; diff --git a/crates/trusted-server-js/lib/test/fixtures/auction-body-eids.txt b/crates/trusted-server-js/lib/test/fixtures/auction-body-eids.txt new file mode 100644 index 0000000000..cec8a54bc9 --- /dev/null +++ b/crates/trusted-server-js/lib/test/fixtures/auction-body-eids.txt @@ -0,0 +1 @@ +{"adUnits":[{"code":"div-gpt-1","mediaTypes":{"banner":{"sizes":[[300,250]]}},"bids":[{"bidder":"trustedServer","params":{}}]}],"eids":[{"source":"first.example.com","inserter":"inserter-€.example.com","matcher":"first.example.com","mm":3,"uids":[{"id":"first-uid","atype":1,"ext":{"label":"日本"}}]},{"source":"bad.example.com","uids":[{"id":"bad-\ud800-uid","atype":1}]},{"source":"last.example.com","ext":{"segments":["s1"]},"uids":[{"id":"last-uid","atype":3}]}]} \ No newline at end of file diff --git a/crates/trusted-server-js/lib/test/fixtures/eid-fixture-input.ts b/crates/trusted-server-js/lib/test/fixtures/eid-fixture-input.ts new file mode 100644 index 0000000000..6c905a568e --- /dev/null +++ b/crates/trusted-server-js/lib/test/fixtures/eid-fixture-input.ts @@ -0,0 +1,23 @@ +/** + * The fixed input behind `auction-body-eids.txt` and `ts-eids-cookie.txt` + * (spec 7.1 rule 8). The middle entry's UID `id` holds a lone UTF-16 + * surrogate, which `JSON.stringify` writes as the escape `\ud800` and the + * server drops for that entry only. The first entry has non-Latin-1 text in + * `inserter` and in a UID `ext`. The last has an EID-level `ext`, which the + * cookie omits. Key order matters: the fixtures are exact bytes. + */ +export const FIXTURE_EIDS = [ + { + source: 'first.example.com', + inserter: 'inserter-€.example.com', + matcher: 'first.example.com', + mm: 3, + uids: [{ id: 'first-uid', atype: 1, ext: { label: '日本' } }], + }, + { source: 'bad.example.com', uids: [{ id: 'bad-\ud800-uid', atype: 1 }] }, + { + source: 'last.example.com', + ext: { segments: ['s1'] }, + uids: [{ id: 'last-uid', atype: 3 }], + }, +]; diff --git a/crates/trusted-server-js/lib/test/fixtures/ts-eids-cookie.txt b/crates/trusted-server-js/lib/test/fixtures/ts-eids-cookie.txt new file mode 100644 index 0000000000..732424d1c1 --- /dev/null +++ b/crates/trusted-server-js/lib/test/fixtures/ts-eids-cookie.txt @@ -0,0 +1 @@ +W3sic291cmNlIjoiZmlyc3QuZXhhbXBsZS5jb20iLCJpbnNlcnRlciI6Imluc2VydGVyLeKCrC5leGFtcGxlLmNvbSIsIm1hdGNoZXIiOiJmaXJzdC5leGFtcGxlLmNvbSIsIm1tIjozLCJ1aWRzIjpbeyJpZCI6ImZpcnN0LXVpZCIsImF0eXBlIjoxLCJleHQiOnsibGFiZWwiOiLml6XmnKwifX1dfSx7InNvdXJjZSI6ImJhZC5leGFtcGxlLmNvbSIsInVpZHMiOlt7ImlkIjoiYmFkLVx1ZDgwMC11aWQiLCJhdHlwZSI6MX1dfSx7InNvdXJjZSI6Imxhc3QuZXhhbXBsZS5jb20iLCJ1aWRzIjpbeyJpZCI6Imxhc3QtdWlkIiwiYXR5cGUiOjN9XX1d \ No newline at end of file diff --git a/crates/trusted-server-js/lib/test/integrations/prebid/eids.test.ts b/crates/trusted-server-js/lib/test/integrations/prebid/eids.test.ts new file mode 100644 index 0000000000..296d85e3f1 --- /dev/null +++ b/crates/trusted-server-js/lib/test/integrations/prebid/eids.test.ts @@ -0,0 +1,422 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { describe, expect, it } from 'vitest'; + +import { + collectBidderEids, + fitEidCookie, + MAX_AUCTION_EIDS_BYTES, + MAX_EID_COOKIE_BYTES, + type EidCollectionStats, +} from '../../../src/integrations/prebid/eids'; +import { FIXTURE_EIDS } from '../../fixtures/eid-fixture-input'; + +function deepFreeze(value: T, seen = new WeakSet()): T { + if (value !== null && typeof value === 'object' && !seen.has(value)) { + seen.add(value); + for (const child of Object.values(value)) { + deepFreeze(child, seen); + } + Object.freeze(value); + } + return value; +} + +/** A bidder request shaped as Prebid passes it: frozen `ortb2`, `auctionId` hidden. */ +function bidderRequest(user: unknown): unknown { + return deepFreeze({ + bidderCode: 'trustedServer', + auctionId: null, + ortb2: { user }, + }); +} + +function freshStats(): EidCollectionStats { + return { forwarded: 0, unserializable: 0, overBudget: 0 }; +} + +function utf8Length(text: string): number { + return new TextEncoder().encode(text).length; +} + +/** An entry whose `JSON.stringify` is exactly `bytes` UTF-8 bytes long. */ +function entryOfBytes(source: string, bytes: number, filler = 'x'): unknown { + const base = JSON.stringify({ source, uids: [{ id: '' }] }); + const fillerBytes = utf8Length(filler); + return { + source, + uids: [{ id: filler.repeat((bytes - utf8Length(base)) / fillerBytes) }], + }; +} + +function decodeCookie(value: string | undefined): unknown { + expect(value, 'should produce a cookie value').toBeDefined(); + return JSON.parse( + new TextDecoder().decode(Uint8Array.from(atob(value!), (char) => char.charCodeAt(0))) + ); +} + +function readFixture(name: string): string { + return fs + .readFileSync( + path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../fixtures', name), + 'utf8' + ) + .trimEnd(); +} + +const EXT_EID = { + source: 'ext.example.com', + uids: [{ id: 'ext-uid', atype: 1 }], +}; +const USER_EID = { + source: 'user.example.com', + uids: [{ id: 'user-uid', atype: 1 }], +}; + +describe('collectBidderEids', () => { + it('reads ortb2.user.ext.eids', () => { + expect(collectBidderEids(bidderRequest({ ext: { eids: [EXT_EID] } }))).toEqual([EXT_EID]); + }); + + it('reads ortb2.user.eids, where RTD modules can write after normalization', () => { + expect(collectBidderEids(bidderRequest({ eids: [USER_EID] }))).toEqual([USER_EID]); + }); + + it('reads user.ext.eids before user.eids', () => { + const request = bidderRequest({ + ext: { eids: [EXT_EID] }, + eids: [USER_EID], + }); + expect( + Object.isFrozen((request as { ortb2: { user: object } }).ortb2.user), + 'the input should be frozen as Prebid passes it' + ).toBe(true); + + expect(collectBidderEids(request)).toEqual([EXT_EID, USER_EID]); + }); + + it.each([ + ['no user', bidderRequest(undefined)], + ['an empty user', bidderRequest({})], + ['non-array paths', bidderRequest({ ext: { eids: 'x' }, eids: { source: 'x' } })], + ['no ortb2', deepFreeze({ bidderCode: 'trustedServer' })], + ])('returns undefined for %s', (_label, request) => { + expect(collectBidderEids(request)).toBeUndefined(); + }); + + it.each([undefined, null, 'request', 7])( + 'returns undefined for a bidder request that is not an object: %s', + (request) => { + expect(collectBidderEids(request)).toBeUndefined(); + } + ); + + it('returns plain-data copies without undefined fields', () => { + const openPair = { + source: 'openpair.example.com', + inserter: undefined, + matcher: undefined, + mm: 3, + uids: [{ id: 'pair-uid', atype: 571187 }], + }; + + const result = collectBidderEids(bidderRequest({ ext: { eids: [openPair] } })); + + expect(result, 'undefined provenance fields should disappear').toStrictEqual([ + { + source: 'openpair.example.com', + mm: 3, + uids: [{ id: 'pair-uid', atype: 571187 }], + }, + ]); + expect(result![0], 'the result should be a copy, not Prebid’s object').not.toBe(openPair); + }); + + it('drops an entry whose serialization throws and keeps the others', () => { + const circular: Record = {}; + circular.self = circular; + const stats = freshStats(); + + const result = collectBidderEids( + bidderRequest({ + ext: { + eids: [ + EXT_EID, + { + source: 'circular.example.com', + ext: circular, + uids: [{ id: 'c' }], + }, + { + source: 'bigint.example.com', + uids: [{ id: 'b', atype: BigInt(10) }], + }, + USER_EID, + ], + }, + }), + stats + ); + + expect(result).toEqual([EXT_EID, USER_EID]); + expect(stats).toEqual({ forwarded: 2, unserializable: 2, overBudget: 0 }); + }); + + it('keeps a set that fits the UTF-8 budget exactly', () => { + const first = entryOfBytes('first.example.com', MAX_AUCTION_EIDS_BYTES - 100); + const second = entryOfBytes('second.example.com', 100); + const stats = freshStats(); + + expect(collectBidderEids(bidderRequest({ ext: { eids: [first, second] } }), stats)).toEqual([ + first, + second, + ]); + expect(stats).toEqual({ forwarded: 2, unserializable: 0, overBudget: 0 }); + }); + + it('drops the entry that crosses the budget and every entry after it', () => { + const first = entryOfBytes('first.example.com', MAX_AUCTION_EIDS_BYTES - 100); + const second = entryOfBytes('second.example.com', 101); + const third = entryOfBytes('third.example.com', 60); + const stats = freshStats(); + + expect( + collectBidderEids(bidderRequest({ ext: { eids: [first, second, third] } }), stats) + ).toEqual([first]); + expect(stats).toEqual({ forwarded: 1, unserializable: 0, overBudget: 2 }); + }); + + it('measures the budget in UTF-8 bytes, not JavaScript string length', () => { + const ascii = entryOfBytes('ascii.example.com', 40_000); + // `€` is one UTF-16 code unit and three UTF-8 bytes: 53 bytes of JSON + // around 10,000 of them. + const multibyte = entryOfBytes('multibyte.example.com', 30_053, '€'); + expect( + JSON.stringify(ascii).length + JSON.stringify(multibyte).length, + 'precondition: the set fits by JavaScript length' + ).toBeLessThan(MAX_AUCTION_EIDS_BYTES); + + expect(collectBidderEids(bidderRequest({ ext: { eids: [ascii, multibyte] } }))).toEqual([ + ascii, + ]); + }); + + it('keeps order and repeated sources', () => { + const a1 = { source: 'a.example.com', mm: 1, uids: [{ id: 'u' }] }; + const b = { source: 'b.example.com', uids: [{ id: 'u' }] }; + const a2 = { source: 'a.example.com', mm: 2, uids: [{ id: 'u' }] }; + + expect(collectBidderEids(bidderRequest({ ext: { eids: [a1, b, a2] } }))).toEqual([a1, b, a2]); + }); + + it('forwards a lone surrogate unchanged for the server to drop', () => { + const result = collectBidderEids(bidderRequest({ ext: { eids: FIXTURE_EIDS } })); + + expect(result).toEqual(FIXTURE_EIDS); + expect(JSON.stringify(result), 'JSON.stringify should write the escape').toContain( + '"bad-\\ud800-uid"' + ); + }); + + it('passes the Prebid module shapes from spec 3.5 through as plain data', () => { + const fiftyOneDegrees = [ + { source: '51d.es', mm: 1, uids: [{ id: 'device-1', atype: 1 }] }, + { source: '51d.es', mm: 2, uids: [{ id: 'device-2', atype: 1 }] }, + ]; + const openPair = { + source: 'openpair.example.com', + inserter: undefined, + matcher: undefined, + uids: [{ id: 'p' }], + }; + const liveIntent = { + source: 'liveintent.com', + ext: { segments: ['s1', 's2'] }, + uids: [{ id: 'li', atype: 3 }], + }; + const oneKey = { + source: 'paf.example.com', + ext: { preferences: { use_browsing_for_personalization: true } }, + uids: [{ id: 'ok', atype: 1 }], + }; + const pair = { + source: 'google.com', + uids: [{ id: 'pair-id', atype: 571187 }], + }; + + const result = collectBidderEids( + bidderRequest({ + ext: { eids: [openPair, liveIntent, oneKey, pair] }, + eids: fiftyOneDegrees, + }) + ); + + expect(result).toStrictEqual([ + { source: 'openpair.example.com', uids: [{ id: 'p' }] }, + liveIntent, + oneKey, + pair, + ...fiftyOneDegrees, + ]); + }); +}); + +describe('fitEidCookie', () => { + it('removes EID-level ext and drops the duplicates that creates, comparing with sorted keys', () => { + const withExt = { + source: 'a.example.com', + ext: { segments: ['s1'] }, + uids: [{ id: 'a' }], + }; + const reordered = { uids: [{ id: 'a' }], source: 'a.example.com' }; + const other = { + source: 'b.example.com', + uids: [{ id: 'b', ext: { kept: true } }], + }; + + const fitted = fitEidCookie([withExt, reordered, other]); + + expect(decodeCookie(fitted.value)).toEqual([ + { source: 'a.example.com', uids: [{ id: 'a' }] }, + other, + ]); + expect(fitted.droppedSources).toEqual([]); + expect(fitted.trimmedSources).toEqual([]); + }); + + it('does not modify the entries it is given', () => { + const entry = { + source: 'a.example.com', + ext: { segments: ['s1'] }, + uids: [{ id: 'a' }, { id: 'b' }], + }; + const snapshot = structuredClone(entry); + + fitEidCookie([entry]); + + expect(entry).toEqual(snapshot); + }); + + it('drops whole tail sources until the cookie fits', () => { + const long = 'x'.repeat(400); + const entries = [0, 1, 2, 3, 4, 5, 6].map((index) => ({ + source: `s${index}.example.com`, + uids: [{ id: long, atype: 1 }], + })); + + const fitted = fitEidCookie(entries); + + expect(fitted.value!.length).toBeLessThanOrEqual(MAX_EID_COOKIE_BYTES); + expect(decodeCookie(fitted.value)).toEqual(entries.slice(0, 5)); + expect(fitted.droppedSources).toEqual(['s6.example.com', 's5.example.com']); + expect(fitted.trimmedSources).toEqual([]); + }); + + it('trims UIDs from the last source before dropping it', () => { + const long = 'x'.repeat(400); + const uids = Array.from({ length: 10 }, (_, index) => ({ + id: `${long}${index}`, + atype: 1, + })); + + const fitted = fitEidCookie([{ source: 'ids.example.com', uids }]); + + expect(decodeCookie(fitted.value)).toEqual([ + { source: 'ids.example.com', uids: uids.slice(0, 5) }, + ]); + expect(fitted.droppedSources).toEqual([]); + expect(fitted.trimmedSources).toEqual(['ids.example.com']); + }); + + it('reports a source trimmed and then dropped only as dropped', () => { + const huge = 'y'.repeat(2500); + const kept = { + source: 'kept.example.com', + uids: [{ id: 'kept', atype: 1 }], + }; + + const fitted = fitEidCookie([ + kept, + { + source: 'dropped.example.com', + uids: Array.from({ length: 3 }, (_, index) => ({ + id: `${huge}${index}`, + atype: 1, + })), + }, + ]); + + expect(decodeCookie(fitted.value)).toEqual([kept]); + expect(fitted.droppedSources).toEqual(['dropped.example.com']); + expect(fitted.trimmedSources).toEqual([]); + }); + + it.each([ + ['a string', 'x'.repeat(4000), '(malformed entry)'], + [ + 'an object without a uids array', + { source: 'no-uids.example.com', blob: 'x'.repeat(4000) }, + 'no-uids.example.com', + ], + ])('removes a last entry that is %s whole', (_label, last, droppedLabel) => { + const small = { source: 'small.example.com', uids: [{ id: 's' }] }; + + const fitted = fitEidCookie([small, last]); + + expect(decodeCookie(fitted.value)).toEqual([small]); + expect(fitted.droppedSources).toEqual([droppedLabel]); + }); + + it('removes a null last entry whole before trimming the entry before it', () => { + const big = { + source: 'big.example.com', + uids: [{ id: 'y'.repeat(1500) }, { id: 'z'.repeat(1500) }], + }; + + const fitted = fitEidCookie([big, null]); + + expect(decodeCookie(fitted.value)).toEqual([ + { source: 'big.example.com', uids: [big.uids[0]] }, + ]); + expect(fitted.droppedSources).toEqual(['(malformed entry)']); + expect(fitted.trimmedSources).toEqual(['big.example.com']); + }); + + it('returns no value when nothing fits', () => { + const fitted = fitEidCookie([{ source: 'huge.example.com', uids: [{ id: 'h'.repeat(5000) }] }]); + + expect(fitted).toEqual({ + value: undefined, + droppedSources: ['huge.example.com'], + trimmedSources: [], + }); + }); + + it('encodes non-Latin-1 text as UTF-8 base64 that round-trips', () => { + const entries = [ + { + source: 'ids.example.com', + inserter: 'inserter-€', + uids: [{ id: '日本' }], + }, + ]; + expect(() => btoa(JSON.stringify(entries)), 'precondition: btoa alone throws here').toThrow(); + + expect(decodeCookie(fitEidCookie(entries).value)).toEqual(entries); + }); + + it('matches btoa output for ASCII payloads', () => { + const entries = [{ source: 'ids.example.com', uids: [{ id: 'ascii-uid', atype: 1 }] }]; + + expect(fitEidCookie(entries).value).toBe(btoa(JSON.stringify(entries))); + }); + + it('produces exactly the ts-eids-cookie fixture for the fixture input', () => { + const collected = collectBidderEids(bidderRequest({ ext: { eids: FIXTURE_EIDS } })); + + expect(fitEidCookie(collected!).value).toBe(readFixture('ts-eids-cookie.txt')); + }); +}); diff --git a/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts b/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts index 0c209b89a1..2499b6d075 100644 --- a/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts @@ -1,4 +1,8 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { describe, it, expect, vi, beforeAll, beforeEach, afterAll, afterEach } from 'vitest'; function apsRenderer() { const bid = envelope.seatbid[0].bid[0]; @@ -236,6 +240,8 @@ interface TestAdapterSpec { // the external bundle in production), so tests install the mock there instead // of mocking module imports. const { + CANARY_EID, + auctionBodyLog, mockSetConfig, mockMergeConfig, mockProcessQueue, @@ -254,11 +260,39 @@ const { const mockMergeConfig = vi.fn((config: unknown) => mockSetConfig(config)); const mockProcessQueue = vi.fn(); const mockRequestBids = vi.fn(); - const mockRegisterBidAdapter = vi.fn(); + const mockRegisterBidAdapter = vi.fn( + ( + _bidder: unknown, + code: string, + spec: { buildRequests?: (...args: unknown[]) => { data?: unknown } } + ) => { + if (code !== 'trustedServer' || typeof spec?.buildRequests !== 'function') return; + const buildRequests = spec.buildRequests; + spec.buildRequests = function (this: unknown, ...args: unknown[]) { + const request = buildRequests.apply(this, args); + auctionBodyLog.bodies.push(String(request?.data)); + auctionBodyLog.total += 1; + return request; + }; + } + ); const mockMarkWinningBidAsUsed = vi.fn(); const mockOnEvent = vi.fn(); + // D1 canary (spec 7.1 rule 3): an ID only pbjs.getUserIdsAsEids() knows. It + // must never reach an /auction body or ts-eids. + const CANARY_EID = { + source: 'canary.example.com', + uids: [{ id: 'canary-uid', atype: 1 }], + }; + // Every /auction body the trustedServer adapter builds, recorded at the + // adapter so no test can bypass the canary check. + const auctionBodyLog = { bodies: [] as string[], total: 0 }; const mockGetUserIdsAsEids = vi.fn( - () => [] as Array<{ source: string; uids?: Array<{ id: string; atype?: number }> }> + () => + [CANARY_EID] as Array<{ + source: string; + uids?: Array<{ id: string; atype?: number }>; + }> ); const mockGetConfig = vi.fn(); @@ -326,6 +360,8 @@ const { }; return { + CANARY_EID, + auctionBodyLog, mockSetConfig, mockMergeConfig, mockProcessQueue, @@ -364,6 +400,7 @@ import type { TsjsApi } from '../../../src/core/types'; import { GptDiagnosticsObserver } from '../../../src/integrations/gpt_diagnostics/observer'; import { GptDiagnosticsStore } from '../../../src/integrations/gpt_diagnostics/store'; import envelope from '../../fixtures/aps-renderer-v1.json'; +import { FIXTURE_EIDS } from '../../fixtures/eid-fixture-input'; // installPrebidNpm is a per-page no-op once the sentinel is set (the module // self-init above already set it), so every test starts from a clean page. @@ -375,6 +412,109 @@ beforeEach(() => { delete mockPbjs['__tsManagedUserIdsSetConfigInstalled']; }); +function readTsEidsCookie(): string | undefined { + return document.cookie.match(/(?:^|; )ts-eids=([^;]*)/)?.[1] || undefined; +} + +function decodeBase64Utf8(value: string): string { + return new TextDecoder().decode(Uint8Array.from(atob(value), (char) => char.charCodeAt(0))); +} + +function decodedTsEidsCookie(): unknown { + const value = readTsEidsCookie(); + return value === undefined ? undefined : JSON.parse(decodeBase64Utf8(value)); +} + +function presetTsEidsCookie(value: string): void { + document.cookie = `ts-eids=${value}; Path=/; Secure; SameSite=Lax; Max-Age=60`; + expect(readTsEidsCookie(), 'the preset ts-eids cookie should read back').toBe(value); +} + +beforeAll(() => { + // jsdom silently drops a Secure cookie on a non-HTTPS URL other than + // localhost (spec 7.1 rule 5). Prove the shim's attributes survive here. + document.cookie = 'ts-eids-probe=1; Path=/; Secure; SameSite=Lax; Max-Age=60'; + expect(document.cookie, 'shim cookie attributes should be readable here').toContain( + 'ts-eids-probe=1' + ); + document.cookie = 'ts-eids-probe=; Path=/; Max-Age=0'; +}); + +afterEach(() => { + // Drain first so one leaking test cannot cascade into the next. + const bodies = auctionBodyLog.bodies.splice(0); + expect(mockGetUserIdsAsEids(), 'a test switched the getUserIdsAsEids canary off').toEqual([ + CANARY_EID, + ]); + for (const body of bodies) { + expect(body, 'the getUserIdsAsEids canary leaked into an /auction body').not.toContain( + CANARY_EID.source + ); + } + const cookie = readTsEidsCookie(); + if (cookie !== undefined) { + let readable = cookie; + try { + readable = decodeBase64Utf8(cookie); + } catch { + // A preset non-base64 test value; check it as written. + } + expect(readable, 'the getUserIdsAsEids canary leaked into ts-eids').not.toContain( + CANARY_EID.source + ); + } +}); + +afterAll(() => { + expect( + auctionBodyLog.total, + 'the canary should have inspected at least one /auction body' + ).toBeGreaterThan(0); +}); + +function deepFreeze(value: T, seen = new WeakSet()): T { + if (value !== null && typeof value === 'object' && !seen.has(value)) { + seen.add(value); + for (const child of Object.values(value)) { + deepFreeze(child, seen); + } + Object.freeze(value); + } + return value; +} + +/** + * A bidder request shaped as Prebid passes it to buildRequests: a frozen + * `ortb2` and `auctionId: null`, as Prebid's proxy hides it by default. + */ +function prebidBidderRequest(user: Record): Record { + return deepFreeze({ + bidderCode: 'trustedServer', + auctionId: null, + ortb2: { user }, + }); +} + +function trustedServerBidRequests(): Array> { + return [ + { + adUnitCode: 'div-gpt-1', + bidder: 'trustedServer', + mediaTypes: { banner: { sizes: [[300, 250]] } }, + params: {}, + }, + ]; +} + +function readFixture(name: string): string { + return fs + .readFileSync( + path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../fixtures', name), + 'utf8' + ) + .trimEnd(); +} + describe('prebid/collectBidders', () => { it('returns empty array for empty ad units', () => { expect(collectBidders([])).toEqual([]); @@ -578,7 +718,7 @@ describe('prebid/installPrebidNpm', () => { mockPbjs.que = []; mockPbjs.getConfig = mockGetConfig; mockGetUserIdsAsEids.mockReset(); - mockGetUserIdsAsEids.mockReturnValue([]); + mockGetUserIdsAsEids.mockReturnValue([CANARY_EID]); mockGetConfig.mockReset(); document.cookie = 'ts-eids=; Path=/; Max-Age=0'; testWindow.__tsjs_prebid_bundle = DEFAULT_BUNDLE_MANIFEST; @@ -1320,7 +1460,14 @@ describe('prebid/installPrebidNpm', () => { 'preserves publisher userSync and autoRefresh=%s when seeding late IDs', (autoRefresh) => { testWindow.__tsjs_prebid = { managedUserIds: [MANAGED_USER_ID] }; - const userSync = { userIds: [], auctionDelay: 300, syncEnabled: false, autoRefresh }; + const userSync = { + userIds: [], + auctionDelay: 300, + syncDelay: 50, + filterSettings: { image: { bidders: '*', filter: 'include' } }, + syncEnabled: false, + autoRefresh, + }; mockGetConfig.mockImplementation((key?: string) => { if (key === 'userSync.userIds') return []; if (key === 'userSync') return userSync; @@ -2416,203 +2563,367 @@ describe('prebid/installPrebidNpm', () => { expect(payload.eids).toBeUndefined(); }); - it('buildRequests includes current Prebid EIDs in the /auction payload', () => { + it('forwards an opaque LiveRamp envelope from the bidder request unchanged', () => { const spec = getAdapterSpec(); - mockGetUserIdsAsEids.mockReturnValue([ - { - source: 'id5-sync.com', - uids: [{ id: 'ID5_abc', atype: 1 }], - }, - { - source: 'sharedid.org', - uids: [{ id: 'shared_123' }, { id: 'shared_456', atype: 3 }], - }, - { - source: 'google.com', - uids: [{ id: 'pair_123', atype: 571187 }], - }, - ]); + const liveramp = { + source: 'liveramp.com', + uids: [{ id: 'opaque-test-envelope', atype: 3 }], + }; - const result = spec.buildRequests([ - { - adUnitCode: 'div-gpt-1', - bidder: 'trustedServer', - mediaTypes: { banner: { sizes: [[300, 250]] } }, - params: {}, - }, - ]); + const request = spec.buildRequests( + trustedServerBidRequests(), + prebidBidderRequest({ ext: { eids: [liveramp] } }) + ); - const payload = JSON.parse(result.data); - expect(payload.eids).toEqual([ - { - source: 'id5-sync.com', - uids: [{ id: 'ID5_abc', atype: 1 }], - }, - { - source: 'sharedid.org', - uids: [{ id: 'shared_123' }, { id: 'shared_456', atype: 3 }], - }, - { - source: 'google.com', - uids: [{ id: 'pair_123', atype: 571187 }], - }, - ]); + expect(JSON.parse(String(request.data)).eids).toEqual([liveramp]); }); - it('forwards the opaque LiveRamp envelope as a liveramp.com EID', () => { + it('forwards bidderRequest.ortb2 EIDs with EID-level fields, user.ext.eids first', () => { const spec = getAdapterSpec(); - mockGetUserIdsAsEids.mockReturnValue([ - { - source: 'liveramp.com', - uids: [{ id: 'opaque-test-envelope', atype: 3 }], - }, - ]); + const extEid = { + source: 'adserver.org', + inserter: 'adserver.org', + matcher: 'adserver.org', + mm: 4, + uids: [{ id: 'fictional-tdid', atype: 1, ext: { rtiPartner: 'TDID' } }], + }; + const userEid = { + source: '51d.es', + mm: 1, + uids: [{ id: 'fictional-device', atype: 1 }], + }; - const request = spec.buildRequests([ - { - adUnitCode: 'div-gpt-1', - bidId: 'bid-1', - bidder: 'trustedServer', - mediaTypes: { banner: { sizes: [[300, 250]] } }, - params: {}, - }, - ]); + const result = spec.buildRequests( + trustedServerBidRequests(), + prebidBidderRequest({ ext: { eids: [extEid] }, eids: [userEid] }) + ); - expect(JSON.parse(request.data).eids).toEqual([ - { - source: 'liveramp.com', - uids: [{ id: 'opaque-test-envelope', atype: 3 }], - }, + expect(JSON.parse(String(result.data)).eids, 'should forward both paths as sent').toEqual([ + extEid, + userEid, ]); + expect(mockGetUserIdsAsEids, 'should never read getUserIdsAsEids').not.toHaveBeenCalled(); + expect( + mockGetConfig.mock.calls.some(([key]) => key === 'ortb2'), + 'should never read the global ortb2 config' + ).toBe(false); }); - it('drops empty and malformed LiveRamp envelope values', () => { + it('sends exactly the auction-body fixture for the fixture input', () => { const spec = getAdapterSpec(); - mockGetUserIdsAsEids.mockReturnValue([ - { - source: 'liveramp.com', - uids: [ - { id: '' }, - { id: undefined as unknown as string }, - { id: 'opaque-valid-envelope', atype: 3 }, - ], - }, - { source: '', uids: [{ id: 'opaque-invalid-source' }] }, - ]); - const request = spec.buildRequests([ - { - adUnitCode: 'div-gpt-1', - bidder: 'trustedServer', - mediaTypes: { banner: { sizes: [[300, 250]] } }, - params: {}, - }, - ]); + const result = spec.buildRequests( + trustedServerBidRequests(), + prebidBidderRequest({ ext: { eids: FIXTURE_EIDS } }) + ); - expect(JSON.parse(request.data).eids).toEqual([ - { - source: 'liveramp.com', - uids: [{ id: 'opaque-valid-envelope', atype: 3 }], - }, - ]); + expect(String(result.data), 'TSJS should produce the Rust fixture byte for byte').toBe( + readFixture('auction-body-eids.txt') + ); }); - it('never writes an opaque LiveRamp envelope to logs', () => { - const sentinel = 'opaque-envelope-must-not-be-logged'; - const spies = [ - vi.spyOn(log, 'debug').mockImplementation(() => {}), - vi.spyOn(log, 'info').mockImplementation(() => {}), - vi.spyOn(log, 'warn').mockImplementation(() => {}), - vi.spyOn(log, 'error').mockImplementation(() => {}), - ]; + it('writes exactly the ts-eids fixture for the fixture input', () => { const spec = getAdapterSpec(); - mockGetUserIdsAsEids.mockReturnValue([ - { source: 'liveramp.com', uids: [{ id: sentinel, atype: 3 }] }, - ]); - spec.buildRequests([ - { - adUnitCode: 'div-gpt-1', - bidder: 'trustedServer', - mediaTypes: { banner: { sizes: [[300, 250]] } }, - params: {}, - }, - ]); + spec.buildRequests( + trustedServerBidRequests(), + prebidBidderRequest({ ext: { eids: FIXTURE_EIDS } }) + ); - const logged = spies.flatMap((spy) => spy.mock.calls).flat(); - expect(logged.some((value) => JSON.stringify(value).includes(sentinel))).toBe(false); + expect(readTsEidsCookie()).toBe(readFixture('ts-eids-cookie.txt')); }); - it('buildRequests clears stale ts-eids cookie when current Prebid EIDs are absent', () => { + it('writes ts-eids from the body set without EID-level ext', () => { const spec = getAdapterSpec(); - document.cookie = 'ts-eids=stale-value'; - mockGetUserIdsAsEids.mockReturnValue([]); + const withExt = { + source: 'liveintent.example.com', + ext: { segments: ['s1'] }, + uids: [{ id: 'li-uid', atype: 3 }], + }; + const plain = { + source: 'ids.example.com', + uids: [{ id: 'ids-uid', atype: 1 }], + }; - spec.buildRequests([ - { - adUnitCode: 'div-gpt-1', - bidder: 'trustedServer', - mediaTypes: { banner: { sizes: [[300, 250]] } }, - params: {}, - }, - ]); + const result = spec.buildRequests( + trustedServerBidRequests(), + prebidBidderRequest({ ext: { eids: [withExt, plain] } }) + ); - expect(document.cookie).toBe(''); + expect(JSON.parse(String(result.data)).eids, 'the body keeps EID-level ext').toEqual([ + withExt, + plain, + ]); + expect(decodedTsEidsCookie(), 'the cookie copies the body set without EID-level ext').toEqual( + [{ source: 'liveintent.example.com', uids: [{ id: 'li-uid', atype: 3 }] }, plain] + ); }); - it('buildRequests preserves uid ext and sanitizes invalid atype values', () => { + it('clears ts-eids before the request when the bidder request holds no EIDs', () => { const spec = getAdapterSpec(); - mockGetUserIdsAsEids.mockReturnValue([ - { - source: 'adserver.org', - uids: [ - { - id: 'uid-with-ext', - atype: 1, - ext: { provider: 'liveintent.com', rtiPartner: 'TDID' }, - }, - { - id: 'uid-bad-atype', - atype: 2_147_483_648, - ext: { keep: true }, - }, - { - id: 'uid-float-atype', - atype: 1.5, - }, - ], - }, - ]); + presetTsEidsCookie('preset-value'); - const result = spec.buildRequests([ - { - adUnitCode: 'div-gpt-1', - bidder: 'trustedServer', - mediaTypes: { banner: { sizes: [[300, 250]] } }, - params: {}, - }, - ]); + const result = spec.buildRequests(trustedServerBidRequests(), prebidBidderRequest({})); - const payload = JSON.parse(result.data); - expect(payload.eids).toEqual([ - { - source: 'adserver.org', - uids: [ - { - id: 'uid-with-ext', - atype: 1, - ext: { provider: 'liveintent.com', rtiPartner: 'TDID' }, - }, - { - id: 'uid-bad-atype', - ext: { keep: true }, + const payload = JSON.parse(String(result.data)); + expect(payload.adUnits[0].code, 'the request should still be built').toBe('div-gpt-1'); + expect(payload.eids, 'the body should carry no eids').toBeUndefined(); + expect(readTsEidsCookie(), 'the cookie should be cleared').toBeUndefined(); + }); + + describe('storage consent', () => { + const storedEid = { source: 'ids.example.com', uids: [{ id: 'ids-uid', atype: 1 }] }; + + function consentedBidderRequest(gdprConsent: unknown): Record { + return deepFreeze({ + bidderCode: 'trustedServer', + auctionId: null, + ortb2: { user: { ext: { eids: [storedEid] } } }, + gdprConsent, + }); + } + + function gdprConsentWithPurposes( + purposeOne: boolean, + purposeFour: boolean + ): Record { + return { + gdprApplies: true, + consentString: 'CPexampleTCStringForTests', + vendorData: { + purpose: { + consents: { 1: purposeOne, 2: true, 4: purposeFour }, + legitimateInterests: {}, }, - { - id: 'uid-float-atype', + }, + }; + } + + it.each([ + ['Purpose 1 is denied', gdprConsentWithPurposes(false, true)], + ['Purpose 4 is denied', gdprConsentWithPurposes(true, false)], + ['GDPR applies without a TCF purpose record', { gdprApplies: true, consentString: '' }], + ])('forwards EIDs but clears ts-eids when %s', (_case, gdprConsent) => { + const spec = getAdapterSpec(); + presetTsEidsCookie('preset-value'); + + const result = spec.buildRequests( + trustedServerBidRequests(), + consentedBidderRequest(gdprConsent) + ); + + expect(JSON.parse(String(result.data)).eids, 'should still forward the EIDs').toEqual([ + storedEid, + ]); + expect( + readTsEidsCookie(), + 'should clear ts-eids without persistence consent' + ).toBeUndefined(); + }); + + it.each([ + ['Purposes 1 and 4 are granted', gdprConsentWithPurposes(true, true)], + ['GDPR does not apply', { gdprApplies: false }], + ])('writes ts-eids when %s', (_case, gdprConsent) => { + const spec = getAdapterSpec(); + + spec.buildRequests(trustedServerBidRequests(), consentedBidderRequest(gdprConsent)); + + expect(decodedTsEidsCookie(), 'should write ts-eids with persistence consent').toEqual([ + storedEid, + ]); + }); + }); + + it('clears ts-eids when no EID fits the cookie budget but still sends the body', () => { + const debugSpy = vi.spyOn(log, 'debug').mockImplementation(() => {}); + const spec = getAdapterSpec(); + presetTsEidsCookie('preset-value'); + const entry = { source: 'huge.example.com', uids: [{ id: 'h'.repeat(5000) }] }; + + const result = spec.buildRequests( + trustedServerBidRequests(), + prebidBidderRequest({ ext: { eids: [entry] } }) + ); + + expect(JSON.parse(String(result.data)).eids, 'the body keeps the entry').toEqual([entry]); + expect(readTsEidsCookie(), 'the cookie should be cleared').toBeUndefined(); + expect(debugSpy).toHaveBeenCalledWith('[tsjs-prebid] forwarding bidder EIDs', { + forwarded: 1, + unserializable: 0, + overBudget: 0, + cookie: 'cleared', + }); + }); + + describe('when document.cookie writes throw', () => { + function withBlockedCookieWrites(run: () => void): void { + const descriptor = Object.getOwnPropertyDescriptor(Document.prototype, 'cookie')!; + Object.defineProperty(document, 'cookie', { + configurable: true, + get: () => descriptor.get!.call(document), + set: () => { + throw new Error('cookies blocked'); + }, + }); + try { + run(); + } finally { + delete (document as unknown as Record).cookie; + } + } + + it('still builds the request with the entry when the cookie write fails', () => { + vi.spyOn(log, 'warn').mockImplementation(() => {}); + const spec = getAdapterSpec(); + const entry = { source: 'liveramp.com', uids: [{ id: 'lr-uid', atype: 3 }] }; + + withBlockedCookieWrites(() => { + const result = spec.buildRequests( + trustedServerBidRequests(), + prebidBidderRequest({ ext: { eids: [entry] } }) + ); + + const payload = JSON.parse(String(result.data)); + expect(payload.adUnits[0].code, 'the request should still be built').toBe('div-gpt-1'); + expect(payload.eids, 'the body should carry the entry').toEqual([entry]); + }); + }); + + it('still builds the request without eids when the cookie clear fails', () => { + vi.spyOn(log, 'warn').mockImplementation(() => {}); + const spec = getAdapterSpec(); + + withBlockedCookieWrites(() => { + const result = spec.buildRequests(trustedServerBidRequests(), prebidBidderRequest({})); + + const payload = JSON.parse(String(result.data)); + expect(payload.adUnits[0].code, 'the request should still be built').toBe('div-gpt-1'); + expect(payload.eids, 'the body should carry no eids').toBeUndefined(); + }); + }); + }); + + it('sends no eids and leaves ts-eids alone without a bidder request object', () => { + const debugSpy = vi.spyOn(log, 'debug').mockImplementation(() => {}); + const spec = getAdapterSpec(); + presetTsEidsCookie('preset-value'); + + const result = spec.buildRequests(trustedServerBidRequests()); + + expect(JSON.parse(String(result.data)).adUnits[0].code).toBe('div-gpt-1'); + expect(JSON.parse(String(result.data)).eids).toBeUndefined(); + expect(readTsEidsCookie(), 'the cookie should be unchanged').toBe('preset-value'); + expect( + debugSpy.mock.calls.some( + ([message]) => typeof message === 'string' && message.includes('no bidder request') + ), + 'should explain at debug level' + ).toBe(true); + }); + + it('lets the later of two overlapping requests own ts-eids', () => { + const spec = getAdapterSpec(); + const first = { source: 'first.example.com', uids: [{ id: 'first-uid' }] }; + const second = { source: 'second.example.com', uids: [{ id: 'second-uid' }] }; + + spec.buildRequests( + trustedServerBidRequests(), + prebidBidderRequest({ ext: { eids: [first] } }) + ); + spec.buildRequests( + trustedServerBidRequests(), + prebidBidderRequest({ ext: { eids: [second] } }) + ); + + expect(decodedTsEidsCookie()).toEqual([second]); + }); + + it('forwards ortb2 EIDs and writes ts-eids without the User ID module', () => { + const spec = getAdapterSpec(); + const original = mockPbjs.getUserIdsAsEids; + mockPbjs.getUserIdsAsEids = undefined as unknown as typeof mockGetUserIdsAsEids; + try { + const lockrId5 = { + source: 'id5-sync.com', + inserter: 'inserter.example.com', + mm: 2, + uids: [{ id: 'fictional-id5', atype: 1 }], + }; + + const result = spec.buildRequests( + trustedServerBidRequests(), + prebidBidderRequest({ ext: { eids: [lockrId5] } }) + ); + + expect(JSON.parse(String(result.data)).eids).toEqual([lockrId5]); + expect(decodedTsEidsCookie()).toEqual([lockrId5]); + } finally { + mockPbjs.getUserIdsAsEids = original; + } + }); + + it('logs EID counts and the cookie decision at debug level, never values', () => { + log.setLevel('debug'); + const spies = [ + vi.spyOn(log, 'debug').mockImplementation(() => {}), + vi.spyOn(log, 'info').mockImplementation(() => {}), + vi.spyOn(log, 'warn').mockImplementation(() => {}), + vi.spyOn(log, 'error').mockImplementation(() => {}), + ]; + try { + const spec = getAdapterSpec(); + + spec.buildRequests( + trustedServerBidRequests(), + prebidBidderRequest({ + ext: { + eids: [{ source: 'ids.example.com', uids: [{ id: 'secret-uid-value' }] }], }, - ], - }, - ]); + }) + ); + + expect(spies[0]).toHaveBeenCalledWith('[tsjs-prebid] forwarding bidder EIDs', { + forwarded: 1, + unserializable: 0, + overBudget: 0, + cookie: 'written', + }); + const logged = spies.flatMap((spy) => spy.mock.calls).flat(); + expect( + logged.some((value) => String(JSON.stringify(value)).includes('secret-uid-value')), + 'no log argument may contain a UID value' + ).toBe(false); + } finally { + log.setLevel('warn'); + } + }); + + it('keeps the trim log when the cookie had to drop sources', () => { + const debugSpy = vi.spyOn(log, 'debug').mockImplementation(() => {}); + const spec = getAdapterSpec(); + const long = 'x'.repeat(400); + const entries = [0, 1, 2, 3, 4, 5, 6].map((index) => ({ + source: `s${index}.example.com`, + uids: [{ id: long, atype: 1 }], + })); + + const result = spec.buildRequests( + trustedServerBidRequests(), + prebidBidderRequest({ ext: { eids: entries } }) + ); + + expect(JSON.parse(String(result.data)).eids, 'the body keeps every entry').toEqual(entries); + expect(decodedTsEidsCookie(), 'the cookie keeps what fits').toEqual(entries.slice(0, 5)); + expect(debugSpy).toHaveBeenCalledWith( + '[tsjs-prebid] ts-eids cookie exceeded 3072 bytes; dropped sources: s6.example.com, s5.example.com' + ); + expect(debugSpy).toHaveBeenCalledWith('[tsjs-prebid] forwarding bidder EIDs', { + forwarded: 7, + unserializable: 0, + overBudget: 0, + cookie: 'trimmed', + }); }); it('buildRequests uses custom endpoint when configured', () => { @@ -2739,6 +3050,66 @@ describe('prebid/installPrebidNpm', () => { }); }); + it('exposes the getUserIdsAsEids canary on the pbjs the shim uses', () => { + const pbjs = installPrebidNpm() as unknown as { + getUserIdsAsEids?: () => unknown; + }; + + expect(pbjs.getUserIdsAsEids).toBe(mockGetUserIdsAsEids); + expect(pbjs.getUserIdsAsEids?.()).toEqual([CANARY_EID]); + }); + + function tcfEnforcementHandler(): (event: unknown) => void { + installPrebidNpm(); + const registration = mockOnEvent.mock.calls.find(([event]) => event === 'tcf2Enforcement'); + expect(registration, 'the shim should listen for tcf2Enforcement').toBeDefined(); + return registration![1] as (event: unknown) => void; + } + + it.each([ + ['biddersBlocked', { biddersBlocked: ['trustedServer'], eidsBlocked: [] }], + ['eidsBlocked', { biddersBlocked: [], eidsBlocked: ['trustedServer'] }], + ])('clears ts-eids when tcf2Enforcement lists trustedServer in %s', (_list, blocked) => { + const handler = tcfEnforcementHandler(); + presetTsEidsCookie('preset-value'); + + handler({ + storageBlocked: [], + analyticsBlocked: [], + ufpdBlocked: [], + geoBlocked: [], + ...blocked, + }); + + expect(readTsEidsCookie()).toBeUndefined(); + }); + + it('leaves ts-eids alone when tcf2Enforcement lists only other bidders', () => { + const handler = tcfEnforcementHandler(); + presetTsEidsCookie('preset-value'); + + handler({ biddersBlocked: ['appnexus'], eidsBlocked: ['appnexus'] }); + + expect(readTsEidsCookie()).toBe('preset-value'); + }); + + it('leaves ts-eids alone when an auction builds no trustedServer request', () => { + mockRequestBids.mockImplementation((opts?: { bidsBackHandler?: () => void }) => { + opts?.bidsBackHandler?.(); + }); + presetTsEidsCookie('preset-value'); + const pbjs = installPrebidNpm(); + + pbjs.requestBids({ + adUnits: [{ bids: [{ bidder: 'appnexus', params: {} }] }], + } as unknown as RequestBidsArg); + + expect(mockRequestBids, 'the auction should have run').toHaveBeenCalled(); + expect(readTsEidsCookie(), 'only buildRequests may write or clear the cookie').toBe( + 'preset-value' + ); + }); + describe('requestBids shim', () => { beforeEach(() => { testWindow.__tsjs_prebid = { @@ -3156,202 +3527,6 @@ describe('prebid/installPrebidNpm', () => { ); expect(hasTsBidder).toBe(true); }); - - it('syncs a structured ts-eids cookie after bidsBackHandler', () => { - mockRequestBids.mockImplementation((opts?: { bidsBackHandler?: () => void }) => { - opts?.bidsBackHandler?.(); - }); - mockGetUserIdsAsEids.mockReturnValue([ - { - source: 'sharedid.org', - uids: [ - { id: 'shared_123', atype: 3 }, - { id: 'shared_456', ext: { provider: 'example' } }, - ], - }, - ]); - - const pbjs = installPrebidNpm(); - pbjs.requestBids({ - adUnits: [{ bids: [{ bidder: 'appnexus', params: {} }] }], - } as unknown as RequestBidsArg); - - const cookieValue = document.cookie.match(/(?:^|; )ts-eids=([^;]+)/)?.[1]; - expect(cookieValue).toBeDefined(); - expect(JSON.parse(atob(cookieValue!))).toEqual([ - { - source: 'sharedid.org', - uids: [ - { id: 'shared_123', atype: 3 }, - { id: 'shared_456', ext: { provider: 'example' } }, - ], - }, - ]); - }); - - it('preserves an opaque LiveRamp envelope in the ts-eids cookie', () => { - mockRequestBids.mockImplementation((opts?: { bidsBackHandler?: () => void }) => { - opts?.bidsBackHandler?.(); - }); - mockGetUserIdsAsEids.mockReturnValue([ - { - source: 'liveramp.com', - uids: [{ id: 'opaque-test-envelope', atype: 3 }], - }, - ]); - - const pbjs = installPrebidNpm(); - pbjs.requestBids({ - adUnits: [{ bids: [{ bidder: 'appnexus', params: {} }] }], - } as unknown as RequestBidsArg); - - const cookieValue = document.cookie.match(/(?:^|; )ts-eids=([^;]+)/)?.[1]; - expect(cookieValue).toBeDefined(); - expect(JSON.parse(atob(cookieValue!))).toEqual([ - { - source: 'liveramp.com', - uids: [{ id: 'opaque-test-envelope', atype: 3 }], - }, - ]); - }); - - it('trims an oversized ts-eids cookie deterministically and logs which sources were dropped', () => { - const debugSpy = vi.spyOn(log, 'debug').mockImplementation(() => {}); - const warnSpy = vi.spyOn(log, 'warn').mockImplementation(() => {}); - mockRequestBids.mockImplementation((opts?: { bidsBackHandler?: () => void }) => { - opts?.bidsBackHandler?.(); - }); - // A long uid value per source makes the encoded payload exceed - // MAX_EID_COOKIE_BYTES (3072) once several sources are present, the - // same way a real page with many ID modules would. - const longUid = 'x'.repeat(400); - mockGetUserIdsAsEids.mockReturnValue([ - { source: 'id5-sync.com', uids: [{ id: longUid, atype: 1 }] }, - { source: 'liveramp.com', uids: [{ id: longUid, atype: 3 }] }, - { source: 'criteo.com', uids: [{ id: longUid, atype: 1 }] }, - { source: 'uidapi.com', uids: [{ id: longUid, atype: 3 }] }, - { source: 'pubcid.org', uids: [{ id: longUid, atype: 1 }] }, - { source: 'sharedid.org', uids: [{ id: longUid, atype: 1 }] }, - { source: 'dropped.example', uids: [{ id: longUid, atype: 1 }] }, - ]); - - const pbjs = installPrebidNpm(); - pbjs.requestBids({ - adUnits: [{ bids: [{ bidder: 'appnexus', params: {} }] }], - } as unknown as RequestBidsArg); - - const cookieValue = document.cookie.match(/(?:^|; )ts-eids=([^;]+)/)?.[1]; - expect(cookieValue).toBeDefined(); - expect(cookieValue!.length).toBeLessThanOrEqual(3072); - - const persistedSources = (JSON.parse(atob(cookieValue!)) as Array<{ source: string }>).map( - (eid) => eid.source - ); - expect(persistedSources).not.toContain('dropped.example'); - expect(persistedSources).toContain('id5-sync.com'); - - const loggedMessage = debugSpy.mock.calls - .map((call) => call.at(-1)) - .find( - (arg): arg is string => typeof arg === 'string' && arg.includes('ts-eids cookie exceeded') - ); - expect(loggedMessage).toBeDefined(); - expect(loggedMessage).toContain('dropped.example'); - expect(warnSpy).not.toHaveBeenCalledWith(expect.stringContaining('ts-eids cookie exceeded')); - }); - - it('logs a source whose UIDs were partially trimmed but retained', () => { - const debugSpy = vi.spyOn(log, 'debug').mockImplementation(() => {}); - mockRequestBids.mockImplementation((opts?: { bidsBackHandler?: () => void }) => { - opts?.bidsBackHandler?.(); - }); - // A single source with many UIDs is trimmed down to fit rather than - // dropped outright — it stays in the payload, but with fewer UIDs. - const longUid = 'x'.repeat(400); - mockGetUserIdsAsEids.mockReturnValue([ - { - source: 'id5-sync.com', - uids: Array.from({ length: 10 }, (_, i) => ({ id: `${longUid}${i}`, atype: 1 })), - }, - ]); - - const pbjs = installPrebidNpm(); - pbjs.requestBids({ - adUnits: [{ bids: [{ bidder: 'appnexus', params: {} }] }], - } as unknown as RequestBidsArg); - - const cookieValue = document.cookie.match(/(?:^|; )ts-eids=([^;]+)/)?.[1]; - expect(cookieValue).toBeDefined(); - - const persisted = JSON.parse(atob(cookieValue!)) as Array<{ - source: string; - uids: unknown[]; - }>; - expect(persisted.map((eid) => eid.source)).toContain('id5-sync.com'); - const persistedSource = persisted.find((eid) => eid.source === 'id5-sync.com'); - expect(persistedSource!.uids.length).toBeLessThan(10); - - const loggedMessage = debugSpy.mock.calls - .map((call) => call.at(-1)) - .find( - (arg): arg is string => typeof arg === 'string' && arg.includes('ts-eids cookie exceeded') - ); - expect(loggedMessage).toBeDefined(); - expect(loggedMessage).toContain('trimmed uids from sources: id5-sync.com'); - }); - - it('reports a source trimmed and then dropped only as dropped', () => { - const debugSpy = vi.spyOn(log, 'debug').mockImplementation(() => {}); - mockRequestBids.mockImplementation((opts?: { bidsBackHandler?: () => void }) => { - opts?.bidsBackHandler?.(); - }); - // The tail source has several UIDs that are each too large to fit: the - // trimmer first removes UIDs from it, then drops it entirely. - const hugeUid = 'y'.repeat(2500); - mockGetUserIdsAsEids.mockReturnValue([ - { source: 'id5-sync.com', uids: [{ id: 'kept', atype: 1 }] }, - { - source: 'dropped.example', - uids: Array.from({ length: 3 }, (_, i) => ({ id: `${hugeUid}${i}`, atype: 1 })), - }, - ]); - - const pbjs = installPrebidNpm(); - pbjs.requestBids({ - adUnits: [{ bids: [{ bidder: 'appnexus', params: {} }] }], - } as unknown as RequestBidsArg); - - const cookieValue = document.cookie.match(/(?:^|; )ts-eids=([^;]+)/)?.[1]; - expect(cookieValue).toBeDefined(); - const persistedSources = (JSON.parse(atob(cookieValue!)) as Array<{ source: string }>).map( - (eid) => eid.source - ); - expect(persistedSources).toEqual(['id5-sync.com']); - - const loggedMessage = debugSpy.mock.calls - .map((call) => call.at(-1)) - .find( - (arg): arg is string => typeof arg === 'string' && arg.includes('ts-eids cookie exceeded') - ); - expect(loggedMessage).toBeDefined(); - expect(loggedMessage).toContain('dropped sources: dropped.example'); - expect(loggedMessage).not.toContain('trimmed uids from sources'); - }); - - it('clears ts-eids cookie after bidsBackHandler when no current EIDs remain', () => { - document.cookie = `ts-eids=${btoa(JSON.stringify([{ source: 'sharedid.org', uids: [{ id: 'stale' }] }]))}`; - mockRequestBids.mockImplementation((opts?: { bidsBackHandler?: () => void }) => { - opts?.bidsBackHandler?.(); - }); - mockGetUserIdsAsEids.mockReturnValue([]); - - const pbjs = installPrebidNpm(); - pbjs.requestBids({ - adUnits: [{ bids: [{ bidder: 'appnexus', params: {} }] }], - } as unknown as RequestBidsArg); - - expect(document.cookie).toBe(''); - }); }); }); @@ -3361,7 +3536,7 @@ describe('prebid/installPrebidNpm with server-injected config', () => { mockPbjs.requestBids = mockRequestBids; mockPbjs.adUnits = []; mockGetUserIdsAsEids.mockReset(); - mockGetUserIdsAsEids.mockReturnValue([]); + mockGetUserIdsAsEids.mockReturnValue([CANARY_EID]); document.cookie = 'ts-eids=; Path=/; Max-Age=0'; delete testWindow.__tsjs_prebid; }); @@ -4463,7 +4638,7 @@ describe('prebid publisher snapshots and delivery refreshes', () => { delete (mockPbjs as unknown as Record).__tsRemoveAdUnitWrapped; mockPbjs.adUnits = []; mockGetUserIdsAsEids.mockReset(); - mockGetUserIdsAsEids.mockReturnValue([]); + mockGetUserIdsAsEids.mockReturnValue([CANARY_EID]); // By default the manifest declares all adapters compiled in. (window as unknown as { __tsjs_prebid_bundle?: unknown }).__tsjs_prebid_bundle = DEFAULT_BUNDLE_MANIFEST; @@ -7550,7 +7725,7 @@ describe('prebid/client-side bidders', () => { mockPbjs.requestBids = mockRequestBids; mockPbjs.adUnits = []; mockGetUserIdsAsEids.mockReset(); - mockGetUserIdsAsEids.mockReturnValue([]); + mockGetUserIdsAsEids.mockReturnValue([CANARY_EID]); // By default the manifest declares all adapters compiled in. testWindow.__tsjs_prebid_bundle = DEFAULT_BUNDLE_MANIFEST; delete testWindow.__tsjs_prebid; @@ -8046,10 +8221,11 @@ describe('prebid/self-init without the external bundle', () => { }); }); -describe('prebid self-init user ID module timing', () => { - const userSyncCallCount = () => - mockSetConfig.mock.calls.filter(([arg]) => arg && typeof arg === 'object' && 'userSync' in arg) - .length; +describe('prebid self-init writes no Prebid userSync settings', () => { + const userSyncCalls = () => + mockSetConfig.mock.calls.filter( + ([arg]) => arg !== null && typeof arg === 'object' && 'userSync' in arg + ); const setReadyState = (value: DocumentReadyState) => { Object.defineProperty(document, 'readyState', { value, configurable: true }); @@ -8064,28 +8240,26 @@ describe('prebid self-init user ID module timing', () => { setReadyState('complete'); }); - it('installs user ID modules immediately when the bundle loads after window load', async () => { - // The GPT slim loader appends this bundle from a window.load handler, so - // the document is already complete — a load listener would never fire. + it('writes no userSync when the bundle loads after window load', async () => { setReadyState('complete'); await import('../../../src/integrations/prebid/index'); - expect(userSyncCallCount()).toBeGreaterThan(0); + expect(mockSetConfig, 'the shim should still apply its own config').toHaveBeenCalledWith({ + debug: false, + }); + expect(userSyncCalls(), 'TS should leave userSync to the publisher and Prebid').toEqual([]); }); - it('defers user ID modules to window load when the document is still loading', async () => { + it('writes no userSync when window load fires after install', async () => { setReadyState('loading'); await import('../../../src/integrations/prebid/index'); - - expect(userSyncCallCount()).toBe(0); - window.dispatchEvent(new Event('load')); - expect(userSyncCallCount()).toBe(1); - // { once: true } — a second load event must not reinstall. - window.dispatchEvent(new Event('load')); - expect(userSyncCallCount()).toBe(1); + expect(mockSetConfig, 'the shim should still apply its own config').toHaveBeenCalledWith({ + debug: false, + }); + expect(userSyncCalls(), 'TS should leave userSync to the publisher and Prebid').toEqual([]); }); }); diff --git a/crates/trusted-server-js/lib/test/integrations/prebid/user_id_modules.test.ts b/crates/trusted-server-js/lib/test/integrations/prebid/user_id_modules.test.ts index 06c1c33d87..3467f2b200 100644 --- a/crates/trusted-server-js/lib/test/integrations/prebid/user_id_modules.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/prebid/user_id_modules.test.ts @@ -2,128 +2,21 @@ import { describe, expect, it } from 'vitest'; import { knownUserIdConfigNames, - resolvePrebidUserIdModulesFromEids, userIdSubmoduleKey, } from '../../../src/integrations/prebid/user_id_modules'; import registry from '../../../src/integrations/prebid/user_id_modules.json'; -const sampleEids = [ - { source: 'yahoo.com', uids: [{ id: 'connect-id', atype: 3 }] }, - { source: 'criteo.com', uids: [{ id: 'criteo-id', atype: 1 }] }, - { source: 'liveintent.com', uids: [{ id: 'liveintent-id', atype: 3 }] }, - { - source: 'bidswitch.net', - uids: [{ id: 'bidswitch-id', atype: 3, ext: { provider: 'liveintent.com' } }], - }, - { - source: 'liveintent.triplelift.com', - uids: [{ id: 'triplelift-id', atype: 3, ext: { provider: 'liveintent.com' } }], - }, - { - source: 'rubiconproject.com', - uids: [{ id: 'rubicon-id', atype: 3, ext: { provider: 'liveintent.com' } }], - }, - { - source: 'liveintent.indexexchange.com', - uids: [{ id: 'index-id', atype: 3, ext: { provider: 'liveintent.com' } }], - }, - { - source: 'openx.net', - uids: [{ id: 'openx-id', atype: 3, ext: { provider: 'liveintent.com' } }], - }, - { - source: 'pubmatic.com', - uids: [{ id: 'pubmatic-id', atype: 3, ext: { provider: 'liveintent.com' } }], - }, - { - source: 'liveintent.sovrn.com', - uids: [{ id: 'sovrn-id', atype: 3, ext: { provider: 'liveintent.com' } }], - }, - { - source: 'liveintent.unrulymedia.com', - uids: [{ id: 'unruly-id', atype: 3, ext: { provider: 'liveintent.com' } }], - }, - { source: 'pubcid.org', uids: [{ id: 'pubcid-id', atype: 1 }] }, - { - source: 'adserver.org', - uids: [{ id: 'tdid', atype: 1, ext: { rtiPartner: 'TDID' } }], - inserter: 'adserver.org', - matcher: 'adserver.org', - mm: 4, - }, -]; - describe('prebid user ID module registry', () => { - it('resolves the target-site EID sample to deterministic Prebid user ID modules', () => { - const result = resolvePrebidUserIdModulesFromEids(sampleEids); - - expect(result).toEqual({ - modules: [ - 'userId', - 'connectIdSystem', - 'criteoIdSystem', - 'liveIntentIdSystem', - 'sharedIdSystem', - 'unifiedIdSystem', - ], - missingSources: [], - }); - }); - it('exposes config names for modules that do not map EID sources', () => { expect(knownUserIdConfigNames()).toEqual( expect.arrayContaining(['lockrAIMId', 'pubProvidedId']) ); }); - it('maps the Google PAIR EID source to pairIdSystem', () => { - const result = resolvePrebidUserIdModulesFromEids([ - { source: 'google.com', uids: [{ id: 'pair-id' }] }, - ]); - - expect(result).toEqual({ - modules: ['userId', 'pairIdSystem'], - missingSources: [], - }); - }); - - it('maps LiveRamp EIDs to identityLinkIdSystem', () => { - expect( - resolvePrebidUserIdModulesFromEids([ - { source: 'liveramp.com', uids: [{ id: 'opaque-envelope', atype: 3 }] }, - ]) - ).toEqual({ - modules: ['userId', 'identityLinkIdSystem'], - missingSources: [], - }); - }); - it('includes identityLinkIdSystem in the checked-in default preset', () => { expect(registry.defaultPreset).toContain('identityLinkIdSystem'); }); - it('maps unknown LiveIntent provider-backed sources to liveIntentIdSystem', () => { - const result = resolvePrebidUserIdModulesFromEids([ - { - source: 'new-liveintent-partner.example', - uids: [{ ext: { provider: 'liveintent.com' } }], - }, - ]); - - expect(result).toEqual({ - modules: ['userId', 'liveIntentIdSystem'], - missingSources: [], - }); - }); - - it('reports unknown non-LiveIntent sources', () => { - const result = resolvePrebidUserIdModulesFromEids([ - { source: 'unknown.example', uids: [{ id: 'abc' }] }, - ]); - - expect(result).toEqual({ modules: [], missingSources: ['unknown.example'] }); - }); - it('keys every alias of one module on the same submodule', () => { expect(userIdSubmoduleKey('pubCommonId')).toBe(userIdSubmoduleKey('sharedId')); expect(userIdSubmoduleKey('SHAREDID')).toBe(userIdSubmoduleKey('sharedId')); @@ -136,6 +29,14 @@ describe('prebid user ID module registry', () => { expect(userIdSubmoduleKey('NotARegisteredModule')).toBe('notaregisteredmodule'); }); + it('carries no EID source metadata', () => { + expect(registry.modules.length, 'the registry should still list modules').toBeGreaterThan(0); + expect( + registry.modules.filter((entry) => 'eidSources' in entry).map((entry) => entry.moduleName), + 'no entry should keep eidSources' + ).toEqual([]); + }); + it('keeps submodule keys and config names unique across registry entries', () => { // Actual registrations are checked by prebid-user-id-registry.test.mjs; // these checks prevent separate entries from sharing an ownership key. diff --git a/crates/trusted-server-js/lib/test/prebid-artifact-integration.test.mjs b/crates/trusted-server-js/lib/test/prebid-artifact-integration.test.mjs index 24db647d31..8d152d6ec5 100644 --- a/crates/trusted-server-js/lib/test/prebid-artifact-integration.test.mjs +++ b/crates/trusted-server-js/lib/test/prebid-artifact-integration.test.mjs @@ -307,10 +307,13 @@ describe('tsjs-prebid production artifacts', () => { expect(analyticsArtifact.bundleCode.length).toBeGreaterThan(200_000); // A value-import of Prebid or a private rendering helper would multiply // the shim size. The bound sits just above the normal compact shim output, - // which is roughly 40 KB: tight enough that material growth has to be + // which is roughly 42 KB: it carries the bidder-request EID collector, the + // ts-eids cookie fitting and encoding, and the tcf2Enforcement listener, + // which replaced the getUserIdsAsEids collector and the bidsBackHandler + // cookie sync. The bound is tight enough that material growth has to be // noticed and re-justified here, and far enough below a multiplication // that one still fails loudly. The bundle beside it is 200 KB and up. - expect(shimCode.length).toBeLessThan(41_000); + expect(shimCode.length).toBeLessThan(42_500); expect(shimCode).toContain('markWinningBidAsUsed'); }); diff --git a/crates/trusted-server-js/lib/test/prebid-eid-fidelity.test.mjs b/crates/trusted-server-js/lib/test/prebid-eid-fidelity.test.mjs new file mode 100644 index 0000000000..b731cfa70e --- /dev/null +++ b/crates/trusted-server-js/lib/test/prebid-eid-fidelity.test.mjs @@ -0,0 +1,520 @@ +// @vitest-environment node + +// Real Prebid.js arms for the trustedServer EID contract (spec 7.5). Unit +// tests mock window.pbjs; this suite evaluates the generated external bundle +// and the served shim together in a JSDOM page and captures /auction bodies. +// Every arm except the D4 control fails against a shim built from main. + +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { JSDOM, requestInterceptor } from 'jsdom'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; + +import { moduleBuildOptions } from '../build-module-options.mjs'; +import { main } from '../build-prebid-external.mjs'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const libDir = path.resolve(__dirname, '..'); + +const PAGE_ORIGIN = 'https://pub.example.com'; +const HOLD_LOAD_PATH = '/hold-window-load.css'; +const UNIFIED_ID_PATH = '/unified-id'; +const UNIFIED_ID_DELAY_MS = 200; +const IDENTITY_LINK_GVL_ID = 97; + +const CONTROL_EID = { + source: 'control.example.com', + uids: [{ id: 'control-uid', atype: 1 }], +}; +const PRESET_COOKIE_EIDS = [{ source: 'preset.example.com', uids: [{ id: 'preset-uid' }] }]; +const LIVERAMP_EID = { + source: 'liveramp.com', + uids: [{ id: 'fictional-idl-envelope', atype: 3 }], +}; +const UNIFIED_ID_EID = { + source: 'adserver.org', + inserter: 'adserver.org', + matcher: 'adserver.org', + mm: 4, + uids: [{ id: 'fictional-tdid', atype: 1, ext: { rtiPartner: 'TDID' } }], +}; +// The only safe form of a trustedServer TCF exception (spec 3.7). +const FULL_BASIC_ADS_RULE = { + purpose: 'basicAds', + enforcePurpose: true, + enforceVendor: true, + softVendorExceptions: ['trustedServer'], +}; + +let outputDirectory; +let bundleCode; +let shimCode; + +beforeAll(async () => { + outputDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'trusted-server-prebid-eids-')); + await main([ + '--modules-json', + JSON.stringify({ + bidder: ['appnexusBidAdapter'], + userId: ['sharedIdSystem', 'unifiedIdSystem', 'identityLinkIdSystem'], + }), + '--out', + outputDirectory, + ]); + const manifest = JSON.parse(fs.readFileSync(path.join(outputDirectory, 'manifest.json'), 'utf8')); + bundleCode = fs.readFileSync(path.join(outputDirectory, manifest.filename), 'utf8'); + + const { build } = await import('vite'); + await build( + moduleBuildOptions({ + name: 'prebid', + entryPath: path.join(libDir, 'src', 'integrations', 'prebid', 'index.ts'), + outDir: outputDirectory, + }) + ); + shimCode = fs.readFileSync(path.join(outputDirectory, 'tsjs-prebid.js'), 'utf8'); +}, 240_000); + +afterAll(() => { + fs.rmSync(outputDirectory, { recursive: true, force: true }); +}); + +function tcfData(vendorConsents, purposeOneConsent) { + return { + gdprApplies: true, + tcString: 'CPexampleTCStringForTests', + eventStatus: 'tcloaded', + cmpStatus: 'loaded', + apiVersion: '2', + purpose: { + consents: { 1: purposeOneConsent, 2: true, 3: true, 4: true }, + legitimateInterests: {}, + }, + vendor: { consents: vendorConsents, legitimateInterests: {} }, + publisher: { restrictions: {} }, + specialFeatureOptins: {}, + }; +} + +/** + * A CMP that answers at once: Purposes 2 to 4 consented, Purpose 1 as given, + * the given vendors only. + */ +function tcfApi(vendorConsents, purposeOneConsent) { + let nextListenerId = 1; + return (command, _version, callback) => { + if (command === 'addEventListener') { + callback( + { ...tcfData(vendorConsents, purposeOneConsent), listenerId: nextListenerId++ }, + true + ); + } else if (command === 'getTCData') { + callback(tcfData(vendorConsents, purposeOneConsent), true); + } else if (command === 'removeEventListener') { + callback(true, true); + } + }; +} + +/** + * A publisher page whose window.load is held by a stylesheet until + * releaseLoad(). Every fetch goes to a spy, installed before the bundle is + * evaluated, that records /auction bodies and answers the unifiedId endpoint + * after UNIFIED_ID_DELAY_MS. + */ +function createPage({ cookies = [], vendorConsents, purposeOneConsent = true } = {}) { + let releaseHeldLoad; + const loadHeld = new Promise((resolve) => { + releaseHeldLoad = resolve; + }); + const dom = new JSDOM( + ``, + { + url: `${PAGE_ORIGIN}/article`, + runScripts: 'outside-only', + pretendToBeVisual: true, + resources: { + interceptors: [ + requestInterceptor(async (request) => { + if (new URL(request.url).pathname === HOLD_LOAD_PATH) { + await loadHeld; + return new Response('', { + status: 200, + headers: { 'Content-Type': 'text/css' }, + }); + } + return new Response('', { status: 204 }); + }), + ], + }, + } + ); + const pageWindow = dom.window; + + // jsdom drops Secure cookies on non-HTTPS origins (spec 7.1 rule 5). + pageWindow.document.cookie = 'ts-eids-probe=1; Path=/; Secure; SameSite=Lax'; + expect(pageWindow.document.cookie, 'Secure cookies should work on the page origin').toContain( + 'ts-eids-probe=1' + ); + pageWindow.document.cookie = 'ts-eids-probe=; Path=/; Max-Age=0'; + for (const cookie of cookies) { + pageWindow.document.cookie = cookie; + } + + const auctionBodies = []; + pageWindow.fetch = vi.fn(async (resource, init) => { + const url = new URL(typeof resource === 'string' ? resource : resource.url, PAGE_ORIGIN); + if (url.pathname === '/auction') { + const body = + init?.body ?? (typeof resource === 'object' ? await resource.clone().text() : undefined); + auctionBodies.push(JSON.parse(body)); + } + if (url.pathname === UNIFIED_ID_PATH) { + await new Promise((resolve) => setTimeout(resolve, UNIFIED_ID_DELAY_MS)); + return new Response(JSON.stringify({ TDID: 'fictional-tdid' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + return new Response('{}', { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }); + pageWindow.Request = class PageRequest extends Request { + constructor(resource, init) { + super(typeof resource === 'string' ? new URL(resource, PAGE_ORIGIN).href : resource, init); + } + }; + pageWindow.Headers = Headers; + pageWindow.Response = Response; + pageWindow.AbortController = AbortController; + if (!('isSecureContext' in pageWindow)) { + pageWindow.isSecureContext = true; + } + if (vendorConsents) { + pageWindow.__tcfapi = tcfApi(vendorConsents, purposeOneConsent); + } + + // Mirror the server's head-injected state. + pageWindow.eval('window.pbjs = { que: [], cmd: [] };'); + pageWindow.__tsjs_prebid = { clientSideBidders: [], serverSideBidders: [] }; + + return { + pageWindow, + auctionBodies, + install() { + pageWindow.eval(bundleCode); + pageWindow.eval(shimCode); + }, + async releaseLoad() { + releaseHeldLoad(); + await vi.waitFor(() => expect(pageWindow.document.readyState).toBe('complete')); + }, + async close() { + releaseHeldLoad(); + // Let in-flight User ID work settle before jsdom tears down. + await new Promise((resolve) => setTimeout(resolve, UNIFIED_ID_DELAY_MS + 100)); + pageWindow.close(); + }, + }; +} + +function readTsEidsCookie(pageWindow) { + const value = pageWindow.document.cookie.match(/(?:^|; )ts-eids=([^;]*)/)?.[1]; + if (!value) { + return undefined; + } + return JSON.parse( + new TextDecoder().decode(Uint8Array.from(atob(value), (char) => char.charCodeAt(0))) + ); +} + +function presetTsEidsCookie() { + return `ts-eids=${btoa(JSON.stringify(PRESET_COOKIE_EIDS))}; Path=/; Secure; SameSite=Lax`; +} + +function requestBids(pageWindow, adUnits) { + return new Promise((resolve) => { + pageWindow.pbjs.requestBids({ + adUnits, + timeout: 1000, + bidsBackHandler: () => resolve(), + }); + }); +} + +/** An ad unit with no publisher bidders: the shim adds only trustedServer. */ +function trustedServerAdUnits() { + return [ + { + code: 'ad-slot-1', + mediaTypes: { banner: { sizes: [[300, 250]] } }, + bids: [], + }, + ]; +} + +/** An ad unit with a client-side bidder that has a GVL ID (appnexus, 32). */ +function clientBidderAdUnits() { + return [ + { + code: 'ad-slot-1', + mediaTypes: { banner: { sizes: [[300, 250]] } }, + bids: [{ bidder: 'appnexus', params: { placementId: 1 } }], + }, + ]; +} + +function tcfEnforcementResults(pageWindow) { + return pageWindow.pbjs + .getEvents() + .filter((event) => event.eventType === 'tcf2Enforcement') + .map((event) => event.args); +} + +describe('trustedServer EIDs with real Prebid.js', () => { + it('D1: never forwards an ID restricted to another bidder', async () => { + const page = createPage({ + cookies: ['_sharedid=fictional-shared-id; Path=/'], + }); + try { + page.install(); + expect(page.pageWindow.document.readyState, 'install runs before load').toBe('loading'); + page.pageWindow.pbjs.setConfig({ + userSync: { + auctionDelay: 300, + userIds: [ + { + name: 'sharedId', + bidders: ['appnexus'], + storage: { type: 'cookie', name: '_sharedid', expires: 30 }, + }, + ], + }, + ortb2: { user: { ext: { eids: [CONTROL_EID] } } }, + }); + + await requestBids(page.pageWindow, trustedServerAdUnits()); + expect(page.pageWindow.document.readyState, 'the auction runs before load').toBe('loading'); + + expect( + page.pageWindow.pbjs.getUserIdsAsEids().map((eid) => eid.source), + 'precondition: the restricted module produced its ID' + ).toContain('pubcid.org'); + expect( + page.auctionBodies.map((body) => body.eids), + 'the /auction body should carry the control and not the restricted ID' + ).toEqual([[CONTROL_EID]]); + expect(readTsEidsCookie(page.pageWindow), 'ts-eids should copy the body set').toEqual([ + CONTROL_EID, + ]); + } finally { + await page.close(); + } + }, 60_000); + + it('D3: forwards same-source entries separately with their own EID-level fields', async () => { + const first = { + source: 'ids.example.com', + inserter: 'inserter-a.example.com', + mm: 2, + ext: { segments: ['s1'] }, + uids: [{ id: 'ids-uid', atype: 1 }], + }; + const second = { + source: 'ids.example.com', + inserter: 'inserter-b.example.com', + matcher: 'matcher-b.example.com', + mm: 3, + uids: [{ id: 'ids-uid', atype: 1 }], + }; + const page = createPage(); + try { + page.install(); + expect(page.pageWindow.document.readyState, 'install runs before load').toBe('loading'); + page.pageWindow.pbjs.setConfig({ + ortb2: { user: { ext: { eids: [first, second] } } }, + }); + + await requestBids(page.pageWindow, trustedServerAdUnits()); + expect(page.pageWindow.document.readyState, 'the auction runs before load').toBe('loading'); + + expect(page.auctionBodies.map((body) => body.eids)).toEqual([[first, second]]); + const { ext: _eidExt, ...firstWithoutExt } = first; + expect( + readTsEidsCookie(page.pageWindow), + 'ts-eids should hold both entries without EID-level ext' + ).toEqual([firstWithoutExt, second]); + } finally { + await page.close(); + } + }, 60_000); + + it('D4: the first auction after window.load waits for a pending User ID module', async () => { + const page = createPage(); + try { + page.install(); + expect(page.pageWindow.document.readyState, 'step 1 starts before load').toBe('loading'); + await page.releaseLoad(); + + page.pageWindow.pbjs.setConfig({ + userSync: { + userIds: [ + { + name: 'unifiedId', + params: { url: `${PAGE_ORIGIN}${UNIFIED_ID_PATH}` }, + }, + ], + }, + }); + expect(page.pageWindow.document.readyState, 'step 2 runs after load').toBe('complete'); + expect( + page.pageWindow.pbjs.getConfig('userSync').auctionDelay, + "Prebid's default auctionDelay should apply" + ).toBe(500); + + await requestBids(page.pageWindow, trustedServerAdUnits()); + + expect( + page.auctionBodies.map((body) => body.eids), + 'the first /auction should carry the delayed unifiedId' + ).toEqual([[UNIFIED_ID_EID]]); + } finally { + await page.close(); + } + }, 60_000); + + it('D4 control: the first auction before window.load waits, on every build', async () => { + const page = createPage(); + try { + page.install(); + page.pageWindow.pbjs.setConfig({ + userSync: { + userIds: [ + { + name: 'unifiedId', + params: { url: `${PAGE_ORIGIN}${UNIFIED_ID_PATH}` }, + }, + ], + }, + }); + expect(page.pageWindow.document.readyState, 'the auction runs before load').toBe('loading'); + + await requestBids(page.pageWindow, trustedServerAdUnits()); + + // A projection, not the full entry: main drops EID-level fields, and + // this control must pass on main and on the branch alike. + expect( + page.auctionBodies.map((body) => + (body.eids ?? []).map((eid) => [eid.source, eid.uids.map((uid) => uid.id)]) + ), + 'the spy and the module should deliver unifiedId to the first auction' + ).toEqual([[['adserver.org', ['fictional-tdid']]]]); + } finally { + await page.close(); + } + }, 60_000); + + async function runTcfPage({ withRule, purposeOneConsent = true }) { + const page = createPage({ + cookies: ['idl_env=fictional-idl-envelope; Path=/', presetTsEidsCookie()], + vendorConsents: { [IDENTITY_LINK_GVL_ID]: true }, + purposeOneConsent, + }); + page.install(); + expect(page.pageWindow.document.readyState, 'install runs before load').toBe('loading'); + expect(readTsEidsCookie(page.pageWindow), 'precondition: ts-eids is preset').toEqual( + PRESET_COOKIE_EIDS + ); + page.pageWindow.pbjs.setConfig({ + consentManagement: { + gdpr: { + cmpApi: 'iab', + timeout: 500, + ...(withRule ? { rules: [FULL_BASIC_ADS_RULE] } : {}), + }, + }, + userSync: { + auctionDelay: 300, + userIds: [ + { + name: 'identityLink', + params: { pid: '999', notUse3P: true }, + storage: { type: 'cookie', name: 'idl_env', expires: 15 }, + }, + ], + }, + ortb2: { user: { ext: { eids: [CONTROL_EID] } } }, + }); + await requestBids(page.pageWindow, clientBidderAdUnits()); + expect(page.pageWindow.document.readyState, 'the auction runs before load').toBe('loading'); + return page; + } + + it('TCF: without an exception no client EIDs reach TS and ts-eids is cleared', async () => { + const page = await runTcfPage({ withRule: false }); + try { + expect( + tcfEnforcementResults(page.pageWindow).some((result) => + result.biddersBlocked.includes('trustedServer') + ), + 'positive control: Prebid blocked trustedServer' + ).toBe(true); + expect(page.auctionBodies, 'no /auction request should be sent').toEqual([]); + expect(readTsEidsCookie(page.pageWindow), 'ts-eids should be cleared').toBeUndefined(); + } finally { + await page.close(); + } + }, 60_000); + + it('TCF: with the full basicAds rule TS gets its EIDs and other bidders stay blocked', async () => { + const page = await runTcfPage({ withRule: true }); + try { + // Prebid appends module EIDs after ortb2 entries (spec 3.2 step 3). + expect(page.auctionBodies.map((body) => body.eids)).toEqual([[CONTROL_EID, LIVERAMP_EID]]); + const results = tcfEnforcementResults(page.pageWindow); + expect( + results.some((result) => result.biddersBlocked.includes('appnexus')), + 'the rule must not switch off enforcement for other bidders' + ).toBe(true); + expect( + results.some((result) => result.biddersBlocked.includes('trustedServer')), + 'trustedServer should not be blocked with the rule' + ).toBe(false); + expect(readTsEidsCookie(page.pageWindow), 'ts-eids should copy the body set').toEqual([ + CONTROL_EID, + LIVERAMP_EID, + ]); + } finally { + await page.close(); + } + }, 60_000); + + it('TCF: with the full basicAds rule and Purpose 1 denied, TS gets its ortb2 EIDs and ts-eids is cleared', async () => { + const page = await runTcfPage({ withRule: true, purposeOneConsent: false }); + try { + // Purpose 1 gates the User ID module (enrichEids), not ortb2 EIDs + // (transmitEids), so only the control reaches TS. + expect( + page.auctionBodies.map((body) => body.eids), + 'Prebid should still let trustedServer forward the ortb2 EID' + ).toEqual([[CONTROL_EID]]); + expect( + tcfEnforcementResults(page.pageWindow).some((result) => + result.biddersBlocked.includes('trustedServer') + ), + 'trustedServer should not be blocked with the rule' + ).toBe(false); + expect( + readTsEidsCookie(page.pageWindow), + 'ts-eids should be cleared without Purpose 1' + ).toBeUndefined(); + } finally { + await page.close(); + } + }, 60_000); +}); diff --git a/docs/guide/api-reference.md b/docs/guide/api-reference.md index f6c8f72d31..721226f38b 100644 --- a/docs/guide/api-reference.md +++ b/docs/guide/api-reference.md @@ -212,6 +212,49 @@ curl -X POST https://edge.example.com/auction \ -d '{"adUnits":[{"code":"banner","mediaTypes":{"banner":{"sizes":[[300,250]]}}}]}' ``` +#### Browser EIDs (`eids`) + +The optional `eids` array carries OpenRTB 2.6 EID objects from the browser: + +```json +{ + "eids": [ + { + "source": "ids.example.com", + "inserter": "inserter.example.com", + "matcher": "ids.example.com", + "mm": 4, + "ext": { "segments": ["s1"] }, + "uids": [ + { "id": "fictional-uid", "atype": 1, "ext": { "partner": "example" } } + ] + } + ] +} +``` + +Each entry is validated on its own. A malformed entry or field is dropped and +the request proceeds; only a malformed body outside `eids` rejects the request. + +| Field | Rule | +| --------------------- | -------------------------------------------------------- | +| entry | JSON object that parses on its own | +| `source` | string, not blank, at most 255 UTF-8 bytes | +| `uids` | at least one valid UID; the first 32 valid UIDs are kept | +| `uids[].id` | string, not blank, at most 512 bytes | +| `uids[].atype`, `mm` | integer from 0 to 2,147,483,647 | +| `uids[].ext`, `ext` | JSON object, at most 2,048 bytes as compact JSON | +| `inserter`, `matcher` | string, not blank, at most 255 bytes | +| array | duplicates dropped, then the first 64 entries kept | + +Entries keep their order and a source may repeat. When the body has no valid +entry, the `ts-eids` cookie is used with the same rules. Browser EIDs are used +only for a request with an allowed EC. + +The response's `x-ts-eids` header carries the consent-gated EIDs sent to +providers, base64-encoded JSON including EID-level fields, capped at 4,096 +bytes. `x-ts-eids-truncated: true` marks a truncated value. + #### PBS stored-request intent The reserved `trustedServer` bid accepts `storedRequest` inside `params`, beside @@ -901,7 +944,7 @@ curl -u 'admin:' \ ### GET /\_ts/admin/eids -Parses the request's `ts-eids` and `sharedId` cookies and previews which configured partner IDs cookie ingestion would match or drop. It performs request inspection only: it does not read or write KV and is available on every adapter. The preview lists candidate matches from the cookies alone; it does not apply the TCF Purpose 1 + 4 consent gate on identity-graph writes, does not see `/auction` request-body EIDs, and does not account for partner UIDs already stored in KV. +Parses the request's `ts-eids` and `sharedId` cookies and previews which configured partner IDs cookie ingestion would match or drop. It performs request inspection only: it does not read or write KV and is available on every adapter. The preview lists candidate matches from the cookies alone; it does not apply the TCF Purpose 1 + 4 consent gate on identity-graph writes, does not see `/auction` request-body EIDs, and does not account for partner UIDs already stored in KV. Cookie entries are validated one by one with the `/auction` body rules, so a mistyped field drops only that field, and the returned `eids` include `inserter`, `matcher` and `mm` when present. The `ingest.unmatched` diagnostics still read the raw cookie, before validation. After successful authentication this endpoint always returns `200 OK`; missing or malformed cookies are represented by `cookie_present`, `sharedid_present`, and `parse_error`. The `ingest.matched` and `ingest.unmatched` arrays show the ingestion preview. Each unmatched entry contains its `source` and either a `no_partner` reason when no configured partner recognizes it or `no_valid_uid` when the partner exists but every supplied UID is empty or exceeds the storage limit. diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index a0c2b1b93a..ef727ef847 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -709,6 +709,11 @@ be at least 32 bytes. Keep it stable to preserve EC identifier continuity. `source_domain` is the canonical partner key. It matches incoming OpenRTB EID `source` values and is also used as the EC KV `ids` map key. ::: +`openrtb_atype` sets the `atype` of EIDs that Trusted Server builds from KV. +It applies only where KV supplies a source the browser did not send; for a +source in the browser's EIDs, the browser's own entry and `atype` are +forwarded instead. + `api_token` is optional. Set it to a key in `trusted_server_secrets` only when the partner calls the inbound identify or batch-sync APIs. A partner without `api_token` remains available for source-domain lookup, bidstream EIDs, and diff --git a/docs/guide/ec-setup-guide.md b/docs/guide/ec-setup-guide.md index cccb04daee..bd917df473 100644 --- a/docs/guide/ec-setup-guide.md +++ b/docs/guide/ec-setup-guide.md @@ -189,6 +189,13 @@ Expected decoded payload contains: - `source = ssp.example.com` - `uids[0].id = ` +When the request also carries browser EIDs, the decoded header lists them +first, exactly as the `trustedServer` adapter sent them (including `inserter`, +`matcher` and `mm`), followed by KV-only partner sources. The browser copies +the same EIDs, without EID-level `ext`, into the `ts-eids` cookie, which +navigations and `GET /_ts/page-bids` ingest. `/auction` ingests its own body +EIDs instead of the cookie. + ## 8) Fastly KV Operational Checks List stores: diff --git a/docs/guide/edge-cookies.md b/docs/guide/edge-cookies.md index 113066cdaf..1cc9659c07 100644 --- a/docs/guide/edge-cookies.md +++ b/docs/guide/edge-cookies.md @@ -148,13 +148,24 @@ flowchart LR ### Prebid EID Flow -Browser-resolved Prebid EIDs reach the identity graph when Trusted Server finalizes a response. It collects matched partner UIDs from three request-local sources, applied in this order so that, when two sources carry the same partner, the later source's UID is the candidate offered to KV: - -1. the `ts-eids` cookie, -2. the `eids` array in the `/auction` request body, -3. the `sharedId` cookie. - -The `/auction` body carries the full EID set Prebid.js resolved, so `/auction` ingestion is not limited by the size cap TSJS applies to the `ts-eids` cookie. The cookie still bridges Prebid user ID modules with the identity graph on requests that carry no EID body, such as `GET /_ts/page-bids` and page navigations. +Browser EIDs reach the identity graph when Trusted Server finalizes a +response. Which browser input it reads depends on the route: + +- `POST /auction` reads only the client EIDs the auction itself used: its + request body, or the `ts-eids` cookie when the body has none. An earlier + page's cookie is never ingested next to a body. +- Publisher navigations, admitted `GET /_ts/page-bids` requests and newly + generated ECs read the `ts-eids` cookie. +- Every route then applies the `sharedId` cookie last. + +The TSJS Prebid adapter writes `ts-eids` in `buildRequests`, before each +`trustedServer` request is sent: the request's EIDs without EID-level `ext`, +trimmed to 3,072 bytes, as base64 of UTF-8 JSON. It clears the cookie when that +set is empty or when Prebid's TCF rules block `trustedServer`. Where GDPR +applies, it writes the cookie only with TCF Purpose 1 (store/access device) and +Purpose 4 (personalized ads) consent, the rule below; without it, the adapter +clears the cookie and still sends the EIDs in the `/auction` body. Auctions that +do not include `trustedServer` leave it unchanged. These Prebid EID writes follow the same consent rule as bidstream EID forwarding: when a TCF signal is present, Purpose 1 (store/access device) and Purpose 4 (personalized ads) must both be consented, and when GDPR applies without a TCF signal the writes are withheld. A user who consents to Purpose 1 but not Purpose 4 keeps their EC, but no EIDs from the request are persisted. @@ -166,12 +177,9 @@ sequenceDiagram participant TS as Trusted Server participant KV as KV Store - Prebid->>Prebid: Auction completes - Prebid->>TSJS: bidsBackHandler fires - TSJS->>Prebid: getUserIdsAsEids() - Prebid-->>TSJS: [{source, uids: [{id, atype}]}] - TSJS->>TSJS: Base64 encode full OpenRTB-style EID array
[{source, uids:[{id, atype, ext?}]}] - TSJS->>B: document.cookie = "ts-eids=..." + Prebid->>TSJS: buildRequests(bidRequests, bidderRequest) + TSJS->>TSJS: Copy bidderRequest.ortb2 EIDs
drop EID-level ext, fit 3,072 bytes + TSJS->>B: document.cookie = "ts-eids=..." (before the request is sent) Note over B,TS: Next eligible EID-sync request B->>TS: Document navigation, POST /auction,
or admitted GET /_ts/page-bids @@ -179,7 +187,7 @@ sequenceDiagram TS->>KV: Add missing partner IDs in one conditional write
skip when UIDs already match
(requires TCF Purpose 1 + 4 under GDPR) ``` -Current TSJS writers preserve the full OpenRTB-style `{source, uids:[...]}` shape in `ts-eids`. The server remains backward-compatible with earlier flattened `{source, id, atype}` cookies during rollout, but new cookies use the structured `uids[]` form. +`ts-eids` holds OpenRTB-style `{source, uids:[...]}` entries, with `inserter`, `matcher` and `mm` when Prebid set them. The server validates each entry with the same rules as the `/auction` body and drops only what is malformed. Earlier flattened `{source, id, atype}` cookies are still accepted. The `sharedId` cookie follows a similar path but is written directly by Prebid's SharedID module rather than by TSJS. The server reads it separately and maps it via the `sharedid.org` source domain. @@ -208,24 +216,24 @@ sequenceDiagram TS->>KV: Upsert ids[source_domain] = UID else Prebid sync seeds browser EIDs B->>B: Prebid User ID modules resolve IDs - B->>TSJS: getUserIdsAsEids() + B->>TSJS: buildRequests(bidRequests, bidderRequest) TSJS->>B: Write ts-eids cookie
Base64 OpenRTB-style EIDs (size-capped) B->>TS: Next eligible request with ts-eids TS->>KV: Add missing matched partner UIDs
defer different stored values end Note over B,TS: Prebid-routed auction - B->>B: getUserIdsAsEids() for current auction + B->>B: Prebid builds the trustedServer bidder request B->>TS: POST /auction
adUnits + eids[] + ts-ec cookie TS->>KV: Resolve EC-backed source-domain IDs KV-->>TS: Stored partner UIDs - TS->>TS: Convert stored UIDs to EIDs
Merge + dedupe with request eids[]
Apply consent gating + TS->>TS: Request eids[] as sent
+ KV EIDs for sources not sent
Apply consent gating TS->>PS: OpenRTB request
user.ext.eids = merged EID set PS->>DSP: OpenRTB bid request
user.ext.eids preserved for bidders DSP-->>PS: OpenRTB bid response PS-->>TS: OpenRTB seatbid response TS-->>B: Auction response + x-ts-eids header when available - TS->>KV: Add missing partner UIDs from request eids[]
+ ts-eids/sharedId cookies (TCF Purpose 1 + 4 under GDPR) + TS->>KV: Add missing partner UIDs from request eids[]
+ sharedId cookie (TCF Purpose 1 + 4 under GDPR) ``` The relevant OpenRTB structure forwarded to Prebid Server and downstream partners is: @@ -263,7 +271,7 @@ The relevant OpenRTB structure forwarded to Prebid Server and downstream partner } ``` -Server-resolved EIDs and current-request Prebid EIDs are deduplicated by `source + uid.id`. Browser-supplied EIDs, from the `/auction` request body, the `ts-eids` cookie, or the `sharedId` cookie, only fill partner UIDs that are missing from KV; a different stored UID is kept, as described in [Prebid EID Flow](#prebid-eid-flow). +Browser EIDs are forwarded as sent; KV adds EIDs only for registered sources the browser did not send. Browser-supplied EIDs, from the `/auction` request body, the `ts-eids` cookie, or the `sharedId` cookie, only fill partner UIDs that are missing from KV; a different stored UID is kept, as described in [Prebid EID Flow](#prebid-eid-flow). ### Pull-Sync Completeness Marker diff --git a/docs/guide/integrations/lockr.md b/docs/guide/integrations/lockr.md index ef9e57b0f3..b0ede7e124 100644 --- a/docs/guide/integrations/lockr.md +++ b/docs/guide/integrations/lockr.md @@ -88,28 +88,38 @@ User data is stored securely in Lockr's privacy vault: ### Extended ID (EID) Support -Lockr identities are injected into OpenRTB bid requests as Extended Identifiers: +The Lockr SDK writes its ID5 EID into Prebid's first-party data with +`pbjs.setConfig({ ortb2: { user: { ext: { eids } } } })`. Prebid includes it in +the `trustedServer` bidder request, and the adapter forwards it to `/auction` +with its EID-level fields, for example: ```json { - "user": { - "ext": { - "eids": [ - { - "source": "lockr.io", - "uids": [ - { - "id": "lockr-user-id-123", - "atype": 1 - } - ] - } - ] - } - } + "source": "id5-sync.com", + "inserter": "inserter.example.com", + "mm": 2, + "uids": [{ "id": "fictional-id5-id", "atype": 1 }] } ``` +The SDK writes the EID after its own network call, so the first auction on a +page may run before it is available. Later auctions include it, and requests +without an EID body get it from the `ts-eids` cookie and KV. + +To store the ID in the EC identity graph, register the source: + +```toml +[[ec.partners]] +name = "ID5 via Lockr" +source_domain = "id5-sync.com" +bidstream_enabled = true +``` + +Where GDPR applies and Prebid's TCF enforcement is active, Prebid sends the +`trustedServer` adapter no EIDs unless the publisher grants the exception +described in +[TCF and the `trustedServer` adapter](./prebid.md#tcf-and-the-trustedserver-adapter). + ## Use Cases ### 1. Cookieless Identity Resolution @@ -224,9 +234,11 @@ curl https://edge.example.com/ **Solutions**: -- Verify OpenRTB request builder includes `user.ext.eids` -- Check integration is registered in IntegrationRegistry -- Ensure `contribute_eids()` method is implemented +- Confirm `pbjs.getEvents()` shows a `bidRequested` event for `trustedServer` + whose `ortb2.user.ext.eids` contains the `id5-sync.com` entry +- Under GDPR, confirm the publisher's Prebid config grants `trustedServer` the + full TCF exception +- Decode the `/auction` response's `x-ts-eids` header to see what was forwarded ## Performance diff --git a/docs/guide/integrations/prebid.md b/docs/guide/integrations/prebid.md index 18db20e237..041571e9b6 100644 --- a/docs/guide/integrations/prebid.md +++ b/docs/guide/integrations/prebid.md @@ -339,8 +339,8 @@ A settled result that denies consent still seeds the managed entry; `tcfControl` then blocks the vendor call and the storage write, which is the enforcement path this integration relies on. A CMP that recovers later — the user completes its UI, or a delayed first response arrives — activates the managed entries at that -point, with `userSync.autoRefresh` briefly enabled so Prebid initializes modules -added after its first pass. +point, with `userSync.autoRefresh` enabled so Prebid initializes modules +added after its first pass. Because Prebid accumulates `userSync` settings, `autoRefresh` then stays enabled for the rest of the page. This scope is deliberately narrow. It applies only to the automatic TCF collector that Trusted Server configures for its own managed modules. A @@ -659,9 +659,26 @@ paths. ## User ID Modules -Prebid.js can expose publisher-configured User ID Module output via -`pbjs.getUserIdsAsEids()`. The TSJS Prebid shim reads those current-request -EIDs after auctions and forwards them to Trusted Server when they are available. +Prebid.js places the EIDs each bidder may receive in that bidder's +`bidderRequest.ortb2`: User ID module output, `setConfig({ortb2})` and +`setBidderConfig` data, and RTD output, with IDs restricted to other bidders +left out and Prebid's activity controls applied. The `trustedServer` adapter +forwards exactly the `user.ext.eids` and `user.eids` entries in its own bidder +request, with their `inserter`, `matcher`, `mm` and `ext`. It never reads +`pbjs.getUserIdsAsEids()`, which also returns IDs restricted to other bidders. + +### Timing and `userSync` + +Trusted Server writes no Prebid `userSync` settings other than the managed +`userIds` entries below (and `autoRefresh` when a late CMP answer seeds them). +The publisher's `auctionDelay`, `syncDelay`, `syncsPerBidder` and +`filterSettings`, or Prebid's defaults, apply from the first auction. Prebid's +default `auctionDelay` is 500 ms: an auction waits up to that long while User +ID modules are still resolving, and not at all once they have settled. Set +`auctionDelay` and `filterSettings` in your own Prebid configuration if the +defaults do not suit you. Prebid's default allows image user syncs only. +Vendors that write `ortb2` after their own network call, such as the Lockr +SDK, are not awaited; their IDs reach later requests. User ID submodule inclusion comes from `bundle.modules.user_id`. The available modules and default preset are checked in at @@ -845,16 +862,26 @@ IdentityLink resolves asynchronously. A new browser's first auction can run before RampID is available; later auctions can include it without blocking the page or auction. When available, the opaque value follows the standard path: -1. `pbjs.getUserIdsAsEids()` exposes an entry whose source is `liveramp.com`. -2. The current `/auction` request includes that entry. -3. Trusted Server merges and consent-gates it, then forwards it to Prebid - Server as `user.ext.eids`. +1. Prebid places the `liveramp.com` entry in the `trustedServer` bidder + request's `ortb2.user.ext.eids`. +2. The adapter forwards that entry in the `/auction` body and, before sending + the request, copies the body's EIDs (without EID-level `ext`) into the + bounded `ts-eids` cookie. +3. Trusted Server consent-gates it and forwards it to Prebid Server as + `user.ext.eids`. 4. After the response, the same `/auction` request ingests the body entry into an EC/KV partner configured with `source_domain = "liveramp.com"`, when that partner UID is not already stored. -5. The browser also persists the opaque value in the bounded `ts-eids` cookie, - which lets requests without an EID body, such as `GET /_ts/page-bids` and - page navigations, ingest it. +5. Requests without an EID body, such as `GET /_ts/page-bids` and page + navigations, read and ingest the `ts-eids` cookie. + +Where GDPR applies and Prebid's TCF enforcement is active, Prebid sends the +`trustedServer` adapter no bid requests unless the publisher grants it an +exception (see [TCF and the `trustedServer` adapter](#tcf-and-the-trustedserver-adapter)), +and the adapter clears `ts-eids`, so no browser EIDs reach Trusted Server. +With such an exception but without TCF Purpose 1 (store/access device) and +Purpose 4 (personalized ads) consent, the adapter still forwards the EIDs in the +`/auction` body but clears `ts-eids` instead of writing it. Trusted Server treats the RampID envelope as an opaque string. Do not log, decode, publish, or dimension metrics by the value. Source names, counts, @@ -936,17 +963,28 @@ not passed by this implementation. ## Identity Forwarding -Trusted Server uses a **hybrid EID forwarding model** for Prebid-routed auctions: - -1. **Current-request EIDs from Prebid.js** are read from `pbjs.getUserIdsAsEids()` in the browser and sent in the `/auction` request body. -2. **Server-side EIDs from the EC/KV identity graph** are resolved on the edge from the current EC ID. -3. Trusted Server **merges and deduplicates** both sets before calling Prebid Server. -4. The merged result is forwarded downstream as `user.ext.eids` in the OpenRTB request. -5. After the response, Trusted Server adds matched partner UIDs that are missing from the EC identity graph, reading the `ts-eids` cookie, then the `/auction` request-body EIDs, then the `sharedId` cookie. Because the body carries the untrimmed EID set, `/auction` ingestion is not limited by the size-capped `ts-eids` cookie. - -The `ts-eids` cookie still matters for requests without an EID body, such as `GET /_ts/page-bids` and page navigations, where it is the source for both EID fallback and identity-graph ingestion. Identity-graph EID writes follow the same consent rule as bidstream forwarding: under GDPR, TCF Purpose 1 and Purpose 4 must both be consented. - -This means Prebid auctions get same-request transparency for browser-resolved IDs without giving up the durability of the server-managed EC identity graph. +Trusted Server combines browser EIDs with the EC/KV identity graph: + +1. **Browser EIDs** are the entries Prebid placed in the `trustedServer` + bidder request. The adapter sends them in the `/auction` body as plain data + and copies them, without EID-level `ext`, into the `ts-eids` cookie before + the request is sent. +2. **Server-side EIDs** are resolved from the EC/KV identity graph for + partners with `bidstream_enabled = true`. +3. Browser entries are forwarded first, exactly as sent. KV adds entries only + for registered sources the browser did not send. +4. Consent gating applies to the merged set, which goes out as `user.ext.eids` + and is echoed in the `x-ts-eids` response header. +5. After the response, `/auction` adds matched partner UIDs that KV does not + yet hold from its own client set (the body, or the cookie when the body has + none), then from the `sharedId` cookie. Other routes read the `ts-eids` + cookie instead. + +The server validates every browser entry on its own, whether it came in the +body or the cookie. A malformed entry or field is dropped and the rest of the +request proceeds. Identity-graph writes follow the same consent rule as +bidstream forwarding: under GDPR, TCF Purpose 1 and Purpose 4 must both be +consented. ### Identity flow @@ -957,38 +995,86 @@ sequenceDiagram participant K as EC + KV identity graph participant P as Prebid Server - B->>B: User ID modules resolve EIDs - B->>T: POST /auction\n(adUnits + current-request eids) + B->>B: Prebid builds the trustedServer bidder request (ortb2 EIDs) + B->>B: Copy its EIDs into ts-eids (no EID-level ext) + B->>T: POST /auction\n(adUnits + the bidder request's eids) + T->>T: Validate each entry T->>K: Resolve EC-backed source-domain IDs K-->>T: KV-derived EIDs - T->>T: Merge + dedupe client + KV EIDs + T->>T: Browser entries as sent, then KV-only sources T->>T: Apply consent gating T->>P: OpenRTB request\nuser.ext.eids = merged set P-->>T: OpenRTB bid response - T-->>B: Auction response - T->>K: Ingest body eids + ts-eids/sharedId cookies\n(TCF Purpose 1 + 4 under GDPR) + T-->>B: Auction response + x-ts-eids + T->>K: Ingest this request's client EIDs + sharedId cookie\n(TCF Purpose 1 + 4 under GDPR) ``` -### Merge and deduplication rules +### Merge rules -- Client-request EIDs and KV-resolved EIDs are merged by `source` -- UIDs are deduplicated by `source + id` -- If the same UID appears in both places, it is sent only once downstream -- Distinct UIDs under the same source are preserved -- Consent gating is applied to the **merged** set before forwarding +- Browser entries are forwarded in the order Prebid sent them and are never + altered or combined. A source can appear in more than one entry, for example + `adserver.org` from two modules with different `matcher` values. +- KV adds one entry for each registered source no browser entry carries. + Sources are compared case-insensitively, ignoring a trailing dot. +- For a source the browser sends, KV's stored UID and its configured + `openrtb_atype` are not used. +- Consent gating is applied to the merged set before forwarding. ### What reaches Prebid Server The downstream Prebid Server request includes: - `user.id` when EC forwarding is allowed -- `user.ext.eids` containing the merged, deduplicated EID set +- `user.ext.eids` with the browser entries and their `inserter`, `matcher`, + `mm` and `ext`, followed by KV-only sources - forwarded browser cookies (subject to consent-forwarding mode) -In practice, this gives operators both: +Before rolling this out, send one request with a repeated source and all four +EID-level fields to each configured provider in staging and confirm it is +accepted. + +### TCF and the `trustedServer` adapter + +The `trustedServer` adapter has no GVL ID. Where GDPR applies and Prebid's TCF +enforcement is active (the publisher configures `consentManagement.gdpr`, or +Trusted Server enables it for managed User IDs), Prebid's default rules remove +`trustedServer` from the auction and withhold its EIDs. Trusted Server then +receives no browser EIDs from the adapter, and the adapter clears `ts-eids` +when Prebid reports the block. + +Granting `trustedServer` an exception is the publisher's decision. Use only +the full rule: + +```js +pbjs.setConfig({ + consentManagement: { + gdpr: { + // ...the publisher's existing CMP settings... + rules: [ + { + purpose: 'basicAds', + enforcePurpose: true, + enforceVendor: true, + softVendorExceptions: ['trustedServer'], + }, + ], + }, + }, +}) +``` + +::: warning +A publisher rule replaces Prebid's default rule for that purpose. A rule +written as `{ purpose: 'basicAds', softVendorExceptions: ['trustedServer'] }`, +without `enforcePurpose: true` and `enforceVendor: true`, switches off both +checks for **every** bidder. Do not use `vendorExceptions`, which also skips +the purpose check. +::: -- **same-request identity transparency** for Prebid User ID Module output, and -- **future-request continuity** through cookie ingestion and KV-backed partner resolution. +With the full rule, `trustedServer` still needs Purpose 2 consent and skips +only the vendor check. A publisher that sets `eidsRequireP4Consent` must also +add the same full rule for `personalizedAds`; without it `trustedServer` gets +bid requests with no EIDs and `ts-eids` is cleared after every auction. ## Endpoints @@ -1053,7 +1139,7 @@ The shared OpenRTB driver builds Prebid Server requests: - Sets `tagid` from the slot ID - Adds site metadata with publisher domain, a validated publisher-owned page URL with query and fragment removed, `site.publisher` from the domain, and the browser `Referer` as `site.ref`. Removing query and fragment data from `site.page` can reduce contextual targeting or per-page reporting for sites whose page identity depends on query parameters - Injects EC ID in the user object -- Merges current-request browser EIDs with KV-resolved EIDs and forwards the deduplicated result as `user.ext.eids` +- Forwards the browser EIDs as sent, followed by KV-resolved EIDs for registered sources the browser did not send, as `user.ext.eids` - Forwards user consent string and sets the GDPR flag based on geo and consent presence - Translates the `Sec-GPC` header to a US Privacy string (`us_privacy`) - Extracts `DNT` and `Accept-Language` headers into device fields diff --git a/docs/superpowers/implementation-notes/2026-10-18-prebid-eid-fidelity.md b/docs/superpowers/implementation-notes/2026-10-18-prebid-eid-fidelity.md new file mode 100644 index 0000000000..ce7294a855 --- /dev/null +++ b/docs/superpowers/implementation-notes/2026-10-18-prebid-eid-fidelity.md @@ -0,0 +1,235 @@ +# Prebid EID Fidelity Implementation Notes + +Red log for the tests this work adds or changes (spec 7.1 rule 1, A12). Each +entry names the test, the command, and the failure seen before the change, or, +for a regression net, the line temporarily reverted and the failure it caused. + +## Environment + +- Date: 2026-10-19 +- Branch: `feat/1246-prebid-eid-fidelity` +- Rust: rustc 1.95.0 (59807616e 2026-04-14) +- Node: v24.12.0 + +## Task 1: `Eid` carries OpenRTB 2.6 EID-level fields + +Command: + +```bash +cargo test-fastly --no-run 2>&1 | grep -E "^error" | sort | uniq -c +``` + +Failures (the new tests and helpers do not compile against the unchanged `Eid`): + +- `eid_serializes_openrtb_provenance_fields_when_present`, `eid_deserializes_provenance_fields_and_keeps_rejecting_unknown_keys`, `eid_header_carries_provenance_fields_and_repeated_sources`, `to_openrtb_forwards_eid_provenance_fields_and_repeated_sources`, `aps_request_forwards_eid_provenance_fields_and_repeated_sources`, `standard_profile_forwards_eid_provenance_fields_and_repeated_sources`: `error[E0560]: struct 'openrtb::Eid' has no field named 'inserter'` (also `matcher`, `mm`, `ext`; 4 each) +- `eid_without_provenance_fields_serializes_as_before`: `error[E0599]: no function or associated item named 'new' found for struct 'openrtb::Eid'` +- `eid_deserializes_provenance_fields_and_keeps_rejecting_unknown_keys`: `error[E0369]: binary operation '==' cannot be applied to type 'openrtb::Eid'` + +## Task 2: One parser for browser EIDs + +Command (each test run alone, because a failed assertion aborts the wasm test binary): + +```bash +cargo test-fastly -- ec::client_eids::tests:: --exact +``` + +Failures (the stub `parse_client_eids` returns no entries, so each assertion on the parsed output fails and the Viceroy run aborts on the panic): + +- `keeps_a_valid_entry_with_every_field`, `drops_only_the_entry_that_does_not_parse`, `drops_entries_that_are_not_objects`, `validates_source`, `drops_an_entry_without_a_valid_uid`, `validates_uid_ids`, `keeps_the_first_32_valid_uids`, `reads_atype_and_mm_as_integers_in_range`, `validates_ext_objects_and_their_size`, `validates_inserter_and_matcher`, `drops_duplicates_before_the_entry_cap`, `keeps_the_first_64_entries`, `keeps_order_and_repeated_sources_and_ignores_unknown_keys`, `logs_dropped_entries_and_fields_by_name_and_source_without_values`: `assert_eq!` or `assert!` on the empty stub output panics (wasm abort) +- `yields_nothing_for_a_payload_that_is_not_an_array`: passes against the stub, as expected (regression net) + +## Task 3: Body and cookie go through the shared parser + +Command (each test run alone, because a failed assertion aborts the wasm test binary): + +```bash +cargo test-fastly -- :: --exact +``` + +Failures (against the unchanged body and cookie readers): + +- `tsjs_auction_body_fixture_drops_only_the_unparsable_entry` (body rejected, provider saw `None`), `tsjs_cookie_fixture_drops_only_the_unparsable_entry` (cookie rejected, provider saw `Some(Null)`), `auction_body_eid_level_fields_reach_the_auction_request` (EID-level fields dropped): wasm abort on assertion. Moved to Task 4: they also depend on the merge rewrite; the old merge drops EID-level fields. +- `structured_cookie_keeps_eid_level_fields`, `structured_cookie_drops_a_mistyped_field_instead_of_the_whole_cookie`, `structured_cookie_isolates_an_unparsable_entry`, `structured_cookie_applies_the_entry_cap`: assertion panic (wasm abort) +- `structured_cookie_diagnostics_report_raw_uid_strings`: also FAILS on unchanged code (the strict cookie parser keeps the 513-byte UID), so it is a red-first test, not a regression net; no revert proof needed +- `auction_uses_the_cookie_when_no_body_entry_is_valid`: passes against unchanged code (regression net) +- Revert proof: with `.filter(|eids| !eids.is_empty())` removed from `resolve_client_auction_eids`, `auction_uses_the_cookie_when_no_body_entry_is_valid` FAILS (`left: Some(Null)`); line restored + +## Task 4: Forward browser entries as sent; KV fills absent sources + +Command (each test run alone, because a failed assertion aborts the wasm test binary): + +```bash +cargo test-fastly -- :: --exact +``` + +Failures (against the old merge): + +- `merge_case_1_forwards_a_browser_entry_with_its_fields`, `merge_case_2_keeps_the_browser_atype_for_a_shared_uid`, `merge_case_3_drops_the_stale_kv_uid_for_a_browser_source`, `merge_case_4_appends_kv_sources_the_browser_did_not_send`, `merge_case_5_keeps_same_source_entries_with_different_fields_apart`, `merge_case_6_matches_sources_as_the_registry_normalizes_them`, `merge_auction_eids_does_not_union_kv_uids_into_a_browser_source`, `merge_auction_eids_keeps_browser_metadata_on_conflict`, `publisher::tests::body_less_auction_eids_put_cookie_entries_first_and_kv_only_sources_after`: assertion panic (wasm abort); the old merge drops EID-level fields, puts KV first, unions UIDs and lets KV's atype and ext win +- `tsjs_auction_body_fixture_drops_only_the_unparsable_entry`, `tsjs_cookie_fixture_drops_only_the_unparsable_entry`, `auction_body_eid_level_fields_reach_the_auction_request` (deferred from Task 3): assertion panic (wasm abort); the old merge rebuilds entries without EID-level fields +- `merge_case_7_forwards_kv_entries_when_the_browser_sent_none`, `merge_drops_entries_without_uids`, `merge_auction_eids_deduplicates_client_and_resolved_ids`: pass against the old merge (regression nets) +- Revert proof: with `merged.push(eid);` replaced by `let _ = eid;`, `merge_case_7_forwards_kv_entries_when_the_browser_sent_none` FAILS (wasm abort); line restored +- Revert proof: with `.filter(|eid| !eid.uids.is_empty())` removed from the browser branch, `merge_drops_entries_without_uids` FAILS (wasm abort); line restored + +## Task 5: KV ingestion on /auction reads only the auction's own client set + +Command (each test run alone, because a failed assertion aborts the wasm test binary): + +```bash +cargo test-fastly -- :: --exact +``` + +Failures (against the old ingestion, which always read the cookie first): + +- `auction::endpoints::tests::auction_ingests_its_body_eids_and_not_a_cookie_only_source`: KV held `body.example.com` and also `cookie-only.example.com` +- `auction::endpoints::tests::disabled_auction_ingests_nothing_from_the_cookie_but_keeps_shared_id`: KV held `cookie-only.example.com` next to `sharedid.org` +- `navigation_ingests_the_cookie_only_source`, `finalize_generated_ec_without_sync_source_ingests_the_ts_eids_cookie`, the edited `finalize_recovers_orphaned_ec_when_purpose_four_denial_empties_updates` and `auction_route_carries_the_raw_ts_eids_cookie_to_finalization` pass against the old code (regression nets) +- Revert proof: with `browser_eid_input`'s fallback arm changed to `_ => (None, ec_context.client_eids()),`, `navigation_ingests_the_cookie_only_source`, `finalize_generated_ec_without_sync_source_ingests_the_ts_eids_cookie` and `finalize_recovers_orphaned_ec_when_purpose_four_denial_empties_updates` each FAIL (wasm abort); arm restored +- Revert proof: with the Fastly adapter's `let eids_cookie = crate::extract_cookie_value(req, COOKIE_TS_EIDS);` replaced by `let eids_cookie: Option = None;`, `auction_route_carries_the_raw_ts_eids_cookie_to_finalization` FAILS (wasm abort); line restored + +## Task 6: Pure EID collector and cookie value builder + +Command (against a stub `eids.ts` whose functions return `undefined` and an empty cookie): + +```bash +cd crates/trusted-server-js/lib && mise exec node@24.12.0 -- npx vitest run test/integrations/prebid/eids.test.ts +``` + +Result: 22 failed, 9 passed (31 tests). The 9 passes are the four `returns undefined for …` cases, the four `… not an object` cases and `does not modify the entries it is given`, which the stub passes trivially. + +Failures: + +- `collectBidderEids` tests (`reads ortb2.user.ext.eids`, `reads ortb2.user.eids, …`, `reads user.ext.eids before user.eids`, `returns plain-data copies without undefined fields`, `drops an entry whose serialization throws …`, `keeps a set that fits the UTF-8 budget exactly`, `drops the entry that crosses the budget …`, `measures the budget in UTF-8 bytes …`, `keeps order and repeated sources`, `forwards a lone surrogate unchanged …`, `passes the Prebid module shapes from spec 3.5 …`): `expected undefined to deeply equal [...]` +- `fitEidCookie` tests (`removes EID-level ext and drops the duplicates …`, `trims UIDs from the last source …`, `reports a source trimmed and then dropped only as dropped`, both `removes a last entry that is … whole` cases, `removes a null last entry whole …`, `encodes non-Latin-1 text as UTF-8 base64 …`): `should produce a cookie value: expected undefined to be defined` +- `fitEidCookie > drops whole tail sources until the cookie fits`: `TypeError: Cannot read properties of undefined (reading 'length')` +- `fitEidCookie > returns no value when nothing fits`: `droppedSources` is `[]` instead of `['huge.example.com']` +- `fitEidCookie > matches btoa output for ASCII payloads`, `fitEidCookie > produces exactly the ts-eids-cookie fixture for the fixture input`: `expected undefined to be ''` + +## Task 7: Adapter forwards the bidder request's EIDs and owns ts-eids + +Command (canary, helpers and new tests installed; adapter unchanged): + +```bash +cd crates/trusted-server-js/lib && mise exec node@24.12.0 -- npx vitest run test/integrations/prebid/index.test.ts test/core/request.test.ts +``` + +Result: 175 failed, 132 passed. Besides the new tests, about 160 existing tests that build a `trustedServer` request or run `bidsBackHandler` fail in the shared `afterEach`, because the old collector forwards the `canary.example.com` EID from `getUserIdsAsEids()` (`the getUserIdsAsEids canary leaked into an /auction body`) or writes it to `ts-eids`. + +Failures: + +- `forwards an opaque LiveRamp envelope from the bidder request unchanged`: `expected [ { …(2) } ] to deeply equal [ { source: 'liveramp.com', …(1) } ]` (the canary arrives instead) +- `forwards bidderRequest.ortb2 EIDs with EID-level fields, user.ext.eids first`: `should forward both paths as sent: expected [ { …(2) } ] to deeply equal [ …(2) ]` +- `sends exactly the auction-body fixture for the fixture input`: `TSJS should produce the Rust fixture byte for byte` +- `writes exactly the ts-eids fixture for the fixture input`: `expected undefined to be 'W3sic291cmNl…'` +- `writes ts-eids from the body set without EID-level ext`: `the body keeps EID-level ext: expected [ { …(2) } ] to deeply equal [ { …(3) }, { …(2) } ]` +- `clears ts-eids before the request when the bidder request holds no EIDs`: `the body should carry no eids: expected [ { …(2) } ] to be undefined` +- `sends no eids and leaves ts-eids alone without a bidder request object`: `expected [ { …(2) } ] to be undefined` +- `lets the later of two overlapping requests own ts-eids`: `expected undefined to deeply equal [ { …(2) } ]` +- `forwards ortb2 EIDs and writes ts-eids without the User ID module`: `expected undefined to deeply equal [ { source: 'id5-sync.com', …(3) } ]` +- `logs EID counts and the cookie decision at debug level, never values`: `expected "debug" to be called with arguments: [ …(2) ]` +- `keeps the trim log when the cookie had to drop sources`: `the body keeps every entry: expected [ { …(2) } ] to deeply equal [ …(7) ]` +- `clears ts-eids when tcf2Enforcement lists trustedServer in biddersBlocked` and `… in eidsBlocked`, and `leaves ts-eids alone when tcf2Enforcement lists only other bidders`: `the shim should listen for tcf2Enforcement: expected undefined to be defined` +- `leaves ts-eids alone when an auction builds no trustedServer request`: `only buildRequests may write or clear the cookie: expected 'W3sic291cmNl…' to be 'preset-value'` +- `exposes the getUserIdsAsEids canary on the pbjs the shim uses` and `sends no eids in the /auction body`: PASS (positive control and net) +- Net proof: with `src/core/request.ts:45` changed to pass `{ eids: [{ source: 'net.example.com', … }] }` to `buildAdRequest`, `sends no eids in the /auction body` FAILS (`requestAds should leave EIDs to the ts-eids cookie: expected { adUnits: […], …(2) } to not have property "eids"`); line restored + +## Task 8: Stop writing Prebid userSync settings + +Command (new self-init tests added; `installUserIdModules` still present): + +```bash +cd crates/trusted-server-js/lib && mise exec node@24.12.0 -- npx vitest run test/integrations/prebid/index.test.ts -t "writes no userSync|preserves publisher userSync" +``` + +Result: 2 failed, 2 passed. The file-level `afterAll` canary also reports `the canary should have inspected at least one /auction body: expected 0 to be greater than 0`. This comes from the `-t` filter selecting no `/auction` test, not from this change, and a full run satisfies it. + +Failures: + +- `prebid self-init writes no Prebid userSync settings > writes no userSync when the bundle loads after window load`: `TS should leave userSync to the publisher and Prebid: expected [ [ { userSync: { …(6) } } ], …(1) ] to deeply equal []` (the `auctionDelay: 0`, `syncDelay: 3000`, `filterSettings.all` write) +- `prebid self-init writes no Prebid userSync settings > writes no userSync when window load fires after install`: same assertion, same write after the `load` event +- `preserves publisher userSync and autoRefresh=%s when seeding late IDs` (both `false` and `true`): PASS with the extended `syncDelay` and `filterSettings` fields. This is the seeding net; its proof follows in Task 8's green run. + +Net proof (seeding path): with `src/integrations/prebid/index.ts` line 2181 changed from `...(isRecord(effectiveUserSync) ? effectiveUserSync : {}),` to `...{},`, `-t "preserves publisher userSync"` FAILS on both cases: `preserves publisher userSync and autoRefresh=false when seeding late IDs` and `… autoRefresh=true …`, with `expected { userIds: [ { …(3) } ], …(1) } to deeply equal { userIds: [ { …(3) } ], …(5) }`. Line restored; the full file then passes 294 of 294. + +## Task 9: Real Prebid.js arms for D1, D3, D4 and TCF + +Command (the shim built from `main`'s sources: `src/integrations/prebid/index.ts` and `src/core/auction.ts` checked out from the merge base `a9037770a` for one run, then restored with `git checkout --`; `git status --short crates/trusted-server-js/lib/src` was clean afterwards): + +```bash +cd crates/trusted-server-js/lib && mise exec node@24.12.0 -- npx vitest run test/prebid-eid-fidelity.test.mjs +``` + +Result: 5 failed, 1 passed (6 tests). Each failure is the one spec 7.5 predicts. + +Failures: + +- `D1: never forwards an ID restricted to another bidder`: `the /auction body should carry the control and not the restricted ID: expected [ [ { source: 'pubcid.org', …(1) } ] ] to deeply equal [ [ { …(2) } ] ]` (the precondition passed: sharedId produced `pubcid.org` from the preset `_sharedid` cookie) +- `D3: forwards same-source entries separately with their own EID-level fields`: `expected [ undefined ] to deeply equal [ [ { …(5) }, { …(5) } ] ]` (`main` never reads `ortb2`) +- `D4: the first auction after window.load waits for a pending User ID module`: `Prebid's default auctionDelay should apply: expected +0 to be 500` +- `TCF: without an exception no client EIDs reach TS and ts-eids is cleared`: `ts-eids should be cleared: expected [ { source: 'liveramp.com', …(1) } ] to be undefined` (the positive control and the no-`/auction` check passed; `main`'s `bidsBackHandler` rewrote the cookie) +- `TCF: with the full basicAds rule TS gets its EIDs and other bidders stay blocked`: `expected [ Array(1) ] to deeply equal [ [ { …(2) }, { …(2) } ] ]` (the body carried `liveramp.com` only) +- `D4 control: the first auction before window.load waits, on every build`: PASS, as intended + +D4 mechanism probe: a temporary copy of the suite without the `auctionDelay` assertion, run with `-t "D4"` against the same `main` sources, still fails the D4 arm at the body: `the first /auction should carry the delayed unifiedId: expected [ undefined ] to deeply equal [ Array(1) ]`. So on `main` the first auction after `window.load` goes out without `adserver.org`, not merely with a different setting. The copy was deleted and the sources restored. + +## Task 10: Delete the dead EID-to-module resolver + +Command: + +```bash +cd crates/trusted-server-js/lib && mise exec node@24.12.0 -- npx vitest run test/integrations/prebid/user_id_modules.test.ts -t "no EID source metadata" +``` + +Failures: + +- `carries no EID source metadata`: `no entry should keep eidSources: expected [ 'connectIdSystem', …(10) ] to deeply equal []` (11 modules still carry `eidSources`) + +Green: after deleting `eidSources` and the resolver, `carries no EID source metadata` passes. The three targeted suites (`user_id_modules.test.ts`, `prebid-user-id-registry.test.mjs`, `build-prebid-external.test.mjs`) pass 81 of 81. `./scripts/test-cli.sh` exits 0. The full Vitest run under Node 24.12.0 passes 1249 of 1249 across 52 files. + +## Final review fixes + +### Fix 1: nothing fits clears the cookie (regression net) + +Revert proof: temporarily deleted the `clearPrebidEidsCookie();` call inside the `fitted.value === undefined` branch of `writePrebidEidsCookie`, then restored it. + +Command: + +```bash +cd crates/trusted-server-js/lib && mise exec node@24.12.0 -- npx vitest run test/integrations/prebid/index.test.ts -t "no EID fits" +``` + +Failures: + +- `clears ts-eids when no EID fits the cookie budget but still sends the body`: `the cookie should be cleared: expected 'preset-value' to be undefined` + +Green: with the call restored, the test passes. + +### Fix 2: cookie writes must not throw out of buildRequests + +Command (against the unguarded `clearPrebidEidsCookie`): + +```bash +cd crates/trusted-server-js/lib && mise exec node@24.12.0 -- npx vitest run test/integrations/prebid/index.test.ts -t "document.cookie writes throw" +``` + +Failures: + +- `still builds the request with the entry when the cookie write fails`: `Error: cookies blocked` thrown from `clearPrebidEidsCookie` through `buildRequests` +- `still builds the request without eids when the cookie clear fails`: `Error: cookies blocked` thrown from `clearPrebidEidsCookie` through `buildRequests` + +Green: with the `try`/`catch` guard both pass; full Vitest passes 1252 of 1252. + +### Fix 3: non-array cookie JSON is a parse error + +Command (against the unchanged decoder): + +```bash +cargo test-fastly -- parse_prebid_eids_cookie_rejects_non_array_json +cargo test-fastly -- eids_lookup_reports_non_array +``` + +Failures: + +- `parse_prebid_eids_cookie_rejects_non_array_json`: test trapped, the `{"a":1}` cookie parsed as `Ok` +- `eids_lookup_reports_non_array_cookie_as_parse_error`: test trapped, no `parse_error` reported + +Green: with the array check both pass. diff --git a/docs/superpowers/plans/2026-10-18-prebid-eid-capture-fidelity.md b/docs/superpowers/plans/2026-10-18-prebid-eid-capture-fidelity.md new file mode 100644 index 0000000000..bfb9fba0b0 --- /dev/null +++ b/docs/superpowers/plans/2026-10-18-prebid-eid-capture-fidelity.md @@ -0,0 +1,5474 @@ +# Prebid EID Fidelity 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:** Make the `trustedServer` Prebid adapter forward to `/auction`, and copy into `ts-eids`, exactly the EIDs Prebid.js gives that bidder, with their OpenRTB 2.6 EID-level fields. Validate them once on the server, forward browser entries as sent, and ingest into KV only the set the auction used. + +**Architecture:** A new pure TSJS module (`integrations/prebid/eids.ts`) copies `bidderRequest.ortb2.user.ext.eids` and `user.eids` as plain data within a 64 KiB UTF-8 budget. It also builds the `ts-eids` cookie value from that set without EID-level `ext`. The adapter writes or clears the cookie in `buildRequests`, and a `tcf2Enforcement` listener clears it when Prebid's TCF rules block `trustedServer`. On the server, a new `ec/client_eids.rs` parser validates each body or cookie entry on its own. `merge_auction_eids` forwards browser entries unchanged and adds KV entries only for registered sources the browser did not send. Finalization ingests only the auction's own client set on `/auction`. TS stops writing Prebid `userSync` settings, which fixes the forced `auctionDelay: 0`. + +**Tech Stack:** Rust 1.95 (`trusted-server-core`, `trusted-server-adapter-fastly`), serde_json 1.0.150 with `raw_value`, TypeScript + Vitest 4.0.18, jsdom 28.1.0, Prebid.js 10.26.0 (bundled npm dependency). + +**Spec:** `docs/superpowers/specs/2026-10-18-prebid-eid-fidelity-design.md` (branch `spec/1246-prebid-eid-fidelity`). Read it before starting. Section numbers in this plan (for example "spec 4.4") refer to it. Where this plan and the spec disagree, the spec wins. Stop and report the disagreement instead of picking one. + +## Global Constraints + +Copied from the spec. Every task implicitly includes these. + +- Follow `AGENTS.md` at the repo root for commands, style, error handling and CI gates. Bare `cargo test` / `cargo build` fail at the workspace root by design; use the aliases. +- Prebid.js is pinned to **10.26.0** by `crates/trusted-server-js/lib/package-lock.json`. Read Prebid behavior from `crates/trusted-server-js/lib/node_modules/prebid.js/`, not from a newer checkout. +- **`ts-eids` stays.** It remains a browser-written first-party cookie that TS reads before KV (product requirement, spec O1). **`x-ts-eids` stays** (spec G7). Never remove either. +- **No new configuration** (spec 4.10). `MAX_AUCTION_EIDS_BYTES` and the server limits are constants. +- TSJS budget: `MAX_AUCTION_EIDS_BYTES = 65,536`, measured in UTF-8 bytes with `TextEncoder`, never with JavaScript string length. +- Cookie: at most **3,072** encoded bytes; attributes exactly `Path=/; Secure; SameSite=Lax; Max-Age=86400`; standard padded base64 of the UTF-8 bytes of the JSON array; never EID-level `ext`. +- Server rules (spec 4.4): `source`, `inserter`, `matcher` are strings, not blank by `str::trim`, at most **255** UTF-8 bytes. `uids[].id` is a string, not blank, at most **512** bytes. `atype` and `mm` are integers from **0 to 2,147,483,647** (`4`, `4.0` and `4e0` are equal). UID-level and EID-level `ext` are JSON objects of at most **2,048** bytes as compact JSON. Keep the first **32** valid UIDs per entry and the first **64** entries after dropping duplicates. +- TSJS does no EID validation beyond serialization. The server validates body and cookie with one parser. +- Never log UID values, in TSJS or Rust. Logs carry source names, field names and counts only. +- Never read `bidderRequest.auctionId`. Prebid hides it from adapters by default. +- Use only example or fictional data in code, tests, comments and docs (`example.com` domains). The real Prebid source names the spec lists (`adserver.org`, `liveramp.com`, `pubcid.org`, `id5-sync.com`, `51d.es`) are public protocol identifiers and may appear in tests that model those modules. +- Test discipline (spec 7.1), checked in every task: + 1. **Red first.** Run each new test against unchanged code and see it fail for the expected reason. For a regression net that already passes, temporarily revert the line it guards, see it fail, then restore. Record every red result in the red log (see "Before you start"). + 2. **Pair absence with presence.** Every "X is missing" assertion sits next to a "control Y is present" assertion on the same request, cookie or output. + 3. **Compare EID payloads whole:** `toEqual` in Vitest, full `serde_json::Value` equality in Rust. No `objectContaining`, `toMatchObject`, `.contains()` or length-only checks on EID payloads. + 4. **Check the cookie environment.** Preset a cookie and read it back before asserting "cleared" or "unchanged". + 5. **Use raw JSON text** in Rust parser tests where spelling matters (`4.0`, `4e0`, `-0`, large numbers, lone surrogates, exact byte lengths). This is an intended exception to the `json!` preference in `AGENTS.md`. + 6. **Write Rust test literals that assert EID-level fields with every `Eid` field spelled out.** Never use `..Default::default()` there. +- Commits: sentence case, imperative, no `fix:`/`feat:` prefixes, no bracketed tags, `git commit --signoff -S`, multiple `-m` flags for paragraphs, no heredoc, no AI attribution lines. Commit only on the implementation branch. + +--- + +## Background + +Read spec sections 1 (defects D1 to D9), 3.2 (how Prebid builds a bidder's EIDs), 3.4 (current data flow), 3.7 (TCF and the `trustedServer` adapter) and 4 (architecture). In short: + +- The shim's `collectAuctionEids()` (`crates/trusted-server-js/lib/src/integrations/prebid/index.ts:1891-1914`) reads `pbjs.getUserIdsAsEids()`. That returns IDs restricted to other bidders and IDs Prebid redacted for `trustedServer` (D1). It also misses everything set through `ortb2` (D2), and drops `inserter`, `matcher`, `mm` and EID-level `ext` (D3). +- `buildRequests` (`index.ts:2357`) ignores its second argument, `bidderRequest`. Its `ortb2` is exactly what Prebid lets `trustedServer` see, already scoped and redacted. +- `syncPrebidEidsCookie()` (`index.ts:2982-3009`) rebuilds the cookie in `bidsBackHandler` from a different call (D5). +- `installUserIdModules()` (`index.ts:2881-2898`) forces `auctionDelay: 0` and other `userSync` values on `window.load` (D4). +- Rust: `Eid` (`crates/trusted-server-core/src/openrtb.rs:66-75`) has only `source` and `uids`. The body parser (`auction/endpoints.rs:491-562`) and cookie parser (`ec/prebid_eids.rs:33-47`, `:282-322`) validate differently, and one bad string fails the whole body (D8). `merge_auction_eids` (`auction/endpoints.rs:564-622`) folds by source with KV first (D9). Finalization ingests the cookie next to the body on `/auction` (`ec/finalize.rs:80-90`). + +The staged `fix/1246` work mentioned in older notes is superseded (spec O4). Do not commit it or build on it. Branch `fix/1246-collect-ortb2-eids` has no commits beyond `main`. + +## File map + +| File | Responsibility | Tasks | +| ------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------- | ---------- | +| `Cargo.toml` (workspace, line 105) | serde_json `raw_value` feature | 2 | +| `crates/trusted-server-core/src/openrtb.rs` | `Eid` fields, `PartialEq`, `Eid::new` | 1 | +| `crates/trusted-server-core/src/ec/client_eids.rs` (new) | The one parser for body and cookie entries | 2 | +| `crates/trusted-server-core/src/ec/mod.rs` | `pub(crate) mod client_eids;` | 2 | +| `crates/trusted-server-core/src/migration_guards.rs` | Fastly-SDK guard list entry | 2 | +| `crates/trusted-server-core/src/auction/formats.rs` | `AdRequest.eids` as raw JSON; `x-ts-eids` test | 1, 3 | +| `crates/trusted-server-core/src/auction/endpoints.rs` | Body/cookie resolution, merge, endpoint tests | 1, 3, 4, 5 | +| `crates/trusted-server-core/src/ec/prebid_eids.rs` | Cookie decode through the shared parser; diagnostics | 1, 3, 5 | +| `crates/trusted-server-core/src/ec/finalize.rs` | KV ingestion input selection | 1, 5 | +| `crates/trusted-server-core/src/publisher.rs` | Navigation/page-bids merge test | 4 | +| `crates/trusted-server-core/src/{ec/eids.rs, ec/identify.rs, auction/test_support.rs, integrations/prebid.rs}` | `Eid::new` call sites | 1 | +| `crates/trusted-server-core/src/integrations/{prebid.rs, aps.rs}`, `src/auction/openrtb/tests.rs` | Outbound serialization tests | 1 | +| `crates/trusted-server-adapter-fastly/src/app.rs` | Adapter-layer cookie extraction test | 5 | +| `crates/trusted-server-js/lib/test/fixtures/auction-body-eids.txt`, `ts-eids-cookie.txt` (new) | Cross-language fixtures | 3, 6, 7 | +| `crates/trusted-server-js/lib/src/integrations/prebid/eids.ts` (new) | Collector, cookie value fitting and encoding | 6 | +| `crates/trusted-server-js/lib/test/integrations/prebid/eids.test.ts` (new) | Unit tests for `eids.ts` | 6 | +| `crates/trusted-server-js/lib/src/core/auction.ts` | `AuctionEid` becomes `unknown` | 7 | +| `crates/trusted-server-js/lib/src/integrations/prebid/index.ts` | Adapter glue, TCF listener, deletions | 7, 8 | +| `crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts` | Canary, adapter tests, self-init tests | 7, 8 | +| `crates/trusted-server-js/lib/test/core/request.test.ts` | `requestAds` sends no `eids` | 7 | +| `crates/trusted-server-js/lib/test/prebid-eid-fidelity.test.mjs` (new) | Real-Prebid JSDOM arms (spec 7.5) | 9 | +| `crates/trusted-server-js/lib/src/integrations/prebid/user_id_modules.{ts,json}`, `test/integrations/prebid/user_id_modules.test.ts` | Delete resolver and `eidSources` (O6) | 10 | +| `docs/guide/integrations/prebid.md`, `lockr.md`, `edge-cookies.md`, `ec-setup-guide.md`, `api-reference.md`, `configuration.md` | Docs (spec 8.6) | 11 | +| `docs/superpowers/implementation-notes/2026-10-18-prebid-eid-fidelity.md` (new) | Red log (spec 7.1 rule 1, A12) | all | + +Commit order follows spec 8.1: Rust (Tasks 1 to 5), TSJS (6 to 8), the real-Prebid suite (9), registry (10), docs (11). All of it ships as one pull request. + +## Before you start + +- [ ] **Step 1: Create the implementation branch from the spec branch.** + +The spec and this plan live on `spec/1246-prebid-eid-fidelity`. They must be committed there before you branch. If `git status --short` in that branch's checkout still lists them as untracked, stop and ask the user to commit the spec PR first. + +Use superpowers:using-git-worktrees to create a worktree for a new branch `feat/1246-prebid-eid-fidelity` cut from `spec/1246-prebid-eid-fidelity`. All commands below run from that worktree's root unless a step says otherwise. + +- [ ] **Step 2: Confirm the baseline is green.** + +Run: + +```bash +cargo test-fastly +(cd crates/trusted-server-js/lib && npm ci && npx vitest run) +``` + +Expected: both PASS. If either fails, stop and report. Do not build on a red baseline. + +- [ ] **Step 3: Create the red log.** + +Create `docs/superpowers/implementation-notes/2026-10-18-prebid-eid-fidelity.md`: + +```markdown +# Prebid EID Fidelity Implementation Notes + +Red log for the tests this work adds or changes (spec 7.1 rule 1, A12). Each +entry names the test, the command, and the failure seen before the change, or, +for a regression net, the line temporarily reverted and the failure it caused. + +## Environment + +- Date: +- Branch: `feat/1246-prebid-eid-fidelity` +- Rust: +- Node: +``` + +Fill in the three values. Every "Run it and see it fail" step below appends a `## Task N` section with this shape: + +````markdown +## Task N: + +Command: + +```bash + +``` + +Failures: + +- ``: +```` + +Keep only the identifying line of each failure, not whole stack traces, and never paste UID values from real pages. + +--- + +## Rust test commands + +Core and Fastly adapter tests run on `wasm32-wasip1` under Viceroy through `cargo test-fastly`. **`cargo test-axum` does not run core tests.** To filter, put the name after `--`, for example `cargo test-fastly -- client_eids`. The filter applies to every test binary in the alias. + +--- + +### Task 1: `Eid` carries OpenRTB 2.6 EID-level fields + +Spec: 4.4 (last paragraph), 4.7, 7.4 ("Serialization", "`auction/formats.rs`"), A5, A11. + +**Files:** + +- Modify: `crates/trusted-server-core/src/openrtb.rs:66-88` (`Eid`, `Uid`) and its tests module (`:193`) +- Modify: `crates/trusted-server-core/src/auction/test_support.rs` (shared EID fixture helpers) +- Modify: every `Eid { … }` struct literal (26 literals in 9 files, listed in Step 5) +- Test: `crates/trusted-server-core/src/openrtb.rs`, `src/auction/formats.rs`, `src/integrations/prebid.rs`, `src/integrations/aps.rs`, `src/auction/openrtb/tests.rs` + +**Interfaces:** + +- Produces: + + ```rust + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] + #[serde(deny_unknown_fields)] + pub struct Eid { + pub source: String, + pub uids: Vec, + pub inserter: Option, + pub matcher: Option, + pub mm: Option, + pub ext: Option, + } + impl Eid { pub fn new(source: String, uids: Vec) -> Self } + // Uid gains PartialEq; its fields are unchanged. + ``` + + Test helpers in `crate::auction::test_support`: `repeated_source_provenance_eids() -> Vec` and `repeated_source_provenance_eids_json() -> serde_json::Value`. + +- [ ] **Step 1: Add the shared test helpers.** + +Append to `crates/trusted-server-core/src/auction/test_support.rs`. Add `use serde_json::json;` to its imports if it is not there. `Eid` and `Uid` are already imported, since line 95 builds an `Eid`. + +```rust +/// Two entries for one source with different EID-level fields, the way +/// Prebid sends them (spec 3.2), for outbound serialization tests. +pub(crate) fn repeated_source_provenance_eids() -> Vec { + vec![ + Eid { + source: "ids.example.com".to_string(), + uids: vec![Uid { + id: "fictional-uid".to_string(), + atype: Some(1), + ext: None, + }], + inserter: Some("inserter-a.example.com".to_string()), + matcher: Some("ids.example.com".to_string()), + mm: Some(4), + ext: None, + }, + Eid { + source: "ids.example.com".to_string(), + uids: vec![Uid { + id: "fictional-uid".to_string(), + atype: Some(1), + ext: None, + }], + inserter: Some("inserter-b.example.com".to_string()), + matcher: Some("matcher-b.example.com".to_string()), + mm: Some(2), + ext: Some(json!({ "segments": ["s1"] })), + }, + ] +} + +/// [`repeated_source_provenance_eids`] as the JSON every outbound path must +/// emit in `user.ext.eids`. +pub(crate) fn repeated_source_provenance_eids_json() -> serde_json::Value { + json!([ + { + "source": "ids.example.com", + "uids": [{ "id": "fictional-uid", "atype": 1 }], + "inserter": "inserter-a.example.com", + "matcher": "ids.example.com", + "mm": 4 + }, + { + "source": "ids.example.com", + "uids": [{ "id": "fictional-uid", "atype": 1 }], + "inserter": "inserter-b.example.com", + "matcher": "matcher-b.example.com", + "mm": 2, + "ext": { "segments": ["s1"] } + } + ]) +} +``` + +- [ ] **Step 2: Write the failing tests.** + +In the `tests` module of `src/openrtb.rs` (add `use serde_json::json;` if missing): + +```rust + #[test] + fn eid_serializes_openrtb_provenance_fields_when_present() { + let eid = Eid { + source: "ids.example.com".to_owned(), + uids: vec![Uid { + id: "uid-1".to_owned(), + atype: Some(1), + ext: Some(json!({ "rtiPartner": "TDID" })), + }], + inserter: Some("inserter.example.com".to_owned()), + matcher: Some("matcher.example.com".to_owned()), + mm: Some(4), + ext: Some(json!({ "segments": ["s1"] })), + }; + + let serialized = serde_json::to_value(&eid).expect("should serialize EID"); + + assert_eq!( + serialized, + json!({ + "source": "ids.example.com", + "uids": [{ "id": "uid-1", "atype": 1, "ext": { "rtiPartner": "TDID" } }], + "inserter": "inserter.example.com", + "matcher": "matcher.example.com", + "mm": 4, + "ext": { "segments": ["s1"] } + }), + "should serialize every EID-level field" + ); + } + + #[test] + fn eid_without_provenance_fields_serializes_as_before() { + let eid = Eid::new( + "ids.example.com".to_owned(), + vec![Uid { + id: "uid-1".to_owned(), + atype: Some(1), + ext: None, + }], + ); + + let serialized = serde_json::to_string(&eid).expect("should serialize EID"); + + assert_eq!( + serialized, r#"{"source":"ids.example.com","uids":[{"id":"uid-1","atype":1}]}"#, + "an EID without EID-level fields should serialize byte-identically to before" + ); + } + + #[test] + fn eid_deserializes_provenance_fields_and_keeps_rejecting_unknown_keys() { + let parsed: Eid = serde_json::from_str( + r#"{"source":"ids.example.com","uids":[{"id":"uid-1"}],"inserter":"inserter.example.com","matcher":"matcher.example.com","mm":3,"ext":{"k":"v"}}"#, + ) + .expect("should deserialize EID-level fields"); + + assert_eq!( + parsed, + Eid { + source: "ids.example.com".to_owned(), + uids: vec![Uid { + id: "uid-1".to_owned(), + atype: None, + ext: None, + }], + inserter: Some("inserter.example.com".to_owned()), + matcher: Some("matcher.example.com".to_owned()), + mm: Some(3), + ext: Some(json!({ "k": "v" })), + }, + "should read every EID-level field" + ); + assert!( + serde_json::from_str::(r#"{"source":"ids.example.com","uids":[],"unknown":1}"#) + .is_err(), + "should keep deny_unknown_fields" + ); + } +``` + +In the `tests` module of `src/auction/formats.rs` (it already imports `Eid`, `Uid` and `json`; add `use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};` and `use crate::auction::test_support::{repeated_source_provenance_eids, repeated_source_provenance_eids_json};` if missing). Put it next to `response_includes_eid_headers_when_eids_present` (`:813`): + +```rust + #[test] + fn eid_header_carries_provenance_fields_and_repeated_sources() { + let mut request = make_auction_request(); + request.user.eids = Some(repeated_source_provenance_eids()); + + let response = + convert_to_openrtb_response(&make_empty_result(), &make_settings(), &request, true) + .expect("should build response"); + + let header = response + .headers() + .get(&HEADER_X_TS_EIDS) + .and_then(|value| value.to_str().ok()) + .expect("should include x-ts-eids"); + let decoded: serde_json::Value = serde_json::from_slice( + &BASE64.decode(header).expect("should decode x-ts-eids base64"), + ) + .expect("should parse x-ts-eids JSON"); + assert_eq!( + decoded, + repeated_source_provenance_eids_json(), + "x-ts-eids should show both entries with their own EID-level fields" + ); + } +``` + +In `src/integrations/prebid.rs` tests, next to `to_openrtb_includes_eids_from_auction_request` (`:6862`), importing the two helpers from `crate::auction::test_support`: + +```rust + #[test] + fn to_openrtb_forwards_eid_provenance_fields_and_repeated_sources() { + let provider = PrebidAuctionProvider::new(base_config()); + let mut auction_request = create_test_auction_request(); + auction_request.user.eids = Some(repeated_source_provenance_eids()); + let settings = make_settings(); + let request = build_test_request(); + let context = create_test_auction_context(&settings, &request); + + let openrtb = provider.to_openrtb( + &auction_request, + &context, + None, + make_request_info(&context), + ); + + let serialized = serde_json::to_value(&openrtb).expect("should serialize OpenRTB request"); + assert_eq!( + serialized["user"]["ext"]["eids"], + repeated_source_provenance_eids_json(), + "Prebid Server should receive both entries with their own EID-level fields" + ); + } +``` + +In `src/integrations/aps.rs` tests, next to `builds_aps_openrtb_request_with_explicit_privacy_policy` (`:2309`): + +```rust + #[test] + fn aps_request_forwards_eid_provenance_fields_and_repeated_sources() { + let provider = ApsAuctionProvider::new(config()); + let mut auction_request = canonical_parity_auction_request(); + auction_request.user.eids = Some(repeated_source_provenance_eids()); + let settings = create_test_settings(); + let services = noop_services(); + let downstream = http::Request::builder() + .uri("https://publisher.example/auction") + .body(EdgeBody::empty()) + .expect("should build downstream request"); + let context = AuctionContext { + settings: &settings, + request: &downstream, + timeout_ms: 321, + transport_timeout_ms: 321, + provider_responses: None, + services: &services, + }; + + let openrtb = provider.build_openrtb_request(&auction_request, &context); + + let serialized = serde_json::to_value(&openrtb).expect("should serialize request"); + assert_eq!( + serialized["user"]["ext"]["eids"], + repeated_source_provenance_eids_json(), + "APS should receive both entries with their own EID-level fields" + ); + } +``` + +In `src/auction/openrtb/tests.rs` (the config-first path, `auction/openrtb.rs:372`): + +```rust +#[test] +fn standard_profile_forwards_eid_provenance_fields_and_repeated_sources() { + let mut request = canonical_parity_auction_request(); + request.user.eids = Some(crate::auction::test_support::repeated_source_provenance_eids()); + + let built = build_with_request("standard", json!({}), request, None); + + let serialized = serde_json::to_value(&built).expect("should serialize request"); + assert_eq!( + serialized["user"]["ext"]["eids"], + crate::auction::test_support::repeated_source_provenance_eids_json(), + "config-first providers should receive both entries with their own EID-level fields" + ); +} +``` + +- [ ] **Step 3: Run them and see them fail.** + +Run: `cargo test-fastly --no-run 2>&1 | grep -E "^error" | sort | uniq -c` +Expected: compile errors `struct 'Eid' has no field named 'inserter'` (also `matcher`, `mm`, `ext`), `no function or associated item named 'new'`, and `binary operation '==' cannot be applied to type 'Eid'`. Record them in the red log under `## Task 1`. + +- [ ] **Step 4: Extend `Eid` and `Uid`.** + +Replace `Eid` and the derive line of `Uid` in `src/openrtb.rs`: + +````rust +/// An Extended User ID entry from an identity provider. +/// +/// Mirrors `OpenRTB` 2.6 §3.2.27. Browser entries keep the EID-level fields +/// Prebid set; KV-resolved entries leave them empty. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Eid { + /// Identity provider domain (e.g. `"id5-sync.com"`). + pub source: String, + /// One or more user IDs from this provider. + pub uids: Vec, + /// Entity that inserted the IDs into the request (`eid.inserter`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub inserter: Option, + /// Entity that matched the IDs (`eid.matcher`). Absent means `source`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub matcher: Option, + /// Match method (`eid.mm`). Vendor values from 500 up are passed through. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub mm: Option, + /// Provider-specific extension data, e.g. `LiveIntent` `segments`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ext: Option, +} + +impl Eid { + /// Builds an [`Eid`] without EID-level fields. + /// + /// Use this for EIDs that never carry provenance, such as KV-resolved + /// partner IDs. + /// + /// # Examples + /// + /// ``` + /// use trusted_server_core::openrtb::{Eid, Uid}; + /// + /// let eid = Eid::new( + /// "ids.example.com".to_owned(), + /// vec![Uid { id: "uid-1".to_owned(), atype: Some(1), ext: None }], + /// ); + /// assert_eq!(eid.inserter, None); + /// ``` + #[must_use] + pub fn new(source: String, uids: Vec) -> Self { + Self { + source, + uids, + inserter: None, + matcher: None, + mm: None, + ext: None, + } + } +} +```` + +and change `Uid`'s derive to `#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]`. + +- [ ] **Step 5: Replace every struct literal with `Eid::new`.** + +Run: `cargo test-fastly --no-run 2>&1 | grep -E "missing fields .*inserter" -A3` + +Convert each flagged `Eid { source: S, uids: U }` to `Eid::new(S, U)`. Do not add values. The compiler should flag these 26 literals (`git grep -n "Eid {" crates/trusted-server-core/src` lists them): + +| File | Lines | Kind | +| ------------------------- | ---------------------------------------- | --------------------------------------------- | +| `auction/endpoints.rs` | 533, 585 | production (rewritten again in Tasks 3 and 4) | +| `auction/endpoints.rs` | 1657, 1666, 1674, 1695, 1703, 1722, 1730 | tests (rewritten in Task 4) | +| `auction/formats.rs` | 816 | test | +| `auction/test_support.rs` | 95 | test helper | +| `ec/eids.rs` | 67 | production (`to_eids`) | +| `ec/identify.rs` | 127 | production | +| `ec/finalize.rs` | 1118, 1520, 1528, 1598 | tests | +| `ec/prebid_eids.rs` | 297, 328 | production (297 is deleted in Task 3) | +| `ec/prebid_eids.rs` | 639, 647 | tests | +| `integrations/prebid.rs` | 6866, 6874, 6882 | tests | +| `openrtb.rs` | 396, 420 | tests | + +For example, `ec/eids.rs:67` becomes: + +```rust + .map(|item| { + Eid::new( + item.source_domain.clone(), + vec![Uid { + id: item.uid.clone(), + atype: Some(item.openrtb_atype), + ext: None, + }], + ) + }) +``` + +and `auction/endpoints.rs:585` becomes `merged.push(Eid::new(eid.source.clone(), Vec::new()));`. `trusted-server-openrtb/src/generated.rs` has its own, unrelated `Eid`. Leave it alone. + +Run `cargo check-axum && cargo check-cloudflare` as well, since target-gated code may hold more literals. + +- [ ] **Step 6: Run the tests.** + +Run: `cargo test-fastly -- eid` +Expected: PASS, including the five new tests. + +Then run the golden serialization tests unchanged (A5): `cargo test-fastly -- golden wire_fixture builds_aps_openrtb_request` +Expected: PASS with no edits to `integrations/prebid.rs:9994`, `:10033`, `integrations/aps.rs:2400` or the fixtures in `auction/openrtb/tests.rs`. If any golden string needs editing, stop: serialization is no longer byte-identical. + +- [ ] **Step 7: Gates and commit.** + +Run: `cargo fmt --all -- --check && cargo clippy-fastly && cargo clippy-axum && cargo test-fastly` +Expected: clean. + +```bash +git add crates/trusted-server-core/src docs/superpowers/implementation-notes/2026-10-18-prebid-eid-fidelity.md +git commit --signoff -S -m "Carry OpenRTB EID-level fields on Eid" -m "Eid gains inserter, matcher, mm and ext, each skipped when absent, so serialization without them is byte-identical. Eid::new replaces the struct literals that never carry these fields." +``` + +--- + +### Task 2: One parser for browser EIDs + +Spec: 4.4 (the rules table and notes), 7.1 rules 7 and 10, 7.4 (`ec/client_eids.rs`, `migration_guards.rs`). + +**Files:** + +- Modify: `Cargo.toml:105` (workspace) +- Create: `crates/trusted-server-core/src/ec/client_eids.rs` +- Modify: `crates/trusted-server-core/src/ec/mod.rs:32-51` (module list) +- Modify: `crates/trusted-server-core/src/migration_guards.rs:76-92` (guard list) + +**Interfaces:** + +- Consumes: `Eid`, `Uid` (Task 1); `crate::ec::kv_types::MAX_UID_LENGTH` (512). +- Produces, in `crate::ec::client_eids`: + + ```rust + pub(crate) const MAX_CLIENT_EID_ENTRIES: usize = 64; + pub(crate) const MAX_CLIENT_UIDS_PER_ENTRY: usize = 32; + pub(crate) const MAX_CLIENT_EID_LABEL_BYTES: usize = 255; + pub(crate) const MAX_CLIENT_EID_EXT_BYTES: usize = 2048; + pub(crate) fn parse_client_eids(raw: &serde_json::value::RawValue) -> Vec; + ``` + + Task 3 calls `parse_client_eids` for the body and the cookie. Per spec 8.4, if PR #1382 lands first, this becomes `pub`. + +- [ ] **Step 1: Enable `raw_value` and add the module with a stub.** + +In the workspace `Cargo.toml`, change line 105 from `serde_json = "1.0.149"` to: + +```toml +serde_json = { version = "1.0.149", features = ["raw_value"] } +``` + +In `src/ec/mod.rs`, add `pub(crate) mod client_eids;` in alphabetical order, after `pub mod batch_sync;`. + +In `src/migration_guards.rs`, add after the `"ec/batch_sync.rs"` entry: + +```rust + ("ec/client_eids.rs", include_str!("ec/client_eids.rs")), +``` + +Create `src/ec/client_eids.rs` with the module doc, the constants and a stub, so the tests in Step 2 compile and fail on their assertions: + +```rust +//! Parsing and validation of browser-supplied EIDs. +//! +//! The `/auction` request body and the `ts-eids` cookie carry the same +//! OpenRTB-style EID array, which TSJS copies from the EIDs Prebid placed in +//! the `trustedServer` bidder request. Both go through [`parse_client_eids`], +//! so a malformed entry or field costs only itself, whichever transport or +//! client sent it. +//! +//! Rules, per entry: +//! +//! - The entry parses on its own and is a JSON object, or it is dropped. +//! - `source` is a string, not blank, at most [`MAX_CLIENT_EID_LABEL_BYTES`] +//! UTF-8 bytes, or the entry is dropped. +//! - `uids` holds at least one valid UID, or the entry is dropped. The first +//! [`MAX_CLIENT_UIDS_PER_ENTRY`] valid UIDs are kept. +//! - `uids[].id` is a string, not blank, at most `MAX_UID_LENGTH` bytes, or +//! the UID is dropped. `uids[].atype` is an integer from 0 to `i32::MAX`; +//! `uids[].ext` is an object of at most [`MAX_CLIENT_EID_EXT_BYTES`] as +//! compact JSON. An invalid `atype` or `ext` drops only that field. +//! - `inserter` and `matcher` follow the `source` rule, `mm` the `atype` rule +//! and `ext` the UID `ext` rule. An invalid value drops only that field. +//! - Unknown keys are ignored. Order is preserved and a source may repeat. +//! +//! An entry equal to an earlier kept entry is dropped, and then the first +//! [`MAX_CLIENT_EID_ENTRIES`] entries are kept. "Blank" means empty or only +//! whitespace by `str::trim`. "Integer" means a JSON number with no fractional +//! part, so `4`, `4.0` and `4e0` are the same value. + +use serde_json::Value as JsonValue; +use serde_json::value::RawValue; + +use crate::openrtb::{Eid, Uid}; + +use super::kv_types::MAX_UID_LENGTH; + +/// Maximum number of entries kept from one browser EID array. +pub(crate) const MAX_CLIENT_EID_ENTRIES: usize = 64; + +/// Maximum number of valid UIDs kept per entry. +pub(crate) const MAX_CLIENT_UIDS_PER_ENTRY: usize = 32; + +/// Maximum UTF-8 length of `source`, `inserter` and `matcher`. +pub(crate) const MAX_CLIENT_EID_LABEL_BYTES: usize = 255; + +/// Maximum compact-JSON length of an EID-level or UID-level `ext` object. +pub(crate) const MAX_CLIENT_EID_EXT_BYTES: usize = 2048; + +/// Parses and validates browser-supplied EID entries from an `/auction` body +/// or a `ts-eids` cookie. +/// +/// See the module documentation for the rules. +#[must_use] +pub(crate) fn parse_client_eids(raw: &RawValue) -> Vec { + let _ = (raw, MAX_UID_LENGTH); + Vec::new() +} +``` + +The stub's `let _ = …` keeps the imports used. Step 4 replaces it. + +- [ ] **Step 2: Write the failing tests.** + +Append to `src/ec/client_eids.rs`. Inputs whose JSON spelling matters are raw text (spec 7.1 rule 7). Expected outputs are full `serde_json::Value`s. + +```rust +#[cfg(test)] +mod tests { + use std::sync::{Mutex, Once}; + + use serde_json::json; + + use super::*; + + fn parse(text: &str) -> Vec { + let raw = RawValue::from_string(text.to_owned()).expect("should capture raw JSON"); + parse_client_eids(&raw) + } + + fn parsed_json(text: &str) -> JsonValue { + serde_json::to_value(parse(text)).expect("should serialize parsed EIDs") + } + + fn good(source: &str) -> String { + format!(r#"{{"source":"{source}","uids":[{{"id":"uid-{source}"}}]}}"#) + } + + fn good_json(source: &str) -> JsonValue { + json!({ "source": source, "uids": [{ "id": format!("uid-{source}") }] }) + } + + /// An `ext` object whose compact JSON is exactly `bytes` long. + fn ext_of_len(bytes: usize) -> String { + // `{"k":""}` is 8 bytes. + format!(r#"{{"k":"{}"}}"#, "x".repeat(bytes - 8)) + } + + static CAPTURED_LOGS: Mutex> = Mutex::new(Vec::new()); + static INSTALL_LOGGER: Once = Once::new(); + + struct CapturingLogger; + + impl log::Log for CapturingLogger { + fn enabled(&self, metadata: &log::Metadata<'_>) -> bool { + metadata.level() <= log::Level::Debug + } + + fn log(&self, record: &log::Record<'_>) { + if self.enabled(record.metadata()) { + CAPTURED_LOGS + .lock() + .expect("should lock captured logs") + .push(record.args().to_string()); + } + } + + fn flush(&self) {} + } + + fn install_capturing_logger() { + INSTALL_LOGGER.call_once(|| { + log::set_logger(&CapturingLogger).expect("should install the capturing test logger"); + log::set_max_level(log::LevelFilter::Debug); + }); + } + + fn captured_logs() -> Vec { + CAPTURED_LOGS + .lock() + .expect("should lock captured logs") + .clone() + } + + #[test] + fn keeps_a_valid_entry_with_every_field() { + let parsed = parsed_json( + r#"[{"source":"ids.example.com","inserter":"inserter.example.com","matcher":"matcher.example.com","mm":4,"ext":{"segments":["s1"]},"uids":[{"id":"uid-1","atype":1,"ext":{"rtiPartner":"TDID"}}]}]"#, + ); + + assert_eq!( + parsed, + json!([{ + "source": "ids.example.com", + "inserter": "inserter.example.com", + "matcher": "matcher.example.com", + "mm": 4, + "ext": { "segments": ["s1"] }, + "uids": [{ "id": "uid-1", "atype": 1, "ext": { "rtiPartner": "TDID" } }] + }]), + "should keep every valid field" + ); + } + + #[test] + fn yields_nothing_for_a_payload_that_is_not_an_array() { + for text in [r#"{"source":"ids.example.com"}"#, r#""text""#, "null", "1", "true"] { + assert_eq!(parsed_json(text), json!([]), "should ignore non-array payload {text}"); + } + } + + #[test] + fn drops_only_the_entry_that_does_not_parse() { + // `\ud800` is a lone surrogate escape: serde_json rejects it when the + // element is parsed on its own. + let bad_entries = [ + r#"{"source":"bad-\ud800.example.com","uids":[{"id":"u"}]}"#, + r#"{"source":"bad.example.com","uids":[{"id":"u-\ud800"}]}"#, + r#"{"source":"bad.example.com","inserter":"i-\ud800","uids":[{"id":"u"}]}"#, + r#"{"source":"bad.example.com","matcher":"m-\ud800","uids":[{"id":"u"}]}"#, + r#"{"source":"bad.example.com","ext":{"k-\ud800":1},"uids":[{"id":"u"}]}"#, + r#"{"source":"bad.example.com","ext":{"k":"v-\ud800"},"uids":[{"id":"u"}]}"#, + r#"{"source":"bad.example.com","uids":[{"id":"u","ext":{"k-\ud800":1}}]}"#, + r#"{"source":"bad.example.com","uids":[{"id":"u","ext":{"k":"v-\ud800"}}]}"#, + ]; + for bad in bad_entries { + let text = format!("[{},{bad},{}]", good("a.example.com"), good("b.example.com")); + + assert_eq!( + parsed_json(&text), + json!([good_json("a.example.com"), good_json("b.example.com")]), + "should drop only the unparsable entry {bad}" + ); + } + } + + #[test] + fn drops_entries_that_are_not_objects() { + let text = format!(r#"[1,"text",null,[],true,{}]"#, good("a.example.com")); + + assert_eq!( + parsed_json(&text), + json!([good_json("a.example.com")]), + "should keep only the object entry" + ); + } + + #[test] + fn validates_source() { + let source_255 = format!("a{}", "é".repeat(127)); + let source_256 = "é".repeat(128); + let text = format!( + r#"[{{"uids":[{{"id":"u"}}]}},{{"source":7,"uids":[{{"id":"u"}}]}},{{"source":" ","uids":[{{"id":"u"}}]}},{{"source":"{source_256}","uids":[{{"id":"u"}}]}},{{"source":"{source_255}","uids":[{{"id":"u"}}]}}]"# + ); + + assert_eq!( + parsed_json(&text), + json!([{ "source": source_255, "uids": [{ "id": "u" }] }]), + "should keep only a non-blank string source of at most 255 UTF-8 bytes" + ); + } + + #[test] + fn drops_an_entry_without_a_valid_uid() { + let text = format!( + r#"[{{"source":"no-uids.example.com"}},{{"source":"string-uids.example.com","uids":"u"}},{{"source":"empty-uids.example.com","uids":[]}},{{"source":"bad-uids.example.com","uids":[{{"id":""}},7]}},{}]"#, + good("a.example.com") + ); + + assert_eq!( + parsed_json(&text), + json!([good_json("a.example.com")]), + "should drop entries whose uids hold no valid UID" + ); + } + + #[test] + fn validates_uid_ids() { + let id_512 = "x".repeat(512); + let id_513 = "x".repeat(513); + let text = format!( + r#"[{{"source":"ids.example.com","uids":[{{"id":7}},{{"id":" "}},{{"id":"{id_513}"}},{{"atype":1}},{{"id":"{id_512}"}}]}}]"# + ); + + assert_eq!( + parsed_json(&text), + json!([{ "source": "ids.example.com", "uids": [{ "id": id_512 }] }]), + "should keep only a non-blank string id of at most 512 bytes" + ); + } + + #[test] + fn keeps_the_first_32_valid_uids() { + let uids: Vec = [r#"{"id":""}"#.to_owned(), r#"{"id":7}"#.to_owned()] + .into_iter() + .chain((0..33).map(|index| format!(r#"{{"id":"u{index}"}}"#))) + .collect(); + let text = format!(r#"[{{"source":"ids.example.com","uids":[{}]}}]"#, uids.join(",")); + + let expected_uids: Vec = (0..32).map(|index| json!({ "id": format!("u{index}") })).collect(); + assert_eq!( + parsed_json(&text), + json!([{ "source": "ids.example.com", "uids": expected_uids }]), + "should keep the first 32 valid UIDs, not counting invalid ones" + ); + } + + #[test] + fn reads_atype_and_mm_as_integers_in_range() { + let accepted = [ + ("4", 4), + ("4.0", 4), + ("4e0", 4), + ("-0", 0), + ("0", 0), + ("2147483647", 2_147_483_647), + ("2147483647.0", 2_147_483_647), + ("571187", 571_187), + ]; + for (spelling, value) in accepted { + let text = format!( + r#"[{{"source":"ids.example.com","mm":{spelling},"uids":[{{"id":"u","atype":{spelling}}}]}}]"# + ); + + assert_eq!( + parsed_json(&text), + json!([{ "source": "ids.example.com", "mm": value, "uids": [{ "id": "u", "atype": value }] }]), + "should accept {spelling} as {value}" + ); + } + + let rejected = [ + "2147483648", + "9007199254740993", + "18446744073709551616", + "1.5", + "-1", + "1e300", + r#""1""#, + "null", + "true", + ]; + for spelling in rejected { + let text = format!( + r#"[{{"source":"ids.example.com","mm":{spelling},"uids":[{{"id":"u","atype":{spelling}}}]}}]"# + ); + + assert_eq!( + parsed_json(&text), + json!([{ "source": "ids.example.com", "uids": [{ "id": "u" }] }]), + "should drop only the fields for {spelling}" + ); + } + } + + #[test] + fn validates_ext_objects_and_their_size() { + let ext_2048 = ext_of_len(2048); + let ext_2049 = ext_of_len(2049); + let text = format!( + r#"[{{"source":"keep.example.com","ext":{ext_2048},"uids":[{{"id":"u","ext":{ext_2048}}}]}},{{"source":"big.example.com","ext":{ext_2049},"uids":[{{"id":"u","ext":{ext_2049}}}]}},{{"source":"shape.example.com","ext":[1],"uids":[{{"id":"u","ext":"text"}}]}}]"# + ); + let kept_ext: JsonValue = serde_json::from_str(&ext_2048).expect("should parse ext"); + + assert_eq!( + parsed_json(&text), + json!([ + { "source": "keep.example.com", "ext": kept_ext, "uids": [{ "id": "u", "ext": kept_ext }] }, + { "source": "big.example.com", "uids": [{ "id": "u" }] }, + { "source": "shape.example.com", "uids": [{ "id": "u" }] } + ]), + "should keep objects of at most 2048 compact bytes and drop only the bad fields" + ); + } + + #[test] + fn validates_inserter_and_matcher() { + let label_255 = format!("a{}", "é".repeat(127)); + let label_256 = "é".repeat(128); + let text = format!( + r#"[{{"source":"keep.example.com","inserter":"{label_255}","matcher":"{label_255}","uids":[{{"id":"u"}}]}},{{"source":"drop.example.com","inserter":"{label_256}","matcher":" ","uids":[{{"id":"u"}}]}},{{"source":"type.example.com","inserter":7,"matcher":null,"uids":[{{"id":"u"}}]}}]"# + ); + + assert_eq!( + parsed_json(&text), + json!([ + { "source": "keep.example.com", "inserter": label_255, "matcher": label_255, "uids": [{ "id": "u" }] }, + { "source": "drop.example.com", "uids": [{ "id": "u" }] }, + { "source": "type.example.com", "uids": [{ "id": "u" }] } + ]), + "should keep non-blank strings of at most 255 bytes and drop only the bad fields" + ); + } + + #[test] + fn drops_duplicates_before_the_entry_cap() { + let mut entries: Vec = (0..64) + .map(|index| good(&format!("s{index}.example.com"))) + .collect(); + // Equal to entry 0 once validated: unknown keys are ignored. + entries.insert( + 1, + r#"{"source":"s0.example.com","uids":[{"id":"uid-s0.example.com"}],"unknown":true}"# + .to_owned(), + ); + let text = format!("[{}]", entries.join(",")); + + let expected: Vec = (0..64) + .map(|index| good_json(&format!("s{index}.example.com"))) + .collect(); + assert_eq!( + parsed_json(&text), + JsonValue::Array(expected), + "a duplicate should not take one of the 64 places" + ); + } + + #[test] + fn keeps_the_first_64_entries() { + let entries: Vec = (0..70) + .map(|index| good(&format!("s{index}.example.com"))) + .collect(); + let text = format!("[{}]", entries.join(",")); + + let expected: Vec = (0..64) + .map(|index| good_json(&format!("s{index}.example.com"))) + .collect(); + assert_eq!(parsed_json(&text), JsonValue::Array(expected), "should keep the first 64 entries"); + } + + #[test] + fn keeps_order_and_repeated_sources_and_ignores_unknown_keys() { + let parsed = parsed_json( + r#"[{"source":"b.example.com","uids":[{"id":"u","unknown":1}],"unknown":2},{"source":"a.example.com","mm":1,"uids":[{"id":"u"}]},{"source":"a.example.com","mm":2,"uids":[{"id":"u"}]}]"#, + ); + + assert_eq!( + parsed, + json!([ + { "source": "b.example.com", "uids": [{ "id": "u" }] }, + { "source": "a.example.com", "mm": 1, "uids": [{ "id": "u" }] }, + { "source": "a.example.com", "mm": 2, "uids": [{ "id": "u" }] } + ]), + "should keep wire order and both same-source entries" + ); + } + + #[test] + fn logs_dropped_entries_and_fields_by_name_and_source_without_values() { + install_capturing_logger(); + + let parsed = parsed_json( + r#"[{"source":"log-a.example.com","mm":-1,"uids":[{"id":"secret-uid-a","atype":"1"}]},{"source":"log-b.example.com","uids":[{"id":""}]}]"#, + ); + + assert_eq!( + parsed, + json!([{ "source": "log-a.example.com", "uids": [{ "id": "secret-uid-a" }] }]), + "should keep the entry and drop only its invalid fields" + ); + let logs = captured_logs(); + for (field, source) in [ + ("`mm`", "log-a.example.com"), + ("`atype`", "log-a.example.com"), + ("without a valid UID", "log-b.example.com"), + ] { + assert!( + logs.iter().any(|line| line.contains(field) && line.contains(source)), + "should log {field} for {source}; captured: {logs:?}" + ); + } + assert!( + !logs.iter().any(|line| line.contains("secret-uid-a")), + "should never log a UID value" + ); + } +} +``` + +The logger is process-global. Only this module installs one, and each assertion matches on a source name unique to its test, so parallel tests do not interfere. + +- [ ] **Step 3: Run them and see them fail.** + +Run: `cargo test-fastly -- client_eids` +Expected: every test except `yields_nothing_for_a_payload_that_is_not_an_array` FAILS, because the stub returns no entries. Record the failures. That one passing test is a regression net: Step 4 must keep it passing. + +- [ ] **Step 4: Implement the parser.** + +Replace the stub `parse_client_eids` with: + +```rust +/// Parses and validates browser-supplied EID entries from an `/auction` body +/// or a `ts-eids` cookie. +/// +/// See the module documentation for the rules. Anything other than a JSON +/// array yields no entries. +/// +/// # Performance +/// +/// Parses each element into a [`JsonValue`] once. Duplicate detection is +/// quadratic in the kept entries, which are capped at +/// [`MAX_CLIENT_EID_ENTRIES`]. +#[must_use] +pub(crate) fn parse_client_eids(raw: &RawValue) -> Vec { + // Capturing elements as raw JSON does not validate string escapes, so an + // element holding a lone surrogate fails only its own parse below. + let Ok(elements) = serde_json::from_str::>>(raw.get()) else { + log::debug!("Client EIDs: payload is not a JSON array; ignoring it"); + return Vec::new(); + }; + + let mut eids: Vec = Vec::new(); + for element in &elements { + if eids.len() >= MAX_CLIENT_EID_ENTRIES { + log::debug!( + "Client EIDs: keeping the first {MAX_CLIENT_EID_ENTRIES} entries; dropping the rest" + ); + break; + } + let Some(eid) = parse_client_eid(element) else { + continue; + }; + if eids.contains(&eid) { + log::debug!( + "Client EIDs: dropping a duplicate entry for source '{}'", + eid.source + ); + continue; + } + eids.push(eid); + } + eids +} + +fn parse_client_eid(element: &RawValue) -> Option { + let entry = match serde_json::from_str::(element.get()) { + Ok(JsonValue::Object(entry)) => entry, + Ok(_) => { + log::debug!("Client EIDs: dropping an entry that is not a JSON object"); + return None; + } + Err(_) => { + log::debug!("Client EIDs: dropping an entry that does not parse"); + return None; + } + }; + + let Some(source) = entry.get("source").and_then(parse_client_eid_label) else { + log::debug!("Client EIDs: dropping an entry without a valid `source`"); + return None; + }; + + let uids: Vec = match entry.get("uids") { + Some(JsonValue::Array(raw_uids)) => raw_uids + .iter() + .filter_map(|raw_uid| parse_client_uid(raw_uid, &source)) + .take(MAX_CLIENT_UIDS_PER_ENTRY) + .collect(), + _ => Vec::new(), + }; + if uids.is_empty() { + log::debug!("Client EIDs: dropping source '{source}' without a valid UID"); + return None; + } + + let inserter = parse_optional_field(&entry, "EID", "inserter", &source, parse_client_eid_label); + let matcher = parse_optional_field(&entry, "EID", "matcher", &source, parse_client_eid_label); + let mm = parse_optional_field(&entry, "EID", "mm", &source, parse_client_eid_integer); + let ext = parse_optional_field(&entry, "EID", "ext", &source, parse_client_eid_ext); + + Some(Eid { + source, + uids, + inserter, + matcher, + mm, + ext, + }) +} + +fn parse_client_uid(raw: &JsonValue, source: &str) -> Option { + let JsonValue::Object(uid) = raw else { + log::debug!("Client EIDs: dropping a UID that is not an object for source '{source}'"); + return None; + }; + + let Some(id) = uid + .get("id") + .and_then(JsonValue::as_str) + .filter(|id| !id.trim().is_empty()) + .filter(|id| id.len() <= MAX_UID_LENGTH) + else { + log::debug!("Client EIDs: dropping a UID without a valid `id` for source '{source}'"); + return None; + }; + + Some(Uid { + id: id.to_owned(), + atype: parse_optional_field(uid, "UID", "atype", source, parse_client_eid_integer), + ext: parse_optional_field(uid, "UID", "ext", source, parse_client_eid_ext), + }) +} + +/// Applies `parse` to `key` when it is present, logging the field name and +/// source, never the value, when the value is invalid. +fn parse_optional_field( + object: &serde_json::Map, + scope: &str, + key: &str, + source: &str, + parse: impl Fn(&JsonValue) -> Option, +) -> Option { + let value = object.get(key)?; + let parsed = parse(value); + if parsed.is_none() { + log::debug!("Client EIDs: dropping invalid {scope} `{key}` for source '{source}'"); + } + parsed +} + +/// Reads `source`, `inserter` or `matcher`: a string that is not blank and at +/// most [`MAX_CLIENT_EID_LABEL_BYTES`] long, kept as supplied. +fn parse_client_eid_label(value: &JsonValue) -> Option { + value + .as_str() + .filter(|label| !label.trim().is_empty()) + .filter(|label| label.len() <= MAX_CLIENT_EID_LABEL_BYTES) + .map(str::to_owned) +} + +/// Reads `atype` or `mm`: a JSON number with no fractional part, from 0 to +/// `i32::MAX`. +fn parse_client_eid_integer(value: &JsonValue) -> Option { + let number = value.as_number()?; + if let Some(integer) = number.as_i64() { + return i32::try_from(integer).ok().filter(|integer| *integer >= 0); + } + if number.is_u64() { + // Above `i64::MAX`, so above the accepted range. + return None; + } + // serde_json reads `4.0`, `4e0` and `-0` as floats. + let float = number.as_f64()?; + if float.fract() != 0.0 || !(0.0..=f64::from(i32::MAX)).contains(&float) { + return None; + } + // Integral and within `0..=i32::MAX`, so the conversion is exact. + Some(float as i32) +} + +/// Reads an `ext`: a JSON object of at most [`MAX_CLIENT_EID_EXT_BYTES`] as +/// compact JSON. +fn parse_client_eid_ext(value: &JsonValue) -> Option { + if !value.is_object() { + return None; + } + let compact_len = serde_json::to_vec(value).ok()?.len(); + (compact_len <= MAX_CLIENT_EID_EXT_BYTES).then(|| value.clone()) +} +``` + +- [ ] **Step 5: Run the tests.** + +Run: `cargo test-fastly -- client_eids migration_guards` +Expected: PASS. If `drops_only_the_entry_that_does_not_parse` fails because serde_json accepted a lone surrogate, check that `Cargo.lock` still pins serde_json 1.0.150, then stop and report. The design depends on per-element rejection (spec D8). + +- [ ] **Step 6: Gates and commit.** + +Run: `cargo fmt --all -- --check && cargo clippy-fastly && cargo clippy-axum && cargo clippy-cloudflare && cargo clippy-spin-native && cargo test-fastly` +Expected: clean. `raw_value` is workspace-wide (spec 4.4 notes the one side effect), so lint every adapter. + +```bash +git add Cargo.toml crates/trusted-server-core/src/ec docs/superpowers/implementation-notes/2026-10-18-prebid-eid-fidelity.md crates/trusted-server-core/src/migration_guards.rs +git commit --signoff -S -m "Add one parser for browser-supplied EIDs" -m "ec::client_eids parses each array element on its own, so one malformed entry or field costs only itself. It validates sources, UIDs, atype, EID-level provenance fields and ext sizes, drops duplicates before the 64-entry cap, and logs field names and sources but never values." +``` + +--- + +### Task 3: Body and cookie go through the shared parser + +Spec: 4.4 ("Callers"), 7.1 rule 8, 7.4 (`ec/prebid_eids.rs`, `auction/endpoints.rs`), A4. + +**Files:** + +- Create: `crates/trusted-server-js/lib/test/fixtures/auction-body-eids.txt`, `crates/trusted-server-js/lib/test/fixtures/ts-eids-cookie.txt` +- Modify: `crates/trusted-server-core/src/auction/formats.rs:43-47` (`AdRequest.eids`) +- Modify: `crates/trusted-server-core/src/auction/endpoints.rs` (imports `:8-23`, constants `:38-40`, `handle_auction` `:293-300`, `resolve_client_auction_eids` and the old parser `:472-562`, tests) +- Modify: `crates/trusted-server-core/src/ec/prebid_eids.rs` (module doc `:1-9`, structs `:32-52`, `decode_prebid_eids_cookie` `:78-97`, `DecodedCookieEids` `:99-127`, `structured_cookie_*` `:282-322`, tests) + +**Interfaces:** + +- Consumes: `crate::ec::client_eids::parse_client_eids(&RawValue) -> Vec` (Task 2). +- Produces: + - `AdRequest { pub eids: Option>, .. }` + - `pub(crate) fn resolve_client_auction_eids(raw: Option<&RawValue>, cookie_value: Option<&str>) -> Option>`. An empty parse result counts as absent, so the cookie is used. Callers: `handle_auction` and `publisher.rs:5541`, which passes `None`. + - Fixture files that Tasks 6 and 7 assert TSJS reproduces exactly. + +- [ ] **Step 1: Create the cross-language fixtures.** + +The input behind both files is defined in Task 6 (`FIXTURE_EIDS`): three entries, with a lone surrogate in the middle entry's UID `id`, non-Latin-1 text in the first entry's `inserter` and UID `ext`, and an EID-level `ext` on the last entry. The `.txt` extension keeps Prettier from rewriting them (`crates/trusted-server-js/lib/package.json:15`). + +Create `crates/trusted-server-js/lib/test/fixtures/auction-body-eids.txt` with exactly this one line (471 bytes as UTF-8, no trailing newline). The `\ud800` is six literal ASCII characters: + +```text +{"adUnits":[{"code":"div-gpt-1","mediaTypes":{"banner":{"sizes":[[300,250]]}},"bids":[{"bidder":"trustedServer","params":{}}]}],"eids":[{"source":"first.example.com","inserter":"inserter-€.example.com","matcher":"first.example.com","mm":3,"uids":[{"id":"first-uid","atype":1,"ext":{"label":"日本"}}]},{"source":"bad.example.com","uids":[{"id":"bad-\ud800-uid","atype":1}]},{"source":"last.example.com","ext":{"segments":["s1"]},"uids":[{"id":"last-uid","atype":3}]}]} +``` + +Create `crates/trusted-server-js/lib/test/fixtures/ts-eids-cookie.txt` with exactly this one line (412 bytes, no trailing newline): + +```text +W3sic291cmNlIjoiZmlyc3QuZXhhbXBsZS5jb20iLCJpbnNlcnRlciI6Imluc2VydGVyLeKCrC5leGFtcGxlLmNvbSIsIm1hdGNoZXIiOiJmaXJzdC5leGFtcGxlLmNvbSIsIm1tIjozLCJ1aWRzIjpbeyJpZCI6ImZpcnN0LXVpZCIsImF0eXBlIjoxLCJleHQiOnsibGFiZWwiOiLml6XmnKwifX1dfSx7InNvdXJjZSI6ImJhZC5leGFtcGxlLmNvbSIsInVpZHMiOlt7ImlkIjoiYmFkLVx1ZDgwMC11aWQiLCJhdHlwZSI6MX1dfSx7InNvdXJjZSI6Imxhc3QuZXhhbXBsZS5jb20iLCJ1aWRzIjpbeyJpZCI6Imxhc3QtdWlkIiwiYXR5cGUiOjN9XX1d +``` + +Verify the bytes: + +```bash +sha256sum crates/trusted-server-js/lib/test/fixtures/auction-body-eids.txt crates/trusted-server-js/lib/test/fixtures/ts-eids-cookie.txt +``` + +Expected (if an editor added a trailing newline, remove it and re-run): + +```text +9266631df92f77c462b39c33e37e699a62fd8822afeb9cf1b3a2850c4eea785c crates/trusted-server-js/lib/test/fixtures/auction-body-eids.txt +e63a610fa5c7006065f8016e853069ed9e5fc34f50025c9670d1972ed7173079 crates/trusted-server-js/lib/test/fixtures/ts-eids-cookie.txt +``` + +The cookie decodes to the body's `eids` array with the last entry's EID-level `ext` removed. It keeps the `\ud800` escape as ASCII, so the server drops the middle entry from the cookie as well. + +- [ ] **Step 2: Write the failing endpoint tests.** + +In the `tests` module of `src/auction/endpoints.rs`, add next to `EidCapturingProvider` (`:1259`): + +```rust + const AUCTION_BODY_FIXTURE: &str = + include_str!("../../../trusted-server-js/lib/test/fixtures/auction-body-eids.txt"); + const TS_EIDS_COOKIE_FIXTURE: &str = + include_str!("../../../trusted-server-js/lib/test/fixtures/ts-eids-cookie.txt"); + + /// Provider that records the full EID set its auction request carried, + /// then fails its launch so no real transport handle is needed. + struct EidRecordingProvider { + eids: Arc>>, + } + + #[async_trait::async_trait(?Send)] + impl AuctionProvider for EidRecordingProvider { + fn provider_name(&self) -> &str { + "eid_recording_provider" + } + + async fn request_bids( + &self, + request: &AuctionRequest, + _context: &AuctionContext<'_>, + ) -> Result> { + *self.eids.lock().expect("should lock recorded eids") = Some( + serde_json::to_value(&request.user.eids).expect("should serialize recorded eids"), + ); + Err(Report::new(TrustedServerError::Auction { + message: "record only".to_string(), + })) + } + + async fn parse_response( + &self, + _response: PlatformResponse, + _response_time_ms: u64, + ) -> Result> { + panic!("parse_response must not run when the launch fails"); + } + + fn timeout_ms(&self) -> u32 { + 100 + } + + fn backend_name(&self, _services: &RuntimeServices, _timeout_ms: u32) -> Option { + Some("record-backend".to_string()) + } + } + + /// Runs `/auction` with an allowed EC and returns the EIDs the provider + /// received: `None` when no provider was called (the request was + /// rejected), `Some(Value::Null)` when it carried no EIDs. + async fn recorded_auction_eids(body: Vec, ts_eids_cookie: Option<&str>) -> Option { + let settings = create_test_settings(); + let mut orchestrator = AuctionOrchestrator::new(AuctionConfig { + enabled: true, + providers: AuctionConfig::legacy_provider_map(&["eid_recording_provider"]), + timeout_ms: 2000, + mediator: None, + ..Default::default() + }); + let recorded = Arc::new(Mutex::new(None)); + orchestrator.register_provider(Arc::new(EidRecordingProvider { + eids: Arc::clone(&recorded), + })); + let ec_id = format!("{}.fixtur", "a".repeat(64)); + let mut ec_context = make_ec_context(Jurisdiction::NonRegulated, Some(&ec_id)); + let mut builder = Request::builder() + .method("POST") + .uri("https://test-publisher.com/auction"); + if let Some(cookie) = ts_eids_cookie { + builder = builder.header(header::COOKIE, format!("{COOKIE_TS_EIDS}={cookie}")); + } + let req = builder + .body(EdgeBody::from(body)) + .expect("should build auction request"); + + // The recording provider fails its launch; the assertion is on what + // it received, not on the auction result. + let _ = handle_auction( + &settings, + &orchestrator, + None, + None, + &mut ec_context, + &noop_services(), + req, + ) + .await; + + recorded.lock().expect("should lock recorded eids").clone() + } + + fn ad_units_only_body() -> Vec { + serde_json::to_vec(&json!({ + "adUnits": [{ "code": "div-gpt-1", "mediaTypes": { "banner": { "sizes": [[300, 250]] } } }] + })) + .expect("should serialize body") + } + + /// The two parsable entries of the fixture input, as providers receive + /// them. The cookie copy has no EID-level `ext` on the last entry. + fn fixture_good_entries(with_eid_ext: bool) -> JsonValue { + let mut last = json!({ + "source": "last.example.com", + "uids": [{ "id": "last-uid", "atype": 3 }] + }); + if with_eid_ext { + last["ext"] = json!({ "segments": ["s1"] }); + } + json!([ + { + "source": "first.example.com", + "inserter": "inserter-€.example.com", + "matcher": "first.example.com", + "mm": 3, + "uids": [{ "id": "first-uid", "atype": 1, "ext": { "label": "日本" } }] + }, + last + ]) + } + + #[tokio::test] + async fn tsjs_auction_body_fixture_drops_only_the_unparsable_entry() { + let recorded = recorded_auction_eids( + AUCTION_BODY_FIXTURE.trim_end().as_bytes().to_vec(), + None, + ) + .await; + + assert_eq!( + recorded, + Some(fixture_good_entries(true)), + "the request should be accepted and carry the two good entries unchanged" + ); + } + + #[tokio::test] + async fn tsjs_cookie_fixture_drops_only_the_unparsable_entry() { + let recorded = + recorded_auction_eids(ad_units_only_body(), Some(TS_EIDS_COOKIE_FIXTURE.trim_end())).await; + + assert_eq!( + recorded, + Some(fixture_good_entries(false)), + "the cookie's good entries should reach the provider without the bad one" + ); + } + + #[tokio::test] + async fn auction_body_eid_level_fields_reach_the_auction_request() { + let eids = json!([{ + "source": "adserver.org", + "inserter": "adserver.org", + "matcher": "adserver.org", + "mm": 4, + "uids": [{ "id": "fictional-tdid", "atype": 1, "ext": { "rtiPartner": "TDID" } }] + }]); + let body = serde_json::to_vec(&json!({ + "adUnits": [{ "code": "div-gpt-1", "mediaTypes": { "banner": { "sizes": [[300, 250]] } } }], + "eids": eids + })) + .expect("should serialize body"); + + assert_eq!( + recorded_auction_eids(body, None).await, + Some(eids), + "EID-level fields from the body should reach the provider unchanged" + ); + } + + #[tokio::test] + async fn auction_uses_the_cookie_when_no_body_entry_is_valid() { + let cookie_entries = json!([{ "source": "cookie.example.com", "uids": [{ "id": "cookie-uid" }] }]); + let cookie = BASE64.encode(serde_json::to_vec(&cookie_entries).expect("should serialize cookie")); + let body = serde_json::to_vec(&json!({ + "adUnits": [{ "code": "div-gpt-1", "mediaTypes": { "banner": { "sizes": [[300, 250]] } } }], + "eids": [{ "source": "", "uids": [{ "id": "body-uid" }] }] + })) + .expect("should serialize body"); + + assert_eq!( + recorded_auction_eids(body, Some(&cookie)).await, + Some(cookie_entries), + "a body whose entries are all invalid should count as absent" + ); + } +``` + +In `src/ec/prebid_eids.rs` tests, add: + +```rust + #[test] + fn structured_cookie_keeps_eid_level_fields() { + let entries = json!([{ + "source": "adserver.org", + "inserter": "adserver.org", + "matcher": "adserver.org", + "mm": 4, + "uids": [{ "id": "fictional-tdid", "atype": 1 }] + }]); + + let decoded = parse_prebid_eids_cookie(&encode_json(&entries)).expect("should decode cookie"); + + assert_eq!( + serde_json::to_value(&decoded).expect("should serialize decoded EIDs"), + entries, + "should keep EID-level fields from the cookie" + ); + } + + #[test] + fn structured_cookie_drops_a_mistyped_field_instead_of_the_whole_cookie() { + let encoded = encode_json(&json!([ + { "source": "a.example.com", "uids": [{ "id": "a-uid", "atype": "1" }] }, + { "source": "b.example.com", "uids": [{ "id": "b-uid", "atype": 3 }] } + ])); + + let decoded = parse_prebid_eids_cookie(&encoded).expect("should decode cookie"); + + assert_eq!( + serde_json::to_value(&decoded).expect("should serialize decoded EIDs"), + json!([ + { "source": "a.example.com", "uids": [{ "id": "a-uid" }] }, + { "source": "b.example.com", "uids": [{ "id": "b-uid", "atype": 3 }] } + ]), + "a mistyped atype should drop only that field" + ); + } + + #[test] + fn structured_cookie_isolates_an_unparsable_entry() { + let raw = r#"[{"source":"a.example.com","uids":[{"id":"a-uid"}]},{"source":"bad.example.com","uids":[{"id":"bad-\ud800"}]}]"#; + + let decoded = parse_prebid_eids_cookie(&BASE64.encode(raw)).expect("should decode cookie"); + + assert_eq!( + serde_json::to_value(&decoded).expect("should serialize decoded EIDs"), + json!([{ "source": "a.example.com", "uids": [{ "id": "a-uid" }] }]), + "a lone surrogate should cost only its own entry" + ); + } + + #[test] + fn structured_cookie_applies_the_entry_cap() { + let entries: Vec = (0..65) + .map(|index| json!({ "source": format!("s{index}.example.com"), "uids": [{ "id": "u" }] })) + .collect(); + + let decoded = parse_prebid_eids_cookie(&encode_json(&serde_json::Value::Array(entries.clone()))) + .expect("should decode cookie"); + + assert_eq!( + serde_json::to_value(&decoded).expect("should serialize decoded EIDs"), + serde_json::Value::Array(entries[..64].to_vec()), + "the body's 64-entry cap should apply to the cookie" + ); + } + + #[test] + fn structured_cookie_diagnostics_report_raw_uid_strings() { + let registry = make_registry(vec![("example", "example.com")]); + let oversized = "x".repeat(MAX_UID_LENGTH + 1); + let encoded = encode_json(&json!([{ "source": "example.com", "uids": [{ "id": oversized }] }])); + + let analysis = analyze_prebid_eids_cookie(&encoded, ®istry).expect("should decode cookie"); + + assert!(analysis.eids.is_empty(), "an oversized UID should not survive validation"); + assert_eq!( + analysis + .diagnostic_sources + .iter() + .map(|source| (source.source.clone(), source.uids.clone())) + .collect::>(), + vec![("example.com".to_owned(), vec![oversized])], + "diagnostics should still report the raw source and UID so admin can say no valid UID" + ); + } +``` + +- [ ] **Step 3: Run them and see them fail.** + +Run: `cargo test-fastly -- tsjs_ auction_body_eid_level auction_uses_the_cookie structured_cookie` +Expected before the change: + +- `tsjs_auction_body_fixture_drops_only_the_unparsable_entry` FAILS with `None`: the whole body is rejected. +- `tsjs_cookie_fixture_drops_only_the_unparsable_entry` FAILS with `Some(Null)`: the whole cookie is rejected. +- `auction_body_eid_level_fields_reach_the_auction_request` FAILS: the fields are dropped. +- `auction_uses_the_cookie_when_no_body_entry_is_valid` PASSES today. It is a regression net. Prove it in Step 7. +- The `structured_cookie_*` tests FAIL: the cookie is rejected or the fields are dropped, except `structured_cookie_diagnostics_report_raw_uid_strings`, a regression net. + +Record all of this. + +- [ ] **Step 4: Capture `eids` as raw JSON.** + +In `src/auction/formats.rs`, add `use serde_json::value::RawValue;` and change the field: + +```rust + /// Browser EIDs, captured as raw JSON so a malformed entry (for example a + /// lone surrogate escape) cannot fail the whole body. Each entry is + /// validated on its own by the shared client EID parser. + pub eids: Option>, +``` + +`AdRequest` has no `flatten` or untagged enum. Do not add one: either breaks raw capture. The `eids: None` initializers in `formats.rs` (`:275`, `:628` and the tests) compile unchanged. + +- [ ] **Step 5: Route body and cookie through `parse_client_eids`.** + +In `src/auction/endpoints.rs`: + +1. Delete the three constants at `:38-40`, `parse_client_auction_eids` (`:491-537`) and `parse_client_auction_uid` (`:539-562`). +2. Change the imports: add `use serde_json::value::RawValue;` and `use crate::ec::client_eids::parse_client_eids;`, and change `use crate::openrtb::{Eid, Uid};` to `use crate::openrtb::Eid;`. Remove `use serde_json::Value as JsonValue;` if the compiler reports it unused. +3. Replace `resolve_client_auction_eids`: + +```rust +/// Resolves the request's browser EIDs: the `/auction` body when it has a +/// valid entry, else the `ts-eids` cookie. +/// +/// Both go through the shared client EID parser, so the same rules and caps +/// apply to each, and a malformed entry costs only itself. +pub(crate) fn resolve_client_auction_eids( + raw: Option<&RawValue>, + cookie_value: Option<&str>, +) -> Option> { + raw.map(parse_client_eids) + .filter(|eids| !eids.is_empty()) + .or_else(|| parse_cookie_auction_eids(cookie_value)) +} +``` + +4. In `handle_auction` (`:294-297`), pass `body.eids.as_deref()` instead of `body.eids.as_ref()`. +5. The existing test `resolve_client_auction_eids_prefers_request_body_over_cookie` (`:1484`) no longer compiles, because it passes a `JsonValue`. Replace its `let raw = json!([...]);` with: + +```rust + let raw = serde_json::value::to_raw_value(&json!([ + { + "source": "id5-sync.com", + "uids": [{ "id": "body_uid", "atype": 1 }] + } + ])) + .expect("should capture raw EIDs"); +``` + +Call it as `resolve_client_auction_eids(Some(raw.as_ref()), Some(&encoded))`, and replace its field-by-field assertions with whole-value equality: + +```rust + assert_eq!( + serde_json::to_value(&resolved).expect("should serialize resolved EIDs"), + json!([{ "source": "id5-sync.com", "uids": [{ "id": "body_uid", "atype": 1 }] }]), + "should use the request body when it has valid entries" + ); +``` + +In `src/ec/prebid_eids.rs`: + +1. Replace the module doc (`:1-9`): + +```rust +//! Prebid EID cookie ingestion. +//! +//! Parses the `ts-eids` cookie written by the TSJS Prebid integration and +//! syncs matched partner UIDs to the KV identity graph. +//! +//! The current cookie holds a base64-encoded JSON array of OpenRTB-style EID +//! objects, validated entry by entry with the same parser as the `/auction` +//! body (`ec::client_eids`). The earlier flattened payload shape +//! (`{source, id, atype}` per entry) is still accepted. +``` + +2. Delete `StructuredCookieEid` and `StructuredCookieUid` (`:32-47`), `structured_cookie_eids_to_openrtb` and `structured_cookie_uid_to_openrtb` (`:282-322`). Add `use serde_json::value::RawValue;` and `use super::client_eids::parse_client_eids;`. +3. Change the enum variant to `Structured(Box)`. +4. In `decode_prebid_eids_cookie`, replace the structured parse: + +```rust + let structured = serde_json::from_slice::>(&bytes) + .map_err(|e| format!("JSON parse failed: {e}"))?; + Ok(DecodedCookieEids::Structured(structured)) +``` + +5. In `impl DecodedCookieEids`, change the `Structured` arms: + +```rust + Self::Structured(raw) => raw_diagnostic_sources(raw), +``` + +```rust + Self::Structured(raw) => parse_client_eids(&raw), +``` + +and add below the impl: + +```rust +/// Reports every source and raw UID string in a structured cookie, before +/// validation, so admin diagnostics can tell "no partner" from "no valid UID". +fn raw_diagnostic_sources(raw: &RawValue) -> Vec { + let Ok(elements) = serde_json::from_str::>>(raw.get()) else { + return Vec::new(); + }; + + elements + .iter() + .filter_map(|element| { + let JsonValue::Object(entry) = serde_json::from_str::(element.get()).ok()? + else { + return None; + }; + let source = entry + .get("source") + .and_then(JsonValue::as_str) + .filter(|source| !source.is_empty())? + .to_owned(); + let uids = entry + .get("uids") + .and_then(JsonValue::as_array) + .map(|uids| { + uids.iter() + .filter_map(|uid| uid.get("id").and_then(JsonValue::as_str)) + .map(str::to_owned) + .collect() + }) + .unwrap_or_default(); + Some(DiagnosticEidSource { source, uids }) + }) + .collect() +} +``` + +- [ ] **Step 6: Remove the tests of the deleted body parser.** + +Delete these tests from `src/auction/endpoints.rs`. Task 2's `client_eids` tests cover the same rules for both transports. Keep `resolve_client_auction_eids_falls_back_to_ts_eids_cookie`. + +- `parse_client_auction_eids_ignores_malformed_entries` (`:1509`) +- `parse_client_auction_eids_caps_sources_and_uids` (`:1535`) +- `parse_client_auction_eids_drops_whitespace_and_oversized_uids` (`:1565`) +- `parse_client_auction_eids_preserves_pair_atype` (`:1585`) +- `parse_client_auction_eids_preserves_uid_ext_and_sanitizes_invalid_atype` (`:1603`) + +- [ ] **Step 7: Run the tests and prove the regression nets.** + +Run: `cargo test-fastly -- tsjs_ auction_body_eid_level auction_uses_the_cookie structured_cookie resolve_client_auction_eids admin prebid_eids` +Expected: PASS. + +Then prove the two nets that passed before the change: + +- Temporarily delete `.filter(|eids| !eids.is_empty())` from `resolve_client_auction_eids` and run `cargo test-fastly -- auction_uses_the_cookie`. It must FAIL (`Some(Null)`). Restore the line. +- Temporarily make `raw_diagnostic_sources` return `Vec::new()` and run `cargo test-fastly -- structured_cookie_diagnostics`. It must FAIL. Restore it. + +Record both in the red log. + +- [ ] **Step 8: Gates and commit.** + +Run: `cargo fmt --all -- --check && cargo clippy-fastly && cargo clippy-axum && cargo test-fastly && cargo test-axum` +Expected: clean. + +```bash +git add crates/trusted-server-core/src crates/trusted-server-js/lib/test/fixtures/auction-body-eids.txt crates/trusted-server-js/lib/test/fixtures/ts-eids-cookie.txt docs/superpowers/implementation-notes/2026-10-18-prebid-eid-fidelity.md +git commit --signoff -S -m "Validate body and cookie EIDs with the shared parser" -m "AdRequest captures eids as raw JSON so a lone surrogate in one entry no longer rejects the whole /auction body. The ts-eids structured branch uses the same parser, so a mistyped field or bad entry costs only itself and the body's caps apply to the cookie. Admin diagnostics still report raw sources and UIDs." +``` + +--- + +### Task 4: Forward browser entries as sent; KV fills absent sources + +Spec: 4.5 (rules and the seven cases), D9, 7.4 ("Merge", "`publisher.rs`"), A3. + +**Files:** + +- Modify: `crates/trusted-server-core/src/auction/endpoints.rs:564-622` (`merge_auction_eids`) and its tests (`:1655-1753`) +- Test: `crates/trusted-server-core/src/publisher.rs` (tests module) + +**Interfaces:** + +- Consumes: `crate::ec::partner::normalize_partner_source_domain(&str) -> Result`. +- Produces: `merge_auction_eids(client_eids: Option>, resolved_eids: Option>) -> Option>`. The signature is unchanged, the behavior is new. `publisher.rs:5549` calls it unchanged. + +- [ ] **Step 1: Write the failing merge tests.** + +In `src/auction/endpoints.rs` tests, replace the three existing `merge_auction_eids_*` tests (`:1655-1753`) with: + +```rust + const SOURCE_S: &str = "ids.example.com"; + const SOURCE_T: &str = "other.example.com"; + + fn kv_eid(source: &str, id: &str, atype: i32) -> Eid { + Eid::new( + source.to_owned(), + vec![Uid { + id: id.to_owned(), + atype: Some(atype), + ext: None, + }], + ) + } + + /// A browser entry, every field spelled out (spec 7.1 rule 9). + fn browser_eid( + source: &str, + id: &str, + atype: Option, + inserter: Option<&str>, + mm: Option, + ) -> Eid { + Eid { + source: source.to_owned(), + uids: vec![Uid { + id: id.to_owned(), + atype, + ext: None, + }], + inserter: inserter.map(str::to_owned), + matcher: None, + mm, + ext: None, + } + } + + fn merged_json(client: Option>, kv: Option>) -> JsonValue { + serde_json::to_value(merge_auction_eids(client, kv)).expect("should serialize merged EIDs") + } + + #[test] + fn merge_case_1_forwards_a_browser_entry_with_its_fields() { + let merged = merged_json( + Some(vec![browser_eid(SOURCE_S, "u1", None, Some("inserter-1.example.com"), Some(1))]), + None, + ); + + assert_eq!( + merged, + json!([{ "source": SOURCE_S, "inserter": "inserter-1.example.com", "mm": 1, "uids": [{ "id": "u1" }] }]), + "a browser entry should go out with its EID-level fields" + ); + } + + #[test] + fn merge_case_2_keeps_the_browser_atype_for_a_shared_uid() { + let merged = merged_json( + Some(vec![browser_eid(SOURCE_S, "u1", Some(1), Some("inserter-1.example.com"), Some(1))]), + Some(vec![kv_eid(SOURCE_S, "u1", 3)]), + ); + + assert_eq!( + merged, + json!([{ "source": SOURCE_S, "inserter": "inserter-1.example.com", "mm": 1, "uids": [{ "id": "u1", "atype": 1 }] }]), + "KV's operator-configured atype should not override the browser's" + ); + } + + #[test] + fn merge_case_3_drops_the_stale_kv_uid_for_a_browser_source() { + let merged = merged_json( + Some(vec![browser_eid(SOURCE_S, "u1", None, Some("inserter-1.example.com"), Some(1))]), + Some(vec![kv_eid(SOURCE_S, "u0", 3)]), + ); + + assert_eq!( + merged, + json!([{ "source": SOURCE_S, "inserter": "inserter-1.example.com", "mm": 1, "uids": [{ "id": "u1" }] }]), + "KV's value for a source the browser sends should not be forwarded" + ); + } + + #[test] + fn merge_case_4_appends_kv_sources_the_browser_did_not_send() { + let merged = merged_json( + Some(vec![browser_eid(SOURCE_S, "u1", None, None, None)]), + Some(vec![kv_eid(SOURCE_T, "t1", 3)]), + ); + + assert_eq!( + merged, + json!([ + { "source": SOURCE_S, "uids": [{ "id": "u1" }] }, + { "source": SOURCE_T, "uids": [{ "id": "t1", "atype": 3 }] } + ]), + "browser entries should come first and KV-only sources after" + ); + } + + #[test] + fn merge_case_5_keeps_same_source_entries_with_different_fields_apart() { + let merged = merged_json( + Some(vec![ + browser_eid(SOURCE_S, "u1", None, Some("inserter-1.example.com"), Some(1)), + browser_eid(SOURCE_S, "u1", None, Some("inserter-2.example.com"), Some(2)), + ]), + None, + ); + + assert_eq!( + merged, + json!([ + { "source": SOURCE_S, "inserter": "inserter-1.example.com", "mm": 1, "uids": [{ "id": "u1" }] }, + { "source": SOURCE_S, "inserter": "inserter-2.example.com", "mm": 2, "uids": [{ "id": "u1" }] } + ]), + "two browser entries for one source should stay two entries" + ); + } + + #[test] + fn merge_case_6_matches_sources_as_the_registry_normalizes_them() { + let merged = merged_json( + Some(vec![browser_eid("IDS.Example.com.", "u1", None, None, None)]), + Some(vec![kv_eid(SOURCE_S, "u0", 3)]), + ); + + assert_eq!( + merged, + json!([{ "source": "IDS.Example.com.", "uids": [{ "id": "u1" }] }]), + "a browser source differing only in case or a trailing dot should suppress KV's" + ); + } + + #[test] + fn merge_case_7_forwards_kv_entries_when_the_browser_sent_none() { + let merged = merged_json(None, Some(vec![kv_eid(SOURCE_S, "u0", 3), kv_eid(SOURCE_T, "t1", 3)])); + + assert_eq!( + merged, + json!([ + { "source": SOURCE_S, "uids": [{ "id": "u0", "atype": 3 }] }, + { "source": SOURCE_T, "uids": [{ "id": "t1", "atype": 3 }] } + ]), + "KV entries should be forwarded unchanged without browser EIDs" + ); + } + + #[test] + fn merge_drops_entries_without_uids() { + let empty_browser = Eid::new(SOURCE_S.to_owned(), Vec::new()); + let empty_kv = Eid::new(SOURCE_T.to_owned(), Vec::new()); + + assert_eq!( + merged_json(Some(vec![empty_browser]), Some(vec![empty_kv])), + JsonValue::Null, + "entries without UIDs should be dropped, leaving no EIDs" + ); + } + + #[test] + fn merge_auction_eids_deduplicates_client_and_resolved_ids() { + let merged = merged_json( + Some(vec![kv_eid("id5-sync.com", "ID5_abc", 1)]), + Some(vec![kv_eid("id5-sync.com", "ID5_abc", 1), kv_eid("liveramp.com", "LR_xyz", 3)]), + ); + + assert_eq!( + merged, + json!([ + { "source": "id5-sync.com", "uids": [{ "id": "ID5_abc", "atype": 1 }] }, + { "source": "liveramp.com", "uids": [{ "id": "LR_xyz", "atype": 3 }] } + ]), + "the browser's id5 entry should go out once, followed by KV's liveramp" + ); + } + + #[test] + fn merge_auction_eids_does_not_union_kv_uids_into_a_browser_source() { + let merged = merged_json( + Some(vec![browser_eid("sharedid.org", "shared_client", None, None, None)]), + Some(vec![kv_eid("sharedid.org", "shared_server", 3)]), + ); + + assert_eq!( + merged, + json!([{ "source": "sharedid.org", "uids": [{ "id": "shared_client" }] }]), + "KV's UID for a source the browser sends should not be added to it" + ); + } + + #[test] + fn merge_auction_eids_keeps_browser_metadata_on_conflict() { + let client = Eid::new( + "adserver.org".to_owned(), + vec![Uid { + id: "shared_uid".to_owned(), + atype: Some(1), + ext: Some(json!({ "provider": "client" })), + }], + ); + let resolved = Eid::new( + "adserver.org".to_owned(), + vec![Uid { + id: "shared_uid".to_owned(), + atype: Some(3), + ext: Some(json!({ "provider": "server" })), + }], + ); + + assert_eq!( + merged_json(Some(vec![client]), Some(vec![resolved])), + json!([{ + "source": "adserver.org", + "uids": [{ "id": "shared_uid", "atype": 1, "ext": { "provider": "client" } }] + }]), + "the browser's atype and ext should win over KV's" + ); + } +``` + +- [ ] **Step 2: Write the failing publisher test.** + +Add to the `tests` module of `src/publisher.rs`. Add any of these imports the module does not already have: `crate::auction::test_support::canonical_parity_auction_request`, `crate::consent::ConsentContext`, `crate::consent::jurisdiction::Jurisdiction`, `crate::cookies::parse_cookies_to_jar`, `crate::ec::EcKvSnapshot`, `crate::ec::kv_types::{KvEntry, KvPartnerId}`, `crate::platform::test_support::noop_services`, `crate::redacted::Redacted`, `crate::settings::EcPartner`, `base64::{Engine as _, engine::general_purpose::STANDARD as BASE64}`. + +```rust + fn eid_merge_partner(source_domain: &str) -> EcPartner { + EcPartner { + name: format!("Partner {source_domain}"), + source_domain: source_domain.to_owned(), + openrtb_atype: EcPartner::default_openrtb_atype(), + bidstream_enabled: true, + api_token: Some(Redacted::new(format!( + "token-{source_domain}-32-bytes-minimum-value" + ))), + batch_rate_limit: EcPartner::default_batch_rate_limit(), + pull_sync_enabled: false, + pull_sync_url: None, + pull_sync_allowed_domains: vec![], + pull_sync_ttl_sec: EcPartner::default_pull_sync_ttl_sec(), + pull_sync_rate_limit: EcPartner::default_pull_sync_rate_limit(), + ts_pull_token: None, + } + } + + #[test] + fn body_less_auction_eids_put_cookie_entries_first_and_kv_only_sources_after() { + let ec_id = format!("{}.navmrg", "a".repeat(64)); + let registry = PartnerRegistry::from_config(&[ + eid_merge_partner("ids.example.com"), + eid_merge_partner("other.example.com"), + ]) + .expect("should build partner registry"); + let mut entry = KvEntry::tombstone(1000); + entry.consent.ok = true; + entry.ids.insert( + "ids.example.com".to_owned(), + KvPartnerId { uid: "kv-stale".to_owned() }, + ); + entry.ids.insert( + "other.example.com".to_owned(), + KvPartnerId { uid: "kv-other".to_owned() }, + ); + let snapshot = EcKvSnapshot::Present { + ec_id: ec_id.clone(), + entry: Box::new(entry), + generation: None, + }; + let ec_context = EcContext::new_for_test( + Some(ec_id.clone()), + ConsentContext { + jurisdiction: Jurisdiction::NonRegulated, + ..ConsentContext::default() + }, + ); + let cookie_entries = json!([{ + "source": "ids.example.com", + "inserter": "inserter-1.example.com", + "mm": 1, + "uids": [{ "id": "browser-fresh", "atype": 1 }] + }]); + let cookie = BASE64.encode(serde_json::to_vec(&cookie_entries).expect("should serialize cookie")); + let jar = parse_cookies_to_jar(&format!("{COOKIE_TS_EIDS}={cookie}")); + let services = noop_services(); + let mut auction_request = canonical_parity_auction_request(); + // No TCF signal and no GDPR, so the consent gate passes the merged set. + auction_request.user.consent = None; + auction_request.user.eids = None; + + apply_auction_eids_and_device( + &mut auction_request, + &AuctionEidTargeting { + cookie_jar: Some(&jar), + ec_id: Some(ec_id.as_str()), + kv_snapshot: &snapshot, + partner_registry: Some(®istry), + ec_context: &ec_context, + services: &services, + geo: None, + path_label: "Test", + }, + ); + + assert_eq!( + serde_json::to_value(&auction_request.user.eids).expect("should serialize EIDs"), + json!([ + { + "source": "ids.example.com", + "inserter": "inserter-1.example.com", + "mm": 1, + "uids": [{ "id": "browser-fresh", "atype": 1 }] + }, + { + "source": "other.example.com", + "uids": [{ "id": "kv-other", "atype": EcPartner::default_openrtb_atype() }] + } + ]), + "navigation and page-bids auctions should forward the cookie entry as sent, then KV-only sources" + ); + } +``` + +If `publisher.rs` tests already import `serde_json::json` under another name, or already define a partner helper, reuse it instead of adding a duplicate. + +- [ ] **Step 3: Run them and see them fail.** + +Run: `cargo test-fastly -- merge_ body_less_auction_eids` +Expected FAIL against the old merge: cases 1 to 6 and the three rewritten tests (fields dropped, KV first, UIDs unioned, KV atype wins), and the publisher test. `merge_case_7_forwards_kv_entries_when_the_browser_sent_none` and `merge_drops_entries_without_uids` pass today. They are regression nets for Step 5. Record everything. + +- [ ] **Step 4: Rewrite the merge.** + +Replace `merge_auction_eids` in `src/auction/endpoints.rs`. Add `use crate::ec::partner::normalize_partner_source_domain;`, change `use std::collections::HashMap;` to `use std::collections::{HashMap, HashSet};`, and remove `use crate::ec::kv_types::MAX_UID_LENGTH;` once it is unused: + +```rust +/// Merges the request's browser EIDs with KV-resolved partner EIDs. +/// +/// Browser entries are forwarded as parsed, in wire order, never altered or +/// combined: they are what Prebid sent the `trustedServer` adapter, and a +/// source may repeat with different EID-level fields. KV entries are appended +/// after them, only for registered sources no browser entry carries. Sources +/// are compared as the partner registry normalizes them (lowercase, trailing +/// dot removed). For a source the browser sends, KV's UIDs, `atype` and `ext` +/// are not used: KV keeps the first value it stored, so it is the staler copy. +/// Entries without UIDs are dropped. +pub(crate) fn merge_auction_eids( + client_eids: Option>, + resolved_eids: Option>, +) -> Option> { + let mut merged: Vec = client_eids + .into_iter() + .flatten() + .filter(|eid| !eid.uids.is_empty()) + .collect(); + let browser_sources: HashSet = merged + .iter() + .filter_map(|eid| normalize_partner_source_domain(&eid.source).ok()) + .collect(); + + for eid in resolved_eids.into_iter().flatten() { + if eid.uids.is_empty() { + continue; + } + let carried_by_browser = normalize_partner_source_domain(&eid.source) + .is_ok_and(|source| browser_sources.contains(&source)); + if !carried_by_browser { + merged.push(eid); + } + } + + if merged.is_empty() { + None + } else { + Some(merged) + } +} +``` + +Update the comment above the call in `handle_auction` (`:345-346`) to: "Forward the browser entries as sent, append KV entries for registered sources the browser did not send, then apply consent gating." + +- [ ] **Step 5: Run the tests and prove the nets.** + +Run: `cargo test-fastly -- merge_ body_less_auction_eids` +Expected: PASS. + +Prove the two nets: + +- Temporarily replace `merged.push(eid);` with `let _ = eid;` and run `cargo test-fastly -- merge_case_7`. It must FAIL. Restore. +- Temporarily remove `.filter(|eid| !eid.uids.is_empty())` and run `cargo test-fastly -- merge_drops_entries_without_uids`. It must FAIL. Restore. + +Record both. + +- [ ] **Step 6: Gates and commit.** + +Run: `cargo fmt --all -- --check && cargo clippy-fastly && cargo clippy-axum && cargo test-fastly` +Expected: clean. + +```bash +git add crates/trusted-server-core/src docs/superpowers/implementation-notes/2026-10-18-prebid-eid-fidelity.md +git commit --signoff -S -m "Forward browser EIDs as sent and fill only absent sources from KV" -m "Browser entries reach providers unchanged, in wire order, with repeated sources kept apart. KV adds entries only for registered sources the browser did not send, so its stale UID and operator atype no longer override the browser's." +``` + +--- + +### Task 5: KV ingestion on `/auction` reads only the auction's own client set + +Spec: 4.6, 7.1 rule 11, 7.4 ("Fastly adapter and `ec/finalize.rs`"), A2 (KV part), A8. + +**Files:** + +- Modify: `crates/trusted-server-core/src/ec/finalize.rs:79-150` (both ingestion branches) and tests +- Modify: `crates/trusted-server-core/src/ec/prebid_eids.rs:129-161` (`collect_eid_updates` doc comment only) +- Test: `crates/trusted-server-core/src/auction/endpoints.rs`, `crates/trusted-server-core/src/ec/finalize.rs`, `crates/trusted-server-adapter-fastly/src/app.rs` + +**Interfaces:** + +- Consumes: `EcContext::eid_sync_source() -> Option` and `EcContext::client_eids() -> Option<&[Eid]>`, both existing. +- Produces: a private `browser_eid_input(&EcContext, Option<&str>) -> (Option<&str>, Option<&[Eid]>)` in `ec/finalize.rs`. `collect_eid_updates` keeps its signature. + +- [ ] **Step 1: Write the failing core-layer tests.** + +In `src/auction/endpoints.rs` tests, next to `auction_body_eids_reach_kv_even_when_the_ts_eids_cookie_is_absent` (`:807`): + +```rust + const COOKIE_ONLY_SOURCE: &str = "cookie-only.example.com"; + + fn ingestion_registry(extra_source: &str) -> PartnerRegistry { + PartnerRegistry::from_config(&[ + counting_test_partner(COOKIE_ONLY_SOURCE), + counting_test_partner(extra_source), + ]) + .expect("should build partner registry") + } + + fn cookie_only_ts_eids() -> String { + BASE64.encode( + serde_json::to_vec(&json!([ + { "source": COOKIE_ONLY_SOURCE, "uids": [{ "id": "cookie-uid" }] } + ])) + .expect("should serialize cookie"), + ) + } + + fn seeded_graph(ec_id: &str) -> KvIdentityGraph { + let graph = KvIdentityGraph::in_memory("test_store"); + let mut live = crate::ec::kv_types::KvEntry::tombstone(1000); + live.consent.ok = true; + graph.create(ec_id, &live).expect("should seed live row"); + graph + } + + fn stored_ids(graph: &KvIdentityGraph, ec_id: &str) -> Vec<(String, String)> { + let (stored, _) = graph + .get(ec_id) + .expect("should read store") + .expect("row should exist"); + stored + .ids + .iter() + .map(|(source, id)| (source.clone(), id.uid.clone())) + .collect() + } + + #[tokio::test] + async fn auction_ingests_its_body_eids_and_not_a_cookie_only_source() { + let settings = create_test_settings(); + let mut orchestrator = AuctionOrchestrator::new(AuctionConfig { + enabled: true, + providers: AuctionConfig::legacy_provider_map(&["eid_capturing_provider"]), + timeout_ms: 2000, + mediator: None, + ..Default::default() + }); + orchestrator.register_provider(Arc::new(EidCapturingProvider { + had_eids: Arc::new(std::sync::Mutex::new(None)), + })); + let registry = ingestion_registry("body.example.com"); + let ec_id = format!("{}.ingbdy", "a".repeat(64)); + let graph = seeded_graph(&ec_id); + let mut ec_context = make_ec_context(Jurisdiction::NonRegulated, Some(&ec_id)); + ec_context.set_eid_sync_source(crate::ec::EidSyncSource::Auction); + let cookie = cookie_only_ts_eids(); + let req = Request::builder() + .method("POST") + .uri("https://test-publisher.com/auction") + .header(header::COOKIE, format!("{COOKIE_TS_EIDS}={cookie}")) + .body(EdgeBody::from( + serde_json::to_vec(&json!({ + "adUnits": [{ "code": "div-gpt-ad-1", "mediaTypes": { "banner": { "sizes": [[300, 250]] } } }], + "eids": [{ "source": "body.example.com", "uids": [{ "id": "body-uid" }] }] + })) + .expect("should serialize body"), + )) + .expect("should build auction request"); + + let _ = handle_auction( + &settings, + &orchestrator, + Some(&graph), + Some(®istry), + &mut ec_context, + &noop_services(), + req, + ) + .await; + let mut response = http::Response::new(EdgeBody::empty()); + crate::ec::finalize::ec_finalize_response( + &settings, + &mut ec_context, + Some(&graph), + ®istry, + Some(&cookie), + None, + &mut response, + ); + + assert_eq!( + stored_ids(&graph, &ec_id), + vec![("body.example.com".to_owned(), "body-uid".to_owned())], + "KV should learn the body's source and not the cookie-only source" + ); + } + + #[tokio::test] + async fn disabled_auction_ingests_nothing_from_the_cookie_but_keeps_shared_id() { + let settings = create_test_settings(); + let orchestrator = AuctionOrchestrator::new(AuctionConfig { + enabled: false, + ..Default::default() + }); + let registry = ingestion_registry("sharedid.org"); + let ec_id = format!("{}.ingdis", "a".repeat(64)); + let graph = seeded_graph(&ec_id); + let mut ec_context = make_ec_context(Jurisdiction::NonRegulated, Some(&ec_id)); + ec_context.set_eid_sync_source(crate::ec::EidSyncSource::Auction); + let cookie = cookie_only_ts_eids(); + let req = Request::builder() + .method("POST") + .uri("https://test-publisher.com/auction") + .header(header::COOKIE, format!("{COOKIE_TS_EIDS}={cookie}")) + .body(EdgeBody::from(ad_units_only_body())) + .expect("should build auction request"); + + let _ = handle_auction( + &settings, + &orchestrator, + Some(&graph), + Some(®istry), + &mut ec_context, + &noop_services(), + req, + ) + .await; + let mut response = http::Response::new(EdgeBody::empty()); + crate::ec::finalize::ec_finalize_response( + &settings, + &mut ec_context, + Some(&graph), + ®istry, + Some(&cookie), + Some("shared-cookie-id"), + &mut response, + ); + + assert_eq!( + stored_ids(&graph, &ec_id), + vec![("sharedid.org".to_owned(), "shared-cookie-id".to_owned())], + "an /auction that resolved no client set should ingest only the sharedId cookie" + ); + } + + #[test] + fn navigation_ingests_the_cookie_only_source() { + // Control for the two tests above: the registry does match the + // cookie-only source when the route reads the cookie. + let settings = create_test_settings(); + let registry = ingestion_registry("body.example.com"); + let ec_id = format!("{}.ingnav", "a".repeat(64)); + let graph = seeded_graph(&ec_id); + let mut ec_context = make_ec_context(Jurisdiction::NonRegulated, Some(&ec_id)); + ec_context.set_eid_sync_source(crate::ec::EidSyncSource::Navigation); + let cookie = cookie_only_ts_eids(); + let mut response = http::Response::new(EdgeBody::empty()); + + crate::ec::finalize::ec_finalize_response( + &settings, + &mut ec_context, + Some(&graph), + ®istry, + Some(&cookie), + None, + &mut response, + ); + + assert_eq!( + stored_ids(&graph, &ec_id), + vec![(COOKIE_ONLY_SOURCE.to_owned(), "cookie-uid".to_owned())], + "a navigation should ingest the cookie's registered source" + ); + } +``` + +`ad_units_only_body` comes from Task 3. If `AuctionConfig` has no `Default` for `enabled: false`, copy the field list from the enabled config above with `enabled: false` and `providers: AuctionConfig::legacy_provider_map(&[])`. + +In `src/ec/finalize.rs` tests, next to `finalize_generated_ec_persists_eid_updates` (`:1297`): + +```rust + #[test] + fn finalize_generated_ec_without_sync_source_ingests_the_ts_eids_cookie() { + let settings = create_test_settings(); + let ec_id = sample_ec_id("genck1"); + let graph = KvIdentityGraph::in_memory("test_store"); + let live = KvEntry::new( + &granting_consent(), + None, + current_timestamp(), + &settings.publisher.domain, + ); + graph + .create(&ec_id, &live) + .expect("should seed generated row"); + let mut ec_context = + make_context(Some(&ec_id), None, false, true, Jurisdiction::NonRegulated); + ec_context.set_kv_snapshot(graph.load_snapshot(&ec_id)); + let registry = PartnerRegistry::from_config(&[make_partner("ids.example.com")]) + .expect("should build registry"); + let eids_cookie = base64::engine::general_purpose::STANDARD.encode( + serde_json::to_vec(&serde_json::json!([ + { "source": "ids.example.com", "uids": [{ "id": "cookie-uid" }] } + ])) + .expect("should serialize test cookie payload"), + ); + let mut response = empty_response(); + + ec_finalize_response( + &settings, + &mut ec_context, + Some(&graph), + ®istry, + Some(&eids_cookie), + None, + &mut response, + ); + + let (stored, _) = graph + .get(&ec_id) + .expect("should read store") + .expect("row should remain"); + assert_eq!( + stored + .ids + .iter() + .map(|(source, id)| (source.as_str(), id.uid.as_str())) + .collect::>(), + vec![("ids.example.com", "cookie-uid")], + "a new EC with no sync source should ingest the cookie" + ); + } +``` + +Change `finalize_recovers_orphaned_ec_when_purpose_four_denial_empties_updates` (`:1096`). Under the new rule a navigation ingests the cookie, not the client set, so its EID must arrive through the cookie. Delete the `ec_context.set_client_eids(vec![…]);` statement (`:1118-1125`), add before the `ec_finalize_response` call: + +```rust + let eids_cookie = base64::engine::general_purpose::STANDARD.encode( + serde_json::to_vec(&serde_json::json!([ + { "source": "id5-sync.com", "uids": [{ "id": "ID5_should_not_persist", "atype": 1 }] } + ])) + .expect("should serialize test cookie payload"), + ); +``` + +and pass `Some(&eids_cookie)` as the fifth argument of that call (it is `None` today, `:1136`). + +- [ ] **Step 2: Write the adapter-layer test.** + +In `crates/trusted-server-adapter-fastly/src/app.rs` tests, next to `dispatch_limits_returning_user_eid_sync_to_eligible_routes` (`:2300`): + +```rust + #[test] + fn auction_route_carries_the_raw_ts_eids_cookie_to_finalization() { + let router = test_router(); + let eids_cookie = base64::engine::general_purpose::STANDARD.encode( + serde_json::json!([{ "source": "ids.example.com", "uids": [{ "id": "example-uid" }] }]) + .to_string(), + ); + let mut request = browser_request(Method::POST, "/auction", "empty"); + request.headers_mut().insert( + header::COOKIE, + HeaderValue::from_str(&format!("ts-eids={eids_cookie}")) + .expect("should build cookie header"), + ); + + let response = route(&router, request); + + let state = response + .extensions() + .get::() + .expect("auction responses should carry EC finalization state"); + assert_eq!( + state.eids_cookie.as_deref(), + Some(eids_cookie.as_str()), + "the adapter should hand finalization the raw ts-eids cookie" + ); + assert_eq!( + state.ec_context.eid_sync_source(), + Some(EidSyncSource::Auction), + "finalization should know this was an /auction request" + ); + } +``` + +This test does not assert the client set. Setting one needs an allowed EC, which no adapter test arranges (spec 7.1 rule 11). + +- [ ] **Step 3: Run them and see them fail.** + +Run: `cargo test-fastly -- ingests_ cookie_only_source finalize_generated_ec_without_sync_source finalize_recovers_orphaned_ec auction_route_carries_the_raw` +Expected before the change: + +- `auction_ingests_its_body_eids_and_not_a_cookie_only_source` FAILS: KV also holds `cookie-only.example.com`. +- `disabled_auction_ingests_nothing_from_the_cookie_but_keeps_shared_id` FAILS for the same reason. +- `navigation_ingests_the_cookie_only_source`, `finalize_generated_ec_without_sync_source_ingests_the_ts_eids_cookie`, the edited orphan test and `auction_route_carries_the_raw_ts_eids_cookie_to_finalization` PASS. They are regression nets, proved in Step 6. + +Record this. + +- [ ] **Step 4: Select the ingestion input by sync source.** + +In `src/ec/finalize.rs`, add `use crate::openrtb::Eid;` and, below `ec_finalize_response`: + +```rust +/// Chooses the browser EID input KV ingestion reads for this request. +/// +/// `/auction` ingests only the client set the auction itself used (its body, +/// else its `ts-eids` cookie, as `handle_auction` resolved it). A cookie from +/// an earlier page is then never ingested next to a scoped body, and an +/// `/auction` that resolved no client set ingests nothing from the cookie. +/// Every other route, and requests with no sync source, read the cookie. +fn browser_eid_input<'a>( + ec_context: &'a EcContext, + eids_cookie: Option<&'a str>, +) -> (Option<&'a str>, Option<&'a [Eid]>) { + match ec_context.eid_sync_source() { + Some(EidSyncSource::Auction) => (None, ec_context.client_eids()), + _ => (eids_cookie, None), + } +} +``` + +In the returning-user branch (`:82-92`), replace the `collect_eid_updates(…)` call inside `.map(|_| …)` with: + +```rust + .map(|_| { + let (cookie_input, client_input) = browser_eid_input(ec_context, eids_cookie); + collect_eid_updates(cookie_input, sharedid_cookie, client_input, registry) + }) +``` + +In the generated-EC branch (`:141-149`): + +```rust + let (cookie_input, client_input) = browser_eid_input(ec_context, eids_cookie); + let updates = gate_eid_updates_by_consent( + collect_eid_updates(cookie_input, sharedid_cookie, client_input, registry), + ec_context.consent(), + ); +``` + +Update the doc comment of `ec_finalize_response` where it describes `eids_cookie`: "`eids_cookie` should be the raw value of the `ts-eids` cookie extracted from the request before routing consumes it. `/auction` responses ignore it in favor of the request's own client set." + +In `src/ec/prebid_eids.rs`, replace the doc comment of `collect_eid_updates` (`:129-137`): + +```rust +/// Collects validated request-local partner updates without performing KV I/O. +/// +/// Finalization passes either the `ts-eids` cookie or the request's own client +/// EIDs, never both: `/auction` passes the client set it used, every other +/// route the cookie. The `sharedId` cookie is applied last, so it wins over a +/// Prebid EID for the same partner. With repeated sources, the later entry's +/// first valid UID is the candidate for that partner. +``` + +- [ ] **Step 5: Run the tests.** + +Run: `cargo test-fastly -- ingests_ cookie_only_source finalize_ auction_route_carries_the_raw prebid_eids` +Expected: PASS, including every existing `finalize_*` test. + +- [ ] **Step 6: Prove the regression nets.** + +- Temporarily change `browser_eid_input`'s fallback arm to `_ => (None, ec_context.client_eids()),` and run `cargo test-fastly -- cookie_only_source finalize_generated_ec_without_sync_source finalize_recovers_orphaned_ec`. All three must FAIL. Restore. +- Temporarily change `crates/trusted-server-adapter-fastly/src/app.rs:420` to `let eids_cookie: Option = None;` and run `cargo test-fastly -- auction_route_carries_the_raw`. It must FAIL. Restore. + +Record both. + +- [ ] **Step 7: Gates and commit.** + +Run: `cargo fmt --all -- --check && cargo clippy-fastly && cargo clippy-axum && cargo test-fastly && cargo test-fastly-reuse` +Expected: clean. + +```bash +git add crates/trusted-server-core/src crates/trusted-server-adapter-fastly/src/app.rs docs/superpowers/implementation-notes/2026-10-18-prebid-eid-fidelity.md +git commit --signoff -S -m "Ingest only the auction's own client EIDs on /auction" -m "Finalization chooses its browser input by sync source. /auction reads the client set the auction used, so a cookie from an earlier page is not ingested next to a scoped body. Other routes keep reading ts-eids, and the sharedId cookie is still applied last." +``` + +--- + +## TSJS test commands + +From `crates/trusted-server-js/lib`: `npx vitest run -t ""` for one test, `npx vitest run` for everything, `npm run format` for the Prettier check (`npm run format:write` fixes). The JSDOM suites (`test/*.test.mjs`) build real artifacts and take minutes. + +--- + +### Task 6: Pure EID collector and cookie value builder + +Spec: 4.2 (collection steps 1 to 4), 4.3 (content, size, encoding, failure), 7.1 rules 4, 6 and 8, 7.2, A9. + +**Files:** + +- Create: `crates/trusted-server-js/lib/src/integrations/prebid/eids.ts` +- Create: `crates/trusted-server-js/lib/test/fixtures/eid-fixture-input.ts` +- Create: `crates/trusted-server-js/lib/test/integrations/prebid/eids.test.ts` + +**Interfaces:** + +- Consumes: `test/fixtures/ts-eids-cookie.txt` (Task 3). +- Produces, in `src/integrations/prebid/eids.ts`: + + ```ts + export const MAX_AUCTION_EIDS_BYTES = 65_536 + export const MAX_EID_COOKIE_BYTES = 3072 + export interface EidCollectionStats { + forwarded: number + unserializable: number + overBudget: number + } + export interface FittedEidCookie { + value: string | undefined + droppedSources: string[] + trimmedSources: string[] + } + export function collectBidderEids( + bidderRequest: unknown, + stats?: EidCollectionStats + ): unknown[] | undefined + export function fitEidCookie(eids: readonly unknown[]): FittedEidCookie + ``` + + `collectBidderEids` keeps the spec 4.2 signature, plus an optional `stats` out-parameter for the debug log in spec 4.12. Task 7 calls both functions from `buildRequests`. + +- Produces, in `test/fixtures/eid-fixture-input.ts`: `export const FIXTURE_EIDS` (used by Task 7). + +- [ ] **Step 1: Add the fixture input and a stub module.** + +`test/fixtures/eid-fixture-input.ts`: + +```ts +/** + * The fixed input behind `auction-body-eids.txt` and `ts-eids-cookie.txt` + * (spec 7.1 rule 8). The middle entry's UID `id` holds a lone UTF-16 + * surrogate, which `JSON.stringify` writes as the escape `\ud800` and the + * server drops for that entry only. The first entry has non-Latin-1 text in + * `inserter` and in a UID `ext`. The last has an EID-level `ext`, which the + * cookie omits. Key order matters: the fixtures are exact bytes. + */ +export const FIXTURE_EIDS = [ + { + source: 'first.example.com', + inserter: 'inserter-€.example.com', + matcher: 'first.example.com', + mm: 3, + uids: [{ id: 'first-uid', atype: 1, ext: { label: '日本' } }], + }, + { source: 'bad.example.com', uids: [{ id: 'bad-\ud800-uid', atype: 1 }] }, + { + source: 'last.example.com', + ext: { segments: ['s1'] }, + uids: [{ id: 'last-uid', atype: 3 }], + }, +] +``` + +`src/integrations/prebid/eids.ts`, a stub so the tests fail on assertions rather than on a missing import: + +```ts +/** UTF-8 byte budget for the EIDs forwarded in one `/auction` body. */ +export const MAX_AUCTION_EIDS_BYTES = 65_536 + +/** Maximum length of the base64 `ts-eids` cookie value. */ +export const MAX_EID_COOKIE_BYTES = 3072 + +/** Counts reported by {@link collectBidderEids} for debug logging. */ +export interface EidCollectionStats { + /** Entries copied into the result. */ + forwarded: number + /** Entries dropped because they could not be serialized. */ + unserializable: number + /** Entries dropped because the byte budget was exhausted. */ + overBudget: number +} + +/** A fitted `ts-eids` cookie value and the sources trimming touched. */ +export interface FittedEidCookie { + /** Base64 cookie value, or `undefined` when nothing fits. */ + value: string | undefined + /** Sources removed whole. */ + droppedSources: string[] + /** Sources that lost UIDs but stayed in the cookie. */ + trimmedSources: string[] +} + +export function collectBidderEids( + _bidderRequest: unknown, + _stats?: EidCollectionStats +): unknown[] | undefined { + return undefined +} + +export function fitEidCookie(_eids: readonly unknown[]): FittedEidCookie { + return { value: undefined, droppedSources: [], trimmedSources: [] } +} +``` + +- [ ] **Step 2: Write the failing tests.** + +`test/integrations/prebid/eids.test.ts`: + +```ts +import fs from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +import { describe, expect, it } from 'vitest' + +import { + collectBidderEids, + fitEidCookie, + MAX_AUCTION_EIDS_BYTES, + MAX_EID_COOKIE_BYTES, + type EidCollectionStats, +} from '../../../src/integrations/prebid/eids' +import { FIXTURE_EIDS } from '../../fixtures/eid-fixture-input' + +function deepFreeze(value: T, seen = new WeakSet()): T { + if (value !== null && typeof value === 'object' && !seen.has(value)) { + seen.add(value) + for (const child of Object.values(value)) { + deepFreeze(child, seen) + } + Object.freeze(value) + } + return value +} + +/** A bidder request shaped as Prebid passes it: frozen `ortb2`, `auctionId` hidden. */ +function bidderRequest(user: unknown): unknown { + return deepFreeze({ + bidderCode: 'trustedServer', + auctionId: null, + ortb2: { user }, + }) +} + +function freshStats(): EidCollectionStats { + return { forwarded: 0, unserializable: 0, overBudget: 0 } +} + +function utf8Length(text: string): number { + return new TextEncoder().encode(text).length +} + +/** An entry whose `JSON.stringify` is exactly `bytes` UTF-8 bytes long. */ +function entryOfBytes(source: string, bytes: number, filler = 'x'): unknown { + const base = JSON.stringify({ source, uids: [{ id: '' }] }) + const fillerBytes = utf8Length(filler) + return { + source, + uids: [{ id: filler.repeat((bytes - utf8Length(base)) / fillerBytes) }], + } +} + +function decodeCookie(value: string | undefined): unknown { + expect(value, 'should produce a cookie value').toBeDefined() + return JSON.parse( + new TextDecoder().decode( + Uint8Array.from(atob(value!), (char) => char.charCodeAt(0)) + ) + ) +} + +function readFixture(name: string): string { + return fs + .readFileSync( + path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '../../fixtures', + name + ), + 'utf8' + ) + .trimEnd() +} + +const EXT_EID = { + source: 'ext.example.com', + uids: [{ id: 'ext-uid', atype: 1 }], +} +const USER_EID = { + source: 'user.example.com', + uids: [{ id: 'user-uid', atype: 1 }], +} + +describe('collectBidderEids', () => { + it('reads ortb2.user.ext.eids', () => { + expect( + collectBidderEids(bidderRequest({ ext: { eids: [EXT_EID] } })) + ).toEqual([EXT_EID]) + }) + + it('reads ortb2.user.eids, where RTD modules can write after normalization', () => { + expect(collectBidderEids(bidderRequest({ eids: [USER_EID] }))).toEqual([ + USER_EID, + ]) + }) + + it('reads user.ext.eids before user.eids', () => { + const request = bidderRequest({ + ext: { eids: [EXT_EID] }, + eids: [USER_EID], + }) + expect( + Object.isFrozen((request as { ortb2: { user: object } }).ortb2.user), + 'the input should be frozen as Prebid passes it' + ).toBe(true) + + expect(collectBidderEids(request)).toEqual([EXT_EID, USER_EID]) + }) + + it.each([ + ['no user', bidderRequest(undefined)], + ['an empty user', bidderRequest({})], + [ + 'non-array paths', + bidderRequest({ ext: { eids: 'x' }, eids: { source: 'x' } }), + ], + ['no ortb2', deepFreeze({ bidderCode: 'trustedServer' })], + ])('returns undefined for %s', (_label, request) => { + expect(collectBidderEids(request)).toBeUndefined() + }) + + it.each([undefined, null, 'request', 7])( + 'returns undefined for a bidder request that is not an object: %s', + (request) => { + expect(collectBidderEids(request)).toBeUndefined() + } + ) + + it('returns plain-data copies without undefined fields', () => { + const openPair = { + source: 'openpair.example.com', + inserter: undefined, + matcher: undefined, + mm: 3, + uids: [{ id: 'pair-uid', atype: 571187 }], + } + + const result = collectBidderEids( + bidderRequest({ ext: { eids: [openPair] } }) + ) + + expect( + result, + 'undefined provenance fields should disappear' + ).toStrictEqual([ + { + source: 'openpair.example.com', + mm: 3, + uids: [{ id: 'pair-uid', atype: 571187 }], + }, + ]) + expect( + result![0], + 'the result should be a copy, not Prebid’s object' + ).not.toBe(openPair) + }) + + it('drops an entry whose serialization throws and keeps the others', () => { + const circular: Record = {} + circular.self = circular + const stats = freshStats() + + const result = collectBidderEids( + bidderRequest({ + ext: { + eids: [ + EXT_EID, + { + source: 'circular.example.com', + ext: circular, + uids: [{ id: 'c' }], + }, + { + source: 'bigint.example.com', + uids: [{ id: 'b', atype: BigInt(10) }], + }, + USER_EID, + ], + }, + }), + stats + ) + + expect(result).toEqual([EXT_EID, USER_EID]) + expect(stats).toEqual({ forwarded: 2, unserializable: 2, overBudget: 0 }) + }) + + it('keeps a set that fits the UTF-8 budget exactly', () => { + const first = entryOfBytes( + 'first.example.com', + MAX_AUCTION_EIDS_BYTES - 100 + ) + const second = entryOfBytes('second.example.com', 100) + const stats = freshStats() + + expect( + collectBidderEids( + bidderRequest({ ext: { eids: [first, second] } }), + stats + ) + ).toEqual([first, second]) + expect(stats).toEqual({ forwarded: 2, unserializable: 0, overBudget: 0 }) + }) + + it('drops the entry that crosses the budget and every entry after it', () => { + const first = entryOfBytes( + 'first.example.com', + MAX_AUCTION_EIDS_BYTES - 100 + ) + const second = entryOfBytes('second.example.com', 101) + const third = entryOfBytes('third.example.com', 60) + const stats = freshStats() + + expect( + collectBidderEids( + bidderRequest({ ext: { eids: [first, second, third] } }), + stats + ) + ).toEqual([first]) + expect(stats).toEqual({ forwarded: 1, unserializable: 0, overBudget: 2 }) + }) + + it('measures the budget in UTF-8 bytes, not JavaScript string length', () => { + const ascii = entryOfBytes('ascii.example.com', 40_000) + // `€` is one UTF-16 code unit and three UTF-8 bytes: 53 bytes of JSON + // around 10,000 of them. + const multibyte = entryOfBytes('multibyte.example.com', 30_053, '€') + expect( + JSON.stringify(ascii).length + JSON.stringify(multibyte).length, + 'precondition: the set fits by JavaScript length' + ).toBeLessThan(MAX_AUCTION_EIDS_BYTES) + + expect( + collectBidderEids(bidderRequest({ ext: { eids: [ascii, multibyte] } })) + ).toEqual([ascii]) + }) + + it('keeps order and repeated sources', () => { + const a1 = { source: 'a.example.com', mm: 1, uids: [{ id: 'u' }] } + const b = { source: 'b.example.com', uids: [{ id: 'u' }] } + const a2 = { source: 'a.example.com', mm: 2, uids: [{ id: 'u' }] } + + expect( + collectBidderEids(bidderRequest({ ext: { eids: [a1, b, a2] } })) + ).toEqual([a1, b, a2]) + }) + + it('forwards a lone surrogate unchanged for the server to drop', () => { + const result = collectBidderEids( + bidderRequest({ ext: { eids: FIXTURE_EIDS } }) + ) + + expect(result).toEqual(FIXTURE_EIDS) + expect( + JSON.stringify(result), + 'JSON.stringify should write the escape' + ).toContain('"bad-\\ud800-uid"') + }) + + it('passes the Prebid module shapes from spec 3.5 through as plain data', () => { + const fiftyOneDegrees = [ + { source: '51d.es', mm: 1, uids: [{ id: 'device-1', atype: 1 }] }, + { source: '51d.es', mm: 2, uids: [{ id: 'device-2', atype: 1 }] }, + ] + const openPair = { + source: 'openpair.example.com', + inserter: undefined, + matcher: undefined, + uids: [{ id: 'p' }], + } + const liveIntent = { + source: 'liveintent.com', + ext: { segments: ['s1', 's2'] }, + uids: [{ id: 'li', atype: 3 }], + } + const oneKey = { + source: 'paf.example.com', + ext: { preferences: { use_browsing_for_personalization: true } }, + uids: [{ id: 'ok', atype: 1 }], + } + const pair = { + source: 'google.com', + uids: [{ id: 'pair-id', atype: 571187 }], + } + + const result = collectBidderEids( + bidderRequest({ + ext: { eids: [openPair, liveIntent, oneKey, pair] }, + eids: fiftyOneDegrees, + }) + ) + + expect(result).toStrictEqual([ + { source: 'openpair.example.com', uids: [{ id: 'p' }] }, + liveIntent, + oneKey, + pair, + ...fiftyOneDegrees, + ]) + }) +}) + +describe('fitEidCookie', () => { + it('removes EID-level ext and drops the duplicates that creates, comparing with sorted keys', () => { + const withExt = { + source: 'a.example.com', + ext: { segments: ['s1'] }, + uids: [{ id: 'a' }], + } + const reordered = { uids: [{ id: 'a' }], source: 'a.example.com' } + const other = { + source: 'b.example.com', + uids: [{ id: 'b', ext: { kept: true } }], + } + + const fitted = fitEidCookie([withExt, reordered, other]) + + expect(decodeCookie(fitted.value)).toEqual([ + { source: 'a.example.com', uids: [{ id: 'a' }] }, + other, + ]) + expect(fitted.droppedSources).toEqual([]) + expect(fitted.trimmedSources).toEqual([]) + }) + + it('does not modify the entries it is given', () => { + const entry = { + source: 'a.example.com', + ext: { segments: ['s1'] }, + uids: [{ id: 'a' }, { id: 'b' }], + } + const snapshot = structuredClone(entry) + + fitEidCookie([entry]) + + expect(entry).toEqual(snapshot) + }) + + it('drops whole tail sources until the cookie fits', () => { + const long = 'x'.repeat(400) + const entries = [0, 1, 2, 3, 4, 5, 6].map((index) => ({ + source: `s${index}.example.com`, + uids: [{ id: long, atype: 1 }], + })) + + const fitted = fitEidCookie(entries) + + expect(fitted.value!.length).toBeLessThanOrEqual(MAX_EID_COOKIE_BYTES) + expect(decodeCookie(fitted.value)).toEqual(entries.slice(0, 5)) + expect(fitted.droppedSources).toEqual(['s6.example.com', 's5.example.com']) + expect(fitted.trimmedSources).toEqual([]) + }) + + it('trims UIDs from the last source before dropping it', () => { + const long = 'x'.repeat(400) + const uids = Array.from({ length: 10 }, (_, index) => ({ + id: `${long}${index}`, + atype: 1, + })) + + const fitted = fitEidCookie([{ source: 'ids.example.com', uids }]) + + expect(decodeCookie(fitted.value)).toEqual([ + { source: 'ids.example.com', uids: uids.slice(0, 5) }, + ]) + expect(fitted.droppedSources).toEqual([]) + expect(fitted.trimmedSources).toEqual(['ids.example.com']) + }) + + it('reports a source trimmed and then dropped only as dropped', () => { + const huge = 'y'.repeat(2500) + const kept = { + source: 'kept.example.com', + uids: [{ id: 'kept', atype: 1 }], + } + + const fitted = fitEidCookie([ + kept, + { + source: 'dropped.example.com', + uids: Array.from({ length: 3 }, (_, index) => ({ + id: `${huge}${index}`, + atype: 1, + })), + }, + ]) + + expect(decodeCookie(fitted.value)).toEqual([kept]) + expect(fitted.droppedSources).toEqual(['dropped.example.com']) + expect(fitted.trimmedSources).toEqual([]) + }) + + it.each([ + ['a string', 'x'.repeat(4000), '(malformed entry)'], + [ + 'an object without a uids array', + { source: 'no-uids.example.com', blob: 'x'.repeat(4000) }, + 'no-uids.example.com', + ], + ])('removes a last entry that is %s whole', (_label, last, droppedLabel) => { + const small = { source: 'small.example.com', uids: [{ id: 's' }] } + + const fitted = fitEidCookie([small, last]) + + expect(decodeCookie(fitted.value)).toEqual([small]) + expect(fitted.droppedSources).toEqual([droppedLabel]) + }) + + it('removes a null last entry whole before trimming the entry before it', () => { + const big = { + source: 'big.example.com', + uids: [{ id: 'y'.repeat(1500) }, { id: 'z'.repeat(1500) }], + } + + const fitted = fitEidCookie([big, null]) + + expect(decodeCookie(fitted.value)).toEqual([ + { source: 'big.example.com', uids: [big.uids[0]] }, + ]) + expect(fitted.droppedSources).toEqual(['(malformed entry)']) + expect(fitted.trimmedSources).toEqual(['big.example.com']) + }) + + it('returns no value when nothing fits', () => { + const fitted = fitEidCookie([ + { source: 'huge.example.com', uids: [{ id: 'h'.repeat(5000) }] }, + ]) + + expect(fitted).toEqual({ + value: undefined, + droppedSources: ['huge.example.com'], + trimmedSources: [], + }) + }) + + it('encodes non-Latin-1 text as UTF-8 base64 that round-trips', () => { + const entries = [ + { + source: 'ids.example.com', + inserter: 'inserter-€', + uids: [{ id: '日本' }], + }, + ] + expect( + () => btoa(JSON.stringify(entries)), + 'precondition: btoa alone throws here' + ).toThrow() + + expect(decodeCookie(fitEidCookie(entries).value)).toEqual(entries) + }) + + it('matches btoa output for ASCII payloads', () => { + const entries = [ + { source: 'ids.example.com', uids: [{ id: 'ascii-uid', atype: 1 }] }, + ] + + expect(fitEidCookie(entries).value).toBe(btoa(JSON.stringify(entries))) + }) + + it('produces exactly the ts-eids-cookie fixture for the fixture input', () => { + const collected = collectBidderEids( + bidderRequest({ ext: { eids: FIXTURE_EIDS } }) + ) + + expect(fitEidCookie(collected!).value).toBe( + readFixture('ts-eids-cookie.txt') + ) + }) +}) +``` + +- [ ] **Step 3: Run them and see them fail.** + +Run: `cd crates/trusted-server-js/lib && npx vitest run test/integrations/prebid/eids.test.ts` +Expected: every test FAILS except the four `returns undefined …` and `… not an object` cases and `does not modify the entries it is given`, which the stub passes trivially. Record the failures. Step 4 must keep those passing; with the real implementation they test real behavior. + +- [ ] **Step 4: Implement `eids.ts`.** + +Replace the stub's two functions and add the helpers. Keep the constants and interfaces. Put the file header comment at the top: + +```ts +// Plain-data copies of the EIDs Prebid placed in the `trustedServer` bidder +// request, and the `ts-eids` cookie value built from them. +// +// Pure functions: nothing here reads `pbjs`, the DOM or the clock. The server +// validates every entry of the body and the cookie alike (spec 4.4), so this +// module checks nothing beyond what serialization needs. + +const utf8Encoder = new TextEncoder() + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value) +} + +/** + * Returns the arrays Prebid may hold EIDs in, in reading order: + * `ortb2.user.ext.eids`, then `ortb2.user.eids`. RTD modules write after + * Prebid normalizes first-party data, so `user.eids` can hold entries too. + * Prebid redacts both paths for the bidder, so reading both does not widen + * what `trustedServer` receives. + */ +function bidderEidArrays(bidderRequest: unknown): unknown[][] { + if (!isRecord(bidderRequest) || !isRecord(bidderRequest.ortb2)) { + return [] + } + const user = bidderRequest.ortb2.user + if (!isRecord(user)) { + return [] + } + const arrays: unknown[][] = [] + if (isRecord(user.ext) && Array.isArray(user.ext.eids)) { + arrays.push(user.ext.eids) + } + if (Array.isArray(user.eids)) { + arrays.push(user.eids) + } + return arrays +} + +function serializeEntry(entry: unknown): string | undefined { + try { + // `undefined` and functions serialize to `undefined`; treat them as + // unserializable like a circular object or a BigInt. + return JSON.stringify(entry) + } catch { + return undefined + } +} + +/** + * Copies the EIDs Prebid placed in a `trustedServer` bidder request. + * + * Reads `ortb2.user.ext.eids` then `ortb2.user.eids`, copies each entry + * through a JSON round trip so the result is plain data (`undefined` fields + * disappear), drops entries that cannot be serialized, and keeps entries in + * order while their serializations total at most + * {@link MAX_AUCTION_EIDS_BYTES} UTF-8 bytes, dropping the rest. Entries are + * not merged and not validated. + * + * @param bidderRequest the second `buildRequests` argument + * @param stats filled with counts for debug logging, when given + * @returns the copied entries, or `undefined` when none remain + */ +export function collectBidderEids( + bidderRequest: unknown, + stats?: EidCollectionStats +): unknown[] | undefined { + const collected: unknown[] = [] + let usedBytes = 0 + let budgetExhausted = false + let unserializable = 0 + let overBudget = 0 + + for (const entries of bidderEidArrays(bidderRequest)) { + for (const entry of entries) { + const serialized = serializeEntry(entry) + if (serialized === undefined) { + unserializable += 1 + continue + } + const bytes = utf8Encoder.encode(serialized).length + if (budgetExhausted || usedBytes + bytes > MAX_AUCTION_EIDS_BYTES) { + budgetExhausted = true + overBudget += 1 + continue + } + usedBytes += bytes + collected.push(JSON.parse(serialized)) + } + } + + if (stats) { + stats.forwarded = collected.length + stats.unserializable = unserializable + stats.overBudget = overBudget + } + return collected.length > 0 ? collected : undefined +} + +function withoutEidExt(entry: unknown): unknown { + if (!isRecord(entry) || !('ext' in entry)) { + return entry + } + const copy: Record = {} + for (const [key, value] of Object.entries(entry)) { + if (key !== 'ext') { + copy[key] = value + } + } + return copy +} + +/** JSON with object keys sorted at every level, for equality checks. */ +function canonicalJson(value: unknown): string { + if (Array.isArray(value)) { + return `[${value.map(canonicalJson).join(',')}]` + } + if (isRecord(value)) { + return `{${Object.keys(value) + .sort() + .map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`) + .join(',')}}` + } + return JSON.stringify(value) ?? 'null' +} + +function cookieLabel(entry: unknown): string { + return isRecord(entry) && typeof entry.source === 'string' + ? entry.source + : '(malformed entry)' +} + +function encodeUtf8Base64(text: string): string { + let binary = '' + for (const byte of utf8Encoder.encode(text)) { + binary += String.fromCharCode(byte) + } + return btoa(binary) +} + +/** + * Builds the `ts-eids` cookie value from a request's collected EIDs. + * + * Removes each entry's EID-level `ext`, drops entries that become identical + * (compared with sorted keys), then trims from the tail until the standard + * padded base64 of the JSON array's UTF-8 bytes is at most + * {@link MAX_EID_COOKIE_BYTES} long: drop UIDs from the last entry, then the + * entry itself, and repeat. A last entry that is not an object with a `uids` + * array is removed whole. The input is not modified. + * + * @param eids the entries forwarded in the `/auction` body + * @returns the cookie value, or `undefined` when nothing fits, and the + * sources trimming touched + */ +export function fitEidCookie(eids: readonly unknown[]): FittedEidCookie { + const seen = new Set() + const payload: unknown[] = [] + for (const entry of eids) { + const stripped = withoutEidExt(entry) + const key = canonicalJson(stripped) + if (seen.has(key)) { + continue + } + seen.add(key) + payload.push( + isRecord(stripped) && Array.isArray(stripped.uids) + ? { ...stripped, uids: [...stripped.uids] } + : stripped + ) + } + + const dropped = new Set() + const trimmed = new Set() + while (payload.length > 0) { + const value = encodeUtf8Base64(JSON.stringify(payload)) + if (value.length <= MAX_EID_COOKIE_BYTES) { + return { + value, + droppedSources: [...dropped], + trimmedSources: [...trimmed], + } + } + + const last = payload[payload.length - 1] + if (isRecord(last) && Array.isArray(last.uids) && last.uids.length > 1) { + last.uids = last.uids.slice(0, -1) + trimmed.add(cookieLabel(last)) + continue + } + + payload.pop() + const label = cookieLabel(last) + dropped.add(label) + // A source trimmed and then dropped is reported once, as dropped. + trimmed.delete(label) + } + + return { + value: undefined, + droppedSources: [...dropped], + trimmedSources: [...trimmed], + } +} +``` + +The spread copies made for entries with a `uids` array are fresh objects, so `last.uids = …` never touches the caller's entries. + +- [ ] **Step 5: Run the tests.** + +Run: `cd crates/trusted-server-js/lib && npx vitest run test/integrations/prebid/eids.test.ts` +Expected: PASS. If the fixture test fails, compare `fitEidCookie(...).value` with the file character by character. The fixture is fixed by Task 3 and the Rust tests. Fix the code, not the file. + +- [ ] **Step 6: Format and commit.** + +Run: `cd crates/trusted-server-js/lib && npm run format:write && npm run format && npx vitest run test/integrations/prebid/eids.test.ts` +Expected: format clean, tests PASS. + +```bash +git add crates/trusted-server-js/lib/src/integrations/prebid/eids.ts crates/trusted-server-js/lib/test/integrations/prebid/eids.test.ts crates/trusted-server-js/lib/test/fixtures/eid-fixture-input.ts docs/superpowers/implementation-notes/2026-10-18-prebid-eid-fidelity.md +git commit --signoff -S -m "Add a pure collector for the trustedServer bidder request's EIDs" -m "collectBidderEids copies ortb2.user.ext.eids and user.eids as plain data within a 64 KiB UTF-8 budget. fitEidCookie builds the ts-eids value without EID-level ext, trimmed to 3,072 bytes and encoded as UTF-8 base64." +``` + +--- + +### Task 7: The adapter forwards the bidder request's EIDs and owns `ts-eids` in `buildRequests` + +Spec: 4.2 (`buildRequests` steps 1 to 5, deletions), 4.3 (when, overlapping auctions, TCF block), 4.11, 4.12, 7.1 rules 2 to 6 and 10, 7.3, A1, A7. + +**Files:** + +- Modify: `crates/trusted-server-js/lib/src/core/auction.ts:23-41` (`AuctionUid`, `AuctionEid`) +- Modify: `crates/trusted-server-js/lib/src/integrations/prebid/index.ts`: imports (`:26-35`), `PbjsGlobal` (`:37-45`), `MAX_OPENRTB_ATYPE` (`:151-153`), `PrebidUserIdEid` (`:1012-1015`), `sanitizeAuctionUid` (`:1076-1101`), `collectAuctionEids` (`:1891-1914`), adapter registration and `buildRequests` (`:2347-2376`), the chained `bidsBackHandler` (`:2537-2541`), and the cookie section (`:2900-3009`) +- Test: `crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts`, `crates/trusted-server-js/lib/test/core/request.test.ts` + +**Interfaces:** + +- Consumes: `collectBidderEids`, `fitEidCookie`, `EidCollectionStats` (Task 6); `FIXTURE_EIDS` and both fixture files. +- Produces: `buildRequests(validBidRequests, bidderRequest?: unknown)`; a `pbjs.onEvent('tcf2Enforcement', …)` listener registered once per page in `installPrebidNpm`; `export type AuctionEid = unknown`. `AuctionUid` is deleted. + +- [ ] **Step 1: Install the D1 canary and the cookie environment check.** + +In `test/integrations/prebid/index.test.ts`: + +1. In the `vi.hoisted` block (`:240-341`), add before `mockGetUserIdsAsEids`: + +```ts +// D1 canary (spec 7.1 rule 3): an ID only pbjs.getUserIdsAsEids() knows. It +// must never reach an /auction body or ts-eids. +const CANARY_EID = { + source: 'canary.example.com', + uids: [{ id: 'canary-uid', atype: 1 }], +} +// Every /auction body the trustedServer adapter builds, recorded at the +// adapter so no test can bypass the canary check. +const auctionBodyLog = { bodies: [] as string[], total: 0 } +``` + +Change `mockGetUserIdsAsEids` to return the canary: + +```ts +const mockGetUserIdsAsEids = vi.fn( + () => + [CANARY_EID] as Array<{ + source: string + uids?: Array<{ id: string; atype?: number }> + }> +) +``` + +and `mockRegisterBidAdapter` to record built bodies: + +```ts +const mockRegisterBidAdapter = vi.fn( + ( + _bidder: unknown, + code: string, + spec: { buildRequests?: (...args: unknown[]) => { data?: unknown } } + ) => { + if (code !== 'trustedServer' || typeof spec?.buildRequests !== 'function') + return + const buildRequests = spec.buildRequests + spec.buildRequests = function (this: unknown, ...args: unknown[]) { + const request = buildRequests.apply(this, args) + auctionBodyLog.bodies.push(String(request?.data)) + auctionBodyLog.total += 1 + return request + } + } +) +``` + +Add `CANARY_EID` and `auctionBodyLog` to the object `vi.hoisted` returns and to the destructuring at `:198`. + +2. In the four describe-level `beforeEach` blocks that reset the mock (`:580-581`, `:3363-3364`, `:4465-4466`, `:7552-7553`), change `mockGetUserIdsAsEids.mockReturnValue([]);` to `mockGetUserIdsAsEids.mockReturnValue([CANARY_EID]);`. + +3. Add `afterAll` and `beforeAll` to the `vitest` import. After the file-level `beforeEach` (`:370-376`) add the shared helpers and hooks: + +```ts +function readTsEidsCookie(): string | undefined { + return document.cookie.match(/(?:^|; )ts-eids=([^;]*)/)?.[1] || undefined +} + +function decodeBase64Utf8(value: string): string { + return new TextDecoder().decode( + Uint8Array.from(atob(value), (char) => char.charCodeAt(0)) + ) +} + +function decodedTsEidsCookie(): unknown { + const value = readTsEidsCookie() + return value === undefined ? undefined : JSON.parse(decodeBase64Utf8(value)) +} + +function presetTsEidsCookie(value: string): void { + document.cookie = `ts-eids=${value}; Path=/; Secure; SameSite=Lax; Max-Age=60` + expect(readTsEidsCookie(), 'the preset ts-eids cookie should read back').toBe( + value + ) +} + +beforeAll(() => { + // jsdom silently drops a Secure cookie on a non-HTTPS URL other than + // localhost (spec 7.1 rule 5). Prove the shim's attributes survive here. + document.cookie = 'ts-eids-probe=1; Path=/; Secure; SameSite=Lax; Max-Age=60' + expect( + document.cookie, + 'shim cookie attributes should be readable here' + ).toContain('ts-eids-probe=1') + document.cookie = 'ts-eids-probe=; Path=/; Max-Age=0' +}) + +afterEach(() => { + expect( + mockGetUserIdsAsEids(), + 'a test switched the getUserIdsAsEids canary off' + ).toEqual([CANARY_EID]) + for (const body of auctionBodyLog.bodies) { + expect( + body, + 'the getUserIdsAsEids canary leaked into an /auction body' + ).not.toContain(CANARY_EID.source) + } + auctionBodyLog.bodies.length = 0 + const cookie = readTsEidsCookie() + if (cookie !== undefined) { + let readable = cookie + try { + readable = decodeBase64Utf8(cookie) + } catch { + // A preset non-base64 test value; check it as written. + } + expect( + readable, + 'the getUserIdsAsEids canary leaked into ts-eids' + ).not.toContain(CANARY_EID.source) + } +}) + +afterAll(() => { + expect( + auctionBodyLog.total, + 'the canary should have inspected at least one /auction body' + ).toBeGreaterThan(0) +}) +``` + +4. Add the shared test inputs next to `TestAdapterSpec` (`:215`). Add these imports at the top: `fs` from `node:fs`, `path` from `node:path`, `fileURLToPath` from `node:url`, and `FIXTURE_EIDS` from `../../fixtures/eid-fixture-input`. + +```ts +function deepFreeze(value: T, seen = new WeakSet()): T { + if (value !== null && typeof value === 'object' && !seen.has(value)) { + seen.add(value) + for (const child of Object.values(value)) { + deepFreeze(child, seen) + } + Object.freeze(value) + } + return value +} + +/** + * A bidder request shaped as Prebid passes it to buildRequests: a frozen + * `ortb2` and `auctionId: null`, as Prebid's proxy hides it by default. + */ +function prebidBidderRequest( + user: Record +): Record { + return deepFreeze({ + bidderCode: 'trustedServer', + auctionId: null, + ortb2: { user }, + }) +} + +function trustedServerBidRequests(): Array> { + return [ + { + adUnitCode: 'div-gpt-1', + bidder: 'trustedServer', + mediaTypes: { banner: { sizes: [[300, 250]] } }, + params: {}, + }, + ] +} + +function readFixture(name: string): string { + return fs + .readFileSync( + path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '../../fixtures', + name + ), + 'utf8' + ) + .trimEnd() +} +``` + +- [ ] **Step 2: Delete or rewrite the tests that encode the old collector.** + +In `describe('adapter spec')` (`:2385`): + +- `buildRequests includes current Prebid EIDs in the /auction payload` (`:2419`): delete. Step 3 covers it. +- `forwards the opaque LiveRamp envelope as a liveramp.com EID` (`:2462`): rewrite so the envelope arrives in the bidder request: + +```ts +it('forwards an opaque LiveRamp envelope from the bidder request unchanged', () => { + const spec = getAdapterSpec() + const liveramp = { + source: 'liveramp.com', + uids: [{ id: 'opaque-test-envelope', atype: 3 }], + } + + const request = spec.buildRequests( + trustedServerBidRequests(), + prebidBidderRequest({ ext: { eids: [liveramp] } }) + ) + + expect(JSON.parse(String(request.data)).eids).toEqual([liveramp]) +}) +``` + +- `drops empty and malformed LiveRamp envelope values` (`:2489`): delete. TSJS no longer validates (spec O2). Task 2's tests cover the server. +- `never writes an opaque LiveRamp envelope to logs` (`:2516`): delete. Step 3's logging test replaces it. +- `buildRequests clears stale ts-eids cookie when current Prebid EIDs are absent` (`:2546`): delete. Step 3 replaces it. +- `buildRequests preserves uid ext and sanitizes invalid atype values` (`:2563`): delete. Server-side rules are covered in Task 2. + +In `describe('requestBids shim')`, delete the cookie-sync tests that depend on `bidsBackHandler`: `syncs a structured ts-eids cookie after bidsBackHandler` (`:3160`), `preserves an opaque LiveRamp envelope in the ts-eids cookie` (`:3192`), `trims an oversized ts-eids cookie …` (`:3218`), `logs a source whose UIDs were partially trimmed but retained` (`:3265`), `reports a source trimmed and then dropped only as dropped` (`:3305`) and `clears ts-eids cookie after bidsBackHandler when no current EIDs remain` (`:3341`). Task 6 covers the trim rules. Step 3 covers the adapter's cookie and trim log. + +Then search the file for `mockGetUserIdsAsEids.mockReturnValue(`. The only matches left should be the four `beforeEach` lines from Step 1. + +- [ ] **Step 3: Write the failing adapter tests.** + +Inside `describe('adapter spec')`: + +```ts +it('forwards bidderRequest.ortb2 EIDs with EID-level fields, user.ext.eids first', () => { + const spec = getAdapterSpec() + const extEid = { + source: 'adserver.org', + inserter: 'adserver.org', + matcher: 'adserver.org', + mm: 4, + uids: [{ id: 'fictional-tdid', atype: 1, ext: { rtiPartner: 'TDID' } }], + } + const userEid = { + source: '51d.es', + mm: 1, + uids: [{ id: 'fictional-device', atype: 1 }], + } + + const result = spec.buildRequests( + trustedServerBidRequests(), + prebidBidderRequest({ ext: { eids: [extEid] }, eids: [userEid] }) + ) + + expect( + JSON.parse(String(result.data)).eids, + 'should forward both paths as sent' + ).toEqual([extEid, userEid]) + expect( + mockGetUserIdsAsEids, + 'should never read getUserIdsAsEids' + ).not.toHaveBeenCalled() + expect( + mockGetConfig.mock.calls.some(([key]) => key === 'ortb2'), + 'should never read the global ortb2 config' + ).toBe(false) +}) + +it('sends exactly the auction-body fixture for the fixture input', () => { + const spec = getAdapterSpec() + + const result = spec.buildRequests( + trustedServerBidRequests(), + prebidBidderRequest({ ext: { eids: FIXTURE_EIDS } }) + ) + + expect( + String(result.data), + 'TSJS should produce the Rust fixture byte for byte' + ).toBe(readFixture('auction-body-eids.txt')) +}) + +it('writes exactly the ts-eids fixture for the fixture input', () => { + const spec = getAdapterSpec() + + spec.buildRequests( + trustedServerBidRequests(), + prebidBidderRequest({ ext: { eids: FIXTURE_EIDS } }) + ) + + expect(readTsEidsCookie()).toBe(readFixture('ts-eids-cookie.txt')) +}) + +it('writes ts-eids from the body set without EID-level ext', () => { + const spec = getAdapterSpec() + const withExt = { + source: 'liveintent.example.com', + ext: { segments: ['s1'] }, + uids: [{ id: 'li-uid', atype: 3 }], + } + const plain = { + source: 'ids.example.com', + uids: [{ id: 'ids-uid', atype: 1 }], + } + + const result = spec.buildRequests( + trustedServerBidRequests(), + prebidBidderRequest({ ext: { eids: [withExt, plain] } }) + ) + + expect( + JSON.parse(String(result.data)).eids, + 'the body keeps EID-level ext' + ).toEqual([withExt, plain]) + expect( + decodedTsEidsCookie(), + 'the cookie copies the body set without EID-level ext' + ).toEqual([ + { source: 'liveintent.example.com', uids: [{ id: 'li-uid', atype: 3 }] }, + plain, + ]) +}) + +it('clears ts-eids before the request when the bidder request holds no EIDs', () => { + const spec = getAdapterSpec() + presetTsEidsCookie('preset-value') + + const result = spec.buildRequests( + trustedServerBidRequests(), + prebidBidderRequest({}) + ) + + const payload = JSON.parse(String(result.data)) + expect(payload.adUnits[0].code, 'the request should still be built').toBe( + 'div-gpt-1' + ) + expect(payload.eids, 'the body should carry no eids').toBeUndefined() + expect(readTsEidsCookie(), 'the cookie should be cleared').toBeUndefined() +}) + +it('sends no eids and leaves ts-eids alone without a bidder request object', () => { + const debugSpy = vi.spyOn(log, 'debug').mockImplementation(() => {}) + const spec = getAdapterSpec() + presetTsEidsCookie('preset-value') + + const result = spec.buildRequests(trustedServerBidRequests()) + + expect(JSON.parse(String(result.data)).adUnits[0].code).toBe('div-gpt-1') + expect(JSON.parse(String(result.data)).eids).toBeUndefined() + expect(readTsEidsCookie(), 'the cookie should be unchanged').toBe( + 'preset-value' + ) + expect( + debugSpy.mock.calls.some( + ([message]) => + typeof message === 'string' && message.includes('no bidder request') + ), + 'should explain at debug level' + ).toBe(true) +}) + +it('lets the later of two overlapping requests own ts-eids', () => { + const spec = getAdapterSpec() + const first = { source: 'first.example.com', uids: [{ id: 'first-uid' }] } + const second = { source: 'second.example.com', uids: [{ id: 'second-uid' }] } + + spec.buildRequests( + trustedServerBidRequests(), + prebidBidderRequest({ ext: { eids: [first] } }) + ) + spec.buildRequests( + trustedServerBidRequests(), + prebidBidderRequest({ ext: { eids: [second] } }) + ) + + expect(decodedTsEidsCookie()).toEqual([second]) +}) + +it('forwards ortb2 EIDs and writes ts-eids without the User ID module', () => { + const spec = getAdapterSpec() + const original = mockPbjs.getUserIdsAsEids + mockPbjs.getUserIdsAsEids = + undefined as unknown as typeof mockGetUserIdsAsEids + try { + const lockrId5 = { + source: 'id5-sync.com', + inserter: 'inserter.example.com', + mm: 2, + uids: [{ id: 'fictional-id5', atype: 1 }], + } + + const result = spec.buildRequests( + trustedServerBidRequests(), + prebidBidderRequest({ ext: { eids: [lockrId5] } }) + ) + + expect(JSON.parse(String(result.data)).eids).toEqual([lockrId5]) + expect(decodedTsEidsCookie()).toEqual([lockrId5]) + } finally { + mockPbjs.getUserIdsAsEids = original + } +}) + +it('logs EID counts and the cookie decision at debug level, never values', () => { + log.setLevel('debug') + const spies = [ + vi.spyOn(log, 'debug').mockImplementation(() => {}), + vi.spyOn(log, 'info').mockImplementation(() => {}), + vi.spyOn(log, 'warn').mockImplementation(() => {}), + vi.spyOn(log, 'error').mockImplementation(() => {}), + ] + try { + const spec = getAdapterSpec() + + spec.buildRequests( + trustedServerBidRequests(), + prebidBidderRequest({ + ext: { + eids: [ + { source: 'ids.example.com', uids: [{ id: 'secret-uid-value' }] }, + ], + }, + }) + ) + + expect(spies[0]).toHaveBeenCalledWith( + '[tsjs-prebid] forwarding bidder EIDs', + { + forwarded: 1, + unserializable: 0, + overBudget: 0, + cookie: 'written', + } + ) + const logged = spies.flatMap((spy) => spy.mock.calls).flat() + expect( + logged.some((value) => + String(JSON.stringify(value)).includes('secret-uid-value') + ), + 'no log argument may contain a UID value' + ).toBe(false) + } finally { + log.setLevel('warn') + } +}) + +it('keeps the trim log when the cookie had to drop sources', () => { + const debugSpy = vi.spyOn(log, 'debug').mockImplementation(() => {}) + const spec = getAdapterSpec() + const long = 'x'.repeat(400) + const entries = [0, 1, 2, 3, 4, 5, 6].map((index) => ({ + source: `s${index}.example.com`, + uids: [{ id: long, atype: 1 }], + })) + + const result = spec.buildRequests( + trustedServerBidRequests(), + prebidBidderRequest({ ext: { eids: entries } }) + ) + + expect( + JSON.parse(String(result.data)).eids, + 'the body keeps every entry' + ).toEqual(entries) + expect(decodedTsEidsCookie(), 'the cookie keeps what fits').toEqual( + entries.slice(0, 5) + ) + expect(debugSpy).toHaveBeenCalledWith( + '[tsjs-prebid] ts-eids cookie exceeded 3072 bytes; dropped sources: s6.example.com, s5.example.com' + ) + expect(debugSpy).toHaveBeenCalledWith( + '[tsjs-prebid] forwarding bidder EIDs', + { forwarded: 7, unserializable: 0, overBudget: 0, cookie: 'trimmed' } + ) +}) +``` + +Inside `describe('prebid/installPrebidNpm')`, outside `adapter spec`: + +```ts +it('exposes the getUserIdsAsEids canary on the pbjs the shim uses', () => { + const pbjs = installPrebidNpm() as unknown as { + getUserIdsAsEids?: () => unknown + } + + expect(pbjs.getUserIdsAsEids).toBe(mockGetUserIdsAsEids) + expect(pbjs.getUserIdsAsEids?.()).toEqual([CANARY_EID]) +}) + +function tcfEnforcementHandler(): (event: unknown) => void { + installPrebidNpm() + const registration = mockOnEvent.mock.calls.find( + ([event]) => event === 'tcf2Enforcement' + ) + expect( + registration, + 'the shim should listen for tcf2Enforcement' + ).toBeDefined() + return registration![1] as (event: unknown) => void +} + +it.each([ + ['biddersBlocked', { biddersBlocked: ['trustedServer'], eidsBlocked: [] }], + ['eidsBlocked', { biddersBlocked: [], eidsBlocked: ['trustedServer'] }], +])( + 'clears ts-eids when tcf2Enforcement lists trustedServer in %s', + (_list, blocked) => { + const handler = tcfEnforcementHandler() + presetTsEidsCookie('preset-value') + + handler({ + storageBlocked: [], + analyticsBlocked: [], + ufpdBlocked: [], + geoBlocked: [], + ...blocked, + }) + + expect(readTsEidsCookie()).toBeUndefined() + } +) + +it('leaves ts-eids alone when tcf2Enforcement lists only other bidders', () => { + const handler = tcfEnforcementHandler() + presetTsEidsCookie('preset-value') + + handler({ biddersBlocked: ['appnexus'], eidsBlocked: ['appnexus'] }) + + expect(readTsEidsCookie()).toBe('preset-value') +}) + +it('leaves ts-eids alone when an auction builds no trustedServer request', () => { + mockRequestBids.mockImplementation( + (opts?: { bidsBackHandler?: () => void }) => { + opts?.bidsBackHandler?.() + } + ) + presetTsEidsCookie('preset-value') + const pbjs = installPrebidNpm() + + pbjs.requestBids({ + adUnits: [{ bids: [{ bidder: 'appnexus', params: {} }] }], + } as unknown as RequestBidsArg) + + expect(mockRequestBids, 'the auction should have run').toHaveBeenCalled() + expect( + readTsEidsCookie(), + 'only buildRequests may write or clear the cookie' + ).toBe('preset-value') +}) +``` + +In `test/core/request.test.ts`, inside `describe('request.requestAds')`: + +```ts +it('sends no eids in the /auction body', async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + headers: { get: () => 'application/json' }, + json: async () => ({ seatbid: [] }), + }) + const { addAdUnits } = await import('../../src/core/registry') + const { requestAds } = await import('../../src/core/request') + document.body.innerHTML = '
' + addAdUnits({ code: 'slot1', mediaTypes: { banner: { sizes: [[300, 250]] } } }) + + requestAds() + await flushRequestAds() + + const [, init] = (globalThis.fetch as ReturnType).mock + .calls[0] as [string, RequestInit] + const body = JSON.parse(String(init.body)) as Record + expect(body.adUnits, 'the body should carry the ad unit').toEqual([ + { + code: 'slot1', + mediaTypes: { banner: { sizes: [[300, 250]] } }, + bids: [], + }, + ]) + expect( + body, + 'requestAds should leave EIDs to the ts-eids cookie' + ).not.toHaveProperty('eids') +}) +``` + +- [ ] **Step 4: Run them and see them fail.** + +Run: `cd crates/trusted-server-js/lib && npx vitest run test/integrations/prebid/index.test.ts test/core/request.test.ts` +Expected before the change: + +- Every new `adapter spec` test FAILS. The old collector ignores the bidder request and forwards the canary, so the shared `afterEach` also reports `the getUserIdsAsEids canary leaked into an /auction body` for every test that builds a request. +- The two `clears ts-eids when tcf2Enforcement …` cases FAIL: no listener is registered. +- `leaves ts-eids alone when an auction builds no trustedServer request` FAILS: `main`'s `bidsBackHandler` writes the canary into the cookie. +- `leaves ts-eids alone when tcf2Enforcement lists only other bidders` FAILS at `tcfEnforcementHandler`: no registration. +- `exposes the getUserIdsAsEids canary …` and `sends no eids in the /auction body` PASS. They are positive controls and nets. Prove the second in Step 7. + +Record a representative failure for each test. + +- [ ] **Step 5: Make `AuctionEid` opaque.** + +In `src/core/auction.ts`, delete `AuctionUid` (`:23-28`) and replace `AuctionEid` (`:30-34`): + +```ts +/** + * An auction-level EID entry forwarded to the server. + * + * TSJS forwards the entries Prebid placed in the `trustedServer` bidder + * request as plain data and the server validates each one, so the server + * defines the contract. + */ +export type AuctionEid = unknown +``` + +- [ ] **Step 6: Rewire the adapter.** + +In `src/integrations/prebid/index.ts`: + +1. Imports: change `import type { AuctionBid, AuctionEid } from '../../core/auction';` to `import type { AuctionBid } from '../../core/auction';`, and add: + +```ts +import { + collectBidderEids, + fitEidCookie, + MAX_EID_COOKIE_BYTES, + type EidCollectionStats, +} from './eids' +``` + +2. `PbjsGlobal` (`:37-45`) no longer needs the `getUserIdsAsEids` extension: + +```ts +/** Prebid.js public API surface (type-only; erased at build time). */ +type PbjsGlobal = typeof _pbjsDefault +``` + +3. Delete `MAX_OPENRTB_ATYPE` and its two comment lines (`:151-153`), `PrebidUserIdEid` (`:1012-1015`), `sanitizeAuctionUid` (`:1076-1101`) and `collectAuctionEids` (`:1891-1914`). Keep `isDefined`, which other code uses. + +4. In the adapter registration, replace `buildRequests` (`:2357-2376`): + +```ts + buildRequests( + validBidRequests: TrustedServerBidRequest[], + bidderRequest?: unknown + ): TrustedServerRequest { + log.debug('[tsjs-prebid] buildRequests', { count: validBidRequests.length }); + const requestScopedBidRequests = [...validBidRequests]; + const auctionEids = forwardBidderEids(bidderRequest); + const payload = buildAdRequest(validBidRequests, { eids: auctionEids }); + return { + method: 'POST', + url: auctionEndpoint, + data: JSON.stringify(payload), + options: { contentType: 'application/json' }, + // Keep bid requests on the request object so interpretResponse can + // map bids without relying on shared mutable adapter state. + bidRequests: requestScopedBidRequests, + tsjsBidRequests: requestScopedBidRequests, + }; + }, +``` + +5. Right after the `registerBidAdapter(…)` call closes (`:2389`), register the TCF listener: + +```ts +// Prebid never calls buildRequests for a bidder its TCF rules block, so +// without this a ts-eids cookie written before consent changed would live +// for its full day. Prebid emits the event at AUCTION_END, before +// bidsBackHandler. +const prebidEvents = pbjs as unknown as { + onEvent?: (event: string, handler: (payload: unknown) => void) => void +} +if (typeof prebidEvents.onEvent === 'function') { + prebidEvents.onEvent('tcf2Enforcement', (payload) => { + if (tcfBlocksTrustedServer(payload)) { + clearPrebidEidsCookie() + log.debug( + '[tsjs-prebid] cleared ts-eids: Prebid TCF rules blocked trustedServer' + ) + } + }) +} +``` + +6. In the `requestBids` wrapper (`:2537-2541`), remove `syncPrebidEidsCookie();`. Replace the comment above the chained handler with `// Chain a bidsBackHandler to register this auction's publisher bids before the publisher's handler runs.` + +7. Replace the cookie section from the `Prebid EID cookie sync` banner (`:2900`) through `syncPrebidEidsCookie` (`:3009`) with: + +```ts +// --------------------------------------------------------------------------- +// Prebid EID forwarding and the ts-eids cookie +// --------------------------------------------------------------------------- + +/** Cookie name for persisted Prebid EIDs. */ +const EID_COOKIE_NAME = 'ts-eids' + +/** Cookie max-age in seconds (1 day). */ +const EID_COOKIE_MAX_AGE = 86400 + +/** What `buildRequests` did with `ts-eids`, for debug logs. */ +type EidCookieDecision = 'written' | 'trimmed' | 'cleared' + +/** Clears any previously persisted Prebid EIDs cookie. */ +function clearPrebidEidsCookie(): void { + document.cookie = `${EID_COOKIE_NAME}=; Path=/; Secure; SameSite=Lax; Max-Age=0` +} + +/** + * Logs which EID sources the cookie had to drop or trim UIDs from, if any. + * Debug level: `/auction` sends the untrimmed set in the request body, so a + * trimmed cookie is expected steady state rather than a warning. + */ +function logTrimmedEidSources( + droppedSources: string[], + trimmedSources: string[] +): void { + if (droppedSources.length === 0 && trimmedSources.length === 0) { + return + } + const parts: string[] = [] + if (droppedSources.length > 0) { + parts.push(`dropped sources: ${droppedSources.join(', ')}`) + } + if (trimmedSources.length > 0) { + parts.push(`trimmed uids from sources: ${trimmedSources.join(', ')}`) + } + log.debug( + `[tsjs-prebid] ts-eids cookie exceeded ${MAX_EID_COOKIE_BYTES} bytes; ${parts.join('; ')}` + ) +} + +/** + * Writes `ts-eids` from the EIDs of the `trustedServer` request being built: + * the same entries without EID-level `ext`, fitted to the cookie size cap. + * Clears the cookie when nothing fits or encoding fails, so it never holds a + * set that no longer matches the latest request. + */ +function writePrebidEidsCookie(eids: unknown[]): EidCookieDecision { + try { + const fitted = fitEidCookie(eids) + logTrimmedEidSources(fitted.droppedSources, fitted.trimmedSources) + if (fitted.value === undefined) { + clearPrebidEidsCookie() + return 'cleared' + } + document.cookie = `${EID_COOKIE_NAME}=${fitted.value}; Path=/; Secure; SameSite=Lax; Max-Age=${EID_COOKIE_MAX_AGE}` + return fitted.droppedSources.length > 0 || fitted.trimmedSources.length > 0 + ? 'trimmed' + : 'written' + } catch { + log.warn('[tsjs-prebid] failed to write the ts-eids cookie; clearing it') + clearPrebidEidsCookie() + return 'cleared' + } +} + +/** + * Collects the EIDs Prebid placed in this `trustedServer` bidder request and + * mirrors them into `ts-eids` synchronously, before Prebid sends the request, + * so the server never falls back to a stale cookie for it. + * + * `bidderRequest.ortb2` is what Prebid lets `trustedServer` see: global + * first-party data merged with its own bidder fragment, IDs restricted to + * other bidders excluded, activity controls applied. Nothing else is read: + * not `getUserIdsAsEids()`, not the `ortb2` config, not `auctionId`. + */ +function forwardBidderEids(bidderRequest: unknown): unknown[] | undefined { + if (bidderRequest === null || typeof bidderRequest !== 'object') { + log.debug( + '[tsjs-prebid] buildRequests got no bidder request; sending no EIDs and leaving ts-eids unchanged' + ) + return undefined + } + + const stats: EidCollectionStats = { + forwarded: 0, + unserializable: 0, + overBudget: 0, + } + const eids = collectBidderEids(bidderRequest, stats) + let cookie: EidCookieDecision + if (eids) { + cookie = writePrebidEidsCookie(eids) + } else { + clearPrebidEidsCookie() + cookie = 'cleared' + } + log.debug('[tsjs-prebid] forwarding bidder EIDs', { ...stats, cookie }) + return eids +} + +/** Whether a `tcf2Enforcement` payload says Prebid blocked `trustedServer` or its EIDs. */ +function tcfBlocksTrustedServer(payload: unknown): boolean { + if (payload === null || typeof payload !== 'object') { + return false + } + const { biddersBlocked, eidsBlocked } = payload as { + biddersBlocked?: unknown + eidsBlocked?: unknown + } + return [biddersBlocked, eidsBlocked].some( + (blocked) => Array.isArray(blocked) && blocked.includes(ADAPTER_CODE) + ) +} +``` + +Function declarations are hoisted, so `installPrebidNpm` may call these even though they appear later in the file, as it already does with `clearPrebidEidsCookie`. + +- [ ] **Step 7: Run the tests and prove the net.** + +Run: `cd crates/trusted-server-js/lib && npx vitest run test/integrations/prebid/index.test.ts test/integrations/prebid/eids.test.ts test/core/request.test.ts test/core/auction.test.ts` +Expected: PASS, including the canary `afterEach` and `afterAll`. + +Prove the `requestAds` net: temporarily change `src/core/request.ts:45` to `const payload = { ...buildAdRequest(adUnits, { eids: [{ source: 'net.example.com', uids: [{ id: 'net' }] }] }), config };` and run `npx vitest run test/core/request.test.ts -t "sends no eids"`. It must FAIL. Restore it, and record the result. + +- [ ] **Step 8: Full JS suite, including the artifact size bound.** + +Run: `cd crates/trusted-server-js/lib && npx vitest run && npm run format` +Expected: PASS. `test/prebid-artifact-integration.test.mjs` asserts the served shim stays under 41,000 characters (`:313`). This task removes about as much code as it adds. If that assertion fails, report the new size instead of raising the bound. The bound exists to catch a value-import of Prebid. + +- [ ] **Step 9: Commit.** + +```bash +git add crates/trusted-server-js/lib/src crates/trusted-server-js/lib/test docs/superpowers/implementation-notes/2026-10-18-prebid-eid-fidelity.md +git commit --signoff -S -m "Forward the trustedServer bidder request's EIDs and write ts-eids in buildRequests" -m "buildRequests reads bidderRequest.ortb2 instead of getUserIdsAsEids(), so IDs restricted to other bidders or redacted for trustedServer are no longer forwarded, and ortb2 EIDs such as Lockr's ID5 now arrive with their EID-level fields. The cookie copies each request's set before it is sent and is cleared when Prebid's TCF rules block trustedServer. bidsBackHandler no longer touches it." +``` + +--- + +### Task 8: Stop writing Prebid `userSync` settings + +Spec: D4, 4.9 ("Change" and "Effects"), 7.3 (last two bullets), A6. + +**Files:** + +- Modify: `crates/trusted-server-js/lib/src/integrations/prebid/index.ts` (`installUserIdModules` `:2874-2898`, self-init `:3011-3035`) +- Test: `crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts` (`describe('prebid self-init user ID module timing')` `:8049-8091`, and the `preserves publisher userSync …` test `:1318`) + +**Interfaces:** + +- Produces: `installUserIdModules` is removed from the module's exports. Before deleting it, run `git grep -n installUserIdModules crates docs` and confirm that only `index.ts`, its test and docs reference it. + +- [ ] **Step 1: Write the failing tests.** + +Replace `describe('prebid self-init user ID module timing', …)` (`:8049-8091`) with: + +```ts +describe('prebid self-init writes no Prebid userSync settings', () => { + const userSyncCalls = () => + mockSetConfig.mock.calls.filter( + ([arg]) => arg !== null && typeof arg === 'object' && 'userSync' in arg + ) + + const setReadyState = (value: DocumentReadyState) => { + Object.defineProperty(document, 'readyState', { value, configurable: true }) + } + + beforeEach(() => { + vi.resetModules() + mockSetConfig.mockClear() + }) + + afterEach(() => { + setReadyState('complete') + }) + + it('writes no userSync when the bundle loads after window load', async () => { + setReadyState('complete') + + await import('../../../src/integrations/prebid/index') + + expect( + mockSetConfig, + 'the shim should still apply its own config' + ).toHaveBeenCalledWith({ + debug: false, + }) + expect( + userSyncCalls(), + 'TS should leave userSync to the publisher and Prebid' + ).toEqual([]) + }) + + it('writes no userSync when window load fires after install', async () => { + setReadyState('loading') + + await import('../../../src/integrations/prebid/index') + window.dispatchEvent(new Event('load')) + + expect( + mockSetConfig, + 'the shim should still apply its own config' + ).toHaveBeenCalledWith({ + debug: false, + }) + expect( + userSyncCalls(), + 'TS should leave userSync to the publisher and Prebid' + ).toEqual([]) + }) +}) +``` + +In `preserves publisher userSync and autoRefresh=%s when seeding late IDs` (`:1318`), extend the effective config so the existing exact assertions also cover `syncDelay` and `filterSettings` (spec 7.3): + +```ts +const userSync = { + userIds: [], + auctionDelay: 300, + syncDelay: 50, + filterSettings: { image: { bidders: '*', filter: 'include' } }, + syncEnabled: false, + autoRefresh, +} +``` + +- [ ] **Step 2: Run them and see them fail.** + +Run: `cd crates/trusted-server-js/lib && npx vitest run test/integrations/prebid/index.test.ts -t "writes no userSync|preserves publisher userSync"` +Expected: both `writes no userSync …` tests FAIL with one `userSync` call (`auctionDelay: 0`, `syncDelay: 3000`, `filterSettings.all`). The extended `preserves publisher userSync …` PASSES. It is a net for the seeding path, proved in Step 4. Record this. + +- [ ] **Step 3: Delete `installUserIdModules` and its load hook.** + +In `src/integrations/prebid/index.ts`, delete `installUserIdModules` and its doc comment (`:2874-2898`). Replace the self-init block (`:3011-3035`) with: + +```ts +// Self-initialize when loaded in a browser (same pattern as other integrations). +if (typeof window !== 'undefined') { + installPrebidNpm() + // When the external bundle failed to load, installPrebidNpm bailed out and + // pbjs.requestBids is undefined. Installing the refresh handler anyway + // would clear TS-applied GPT targeting on every publisher refresh and then + // fail to run the replacement auction — leave GPT untouched instead. + if (hasPrebidJsApi()) { + installRefreshHandler() + } + // TS writes no userSync values beyond managed userIds (and autoRefresh on a + // late seed), so the publisher's or Prebid's auctionDelay applies from the + // first auction. +} +``` + +Leave the managed seeding's `autoRefresh: true` write (`:2248-2256`) unchanged (spec 4.9). + +- [ ] **Step 4: Run the tests and prove the net.** + +Run: `cd crates/trusted-server-js/lib && npx vitest run test/integrations/prebid/index.test.ts` +Expected: PASS. + +Prove the seeding net: temporarily change `...(isRecord(effectiveUserSync) ? effectiveUserSync : {}),` (`:2242`) to `...{},` and run `npx vitest run test/integrations/prebid/index.test.ts -t "preserves publisher userSync"`. It must FAIL. Restore it, and record the result. + +- [ ] **Step 5: Full JS suite, format, commit.** + +Run: `cd crates/trusted-server-js/lib && npx vitest run && npm run format` +Expected: PASS. + +```bash +git add crates/trusted-server-js/lib/src/integrations/prebid/index.ts crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts docs/superpowers/implementation-notes/2026-10-18-prebid-eid-fidelity.md +git commit --signoff -S -m "Stop overriding the publisher's Prebid userSync settings" -m "installUserIdModules forced auctionDelay 0, syncDelay 3000, syncsPerBidder 5 and iframe syncs for every bidder on window.load. Prebid accumulates userSync settings, so every later auction ran without waiting for pending IDs. TS now writes only managed userIds and, on a late seed, autoRefresh." +``` + +--- + +### Task 9: Prove D1, D3, D4 and the TCF behavior with real Prebid.js + +Spec: 7.5 (harness work and the four arms), 7.1 rules 1, 2, 5 and 12, O8, A1, A6, A7, A10. + +Unit tests can only check what a mocked `pbjs` receives. These arms evaluate the real generated bundle and the real shim in a JSDOM page, the way `test/prebid-artifact-integration.test.mjs` and `test/prebid-consent-enforcement.test.mjs` do, and capture `/auction` bodies. CI runs them with the rest of Vitest. + +They go in one new suite file. Spec 7.5 allows "extending one suite or sharing helpers", and a separate file leaves the two existing suites' tests untouched. The new file combines the consent suite's `__tcfapi` stub with the artifact suite's body capture. + +**Files:** + +- Create: `crates/trusted-server-js/lib/test/prebid-eid-fidelity.test.mjs` + +**Interfaces:** + +- Consumes: `main` from `build-prebid-external.mjs` and `moduleBuildOptions` from `build-module-options.mjs`, used as the artifact suite does at `test/prebid-artifact-integration.test.mjs:16-66`. + +The arms must fail against `main` (spec 7.1 rule 1). The shim is built from `src/` in `beforeAll`, so Step 3 proves red by building it from `main`'s sources. + +Harness facts checked while writing this plan (jsdom 28.1.0, Prebid 10.26.0): + +- A `` whose interceptor response is held keeps `document.readyState` at `loading` and delays `window.load` until it resolves. +- jsdom 28.1.0 exposes `TextEncoder` and `TextDecoder` on the page window, so the harness need not copy them. +- Prebid's `ajax` binds `window.fetch` when the bundle is evaluated (`src/ajax.ts:8-9`), so the fetch spy must exist before `eval(bundleCode)`. +- `tcf2Enforcement` carries `biddersBlocked` and `eidsBlocked` arrays (`modules/tcfControl.ts:319-336`). `pbjs.getEvents()` returns `{ eventType, args }` records. +- unifiedId accepts `params.url`. It reads `{ "TDID": … }` and emits `adserver.org` with `inserter`/`matcher` `adserver.org`, `mm: 4`, `atype: 1` and UID `ext.rtiPartner: "TDID"` (`modules/unifiedIdSystem.js:36-90`, `libraries/uid1Eids/uid1Eids.js`). +- sharedId emits `pubcid.org` with `atype: 1`. identityLink emits `liveramp.com` with `atype: 3` and has GVL ID 97. + +- [ ] **Step 1: Write the suite.** + +`test/prebid-eid-fidelity.test.mjs`: + +```js +// @vitest-environment node + +// Real Prebid.js arms for the trustedServer EID contract (spec 7.5). Unit +// tests mock window.pbjs; this suite evaluates the generated external bundle +// and the served shim together in a JSDOM page and captures /auction bodies. +// Every arm except the D4 control fails against a shim built from main. + +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +import { JSDOM, requestInterceptor } from 'jsdom' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' + +import { moduleBuildOptions } from '../build-module-options.mjs' +import { main } from '../build-prebid-external.mjs' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const libDir = path.resolve(__dirname, '..') + +const PAGE_ORIGIN = 'https://pub.example.com' +const HOLD_LOAD_PATH = '/hold-window-load.css' +const UNIFIED_ID_PATH = '/unified-id' +const UNIFIED_ID_DELAY_MS = 200 +const IDENTITY_LINK_GVL_ID = 97 + +const CONTROL_EID = { + source: 'control.example.com', + uids: [{ id: 'control-uid', atype: 1 }], +} +const PRESET_COOKIE_EIDS = [ + { source: 'preset.example.com', uids: [{ id: 'preset-uid' }] }, +] +const LIVERAMP_EID = { + source: 'liveramp.com', + uids: [{ id: 'fictional-idl-envelope', atype: 3 }], +} +const UNIFIED_ID_EID = { + source: 'adserver.org', + inserter: 'adserver.org', + matcher: 'adserver.org', + mm: 4, + uids: [{ id: 'fictional-tdid', atype: 1, ext: { rtiPartner: 'TDID' } }], +} +// The only safe form of a trustedServer TCF exception (spec 3.7). +const FULL_BASIC_ADS_RULE = { + purpose: 'basicAds', + enforcePurpose: true, + enforceVendor: true, + softVendorExceptions: ['trustedServer'], +} + +let outputDirectory +let bundleCode +let shimCode + +beforeAll(async () => { + outputDirectory = fs.mkdtempSync( + path.join(os.tmpdir(), 'trusted-server-prebid-eids-') + ) + await main([ + '--modules-json', + JSON.stringify({ + bidder: ['appnexusBidAdapter'], + userId: ['sharedIdSystem', 'unifiedIdSystem', 'identityLinkIdSystem'], + }), + '--out', + outputDirectory, + ]) + const manifest = JSON.parse( + fs.readFileSync(path.join(outputDirectory, 'manifest.json'), 'utf8') + ) + bundleCode = fs.readFileSync( + path.join(outputDirectory, manifest.filename), + 'utf8' + ) + + const { build } = await import('vite') + await build( + moduleBuildOptions({ + name: 'prebid', + entryPath: path.join(libDir, 'src', 'integrations', 'prebid', 'index.ts'), + outDir: outputDirectory, + }) + ) + shimCode = fs.readFileSync( + path.join(outputDirectory, 'tsjs-prebid.js'), + 'utf8' + ) +}, 240_000) + +afterAll(() => { + fs.rmSync(outputDirectory, { recursive: true, force: true }) +}) + +function tcfData(vendorConsents) { + return { + gdprApplies: true, + tcString: 'CPexampleTCStringForTests', + eventStatus: 'tcloaded', + cmpStatus: 'loaded', + apiVersion: '2', + purpose: { + consents: { 1: true, 2: true, 3: true, 4: true }, + legitimateInterests: {}, + }, + vendor: { consents: vendorConsents, legitimateInterests: {} }, + publisher: { restrictions: {} }, + specialFeatureOptins: {}, + } +} + +/** A CMP that answers at once: Purposes 1 to 4 consented, the given vendors only. */ +function tcfApi(vendorConsents) { + let nextListenerId = 1 + return (command, _version, callback) => { + if (command === 'addEventListener') { + callback( + { ...tcfData(vendorConsents), listenerId: nextListenerId++ }, + true + ) + } else if (command === 'getTCData') { + callback(tcfData(vendorConsents), true) + } else if (command === 'removeEventListener') { + callback(true, true) + } + } +} + +/** + * A publisher page whose window.load is held by a stylesheet until + * releaseLoad(). Every fetch goes to a spy, installed before the bundle is + * evaluated, that records /auction bodies and answers the unifiedId endpoint + * after UNIFIED_ID_DELAY_MS. + */ +function createPage({ cookies = [], vendorConsents } = {}) { + let releaseHeldLoad + const loadHeld = new Promise((resolve) => { + releaseHeldLoad = resolve + }) + const dom = new JSDOM( + ``, + { + url: `${PAGE_ORIGIN}/article`, + runScripts: 'outside-only', + pretendToBeVisual: true, + resources: { + interceptors: [ + requestInterceptor(async (request) => { + if (new URL(request.url).pathname === HOLD_LOAD_PATH) { + await loadHeld + return new Response('', { + status: 200, + headers: { 'Content-Type': 'text/css' }, + }) + } + return new Response('', { status: 204 }) + }), + ], + }, + } + ) + const pageWindow = dom.window + + // jsdom drops Secure cookies on non-HTTPS origins (spec 7.1 rule 5). + pageWindow.document.cookie = 'ts-eids-probe=1; Path=/; Secure; SameSite=Lax' + expect( + pageWindow.document.cookie, + 'Secure cookies should work on the page origin' + ).toContain('ts-eids-probe=1') + pageWindow.document.cookie = 'ts-eids-probe=; Path=/; Max-Age=0' + for (const cookie of cookies) { + pageWindow.document.cookie = cookie + } + + const auctionBodies = [] + pageWindow.fetch = vi.fn(async (resource, init) => { + const url = new URL( + typeof resource === 'string' ? resource : resource.url, + PAGE_ORIGIN + ) + if (url.pathname === '/auction') { + const body = + init?.body ?? + (typeof resource === 'object' + ? await resource.clone().text() + : undefined) + auctionBodies.push(JSON.parse(body)) + } + if (url.pathname === UNIFIED_ID_PATH) { + await new Promise((resolve) => setTimeout(resolve, UNIFIED_ID_DELAY_MS)) + return new Response(JSON.stringify({ TDID: 'fictional-tdid' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + } + return new Response('{}', { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + }) + pageWindow.Request = class PageRequest extends Request { + constructor(resource, init) { + super( + typeof resource === 'string' + ? new URL(resource, PAGE_ORIGIN).href + : resource, + init + ) + } + } + pageWindow.Headers = Headers + pageWindow.Response = Response + pageWindow.AbortController = AbortController + if (!('isSecureContext' in pageWindow)) { + pageWindow.isSecureContext = true + } + if (vendorConsents) { + pageWindow.__tcfapi = tcfApi(vendorConsents) + } + + // Mirror the server's head-injected state. + pageWindow.eval('window.pbjs = { que: [], cmd: [] };') + pageWindow.__tsjs_prebid = { clientSideBidders: [], serverSideBidders: [] } + + return { + pageWindow, + auctionBodies, + install() { + pageWindow.eval(bundleCode) + pageWindow.eval(shimCode) + }, + async releaseLoad() { + releaseHeldLoad() + await vi.waitFor(() => + expect(pageWindow.document.readyState).toBe('complete') + ) + }, + async close() { + releaseHeldLoad() + // Let in-flight User ID work settle before jsdom tears down. + await new Promise((resolve) => + setTimeout(resolve, UNIFIED_ID_DELAY_MS + 100) + ) + pageWindow.close() + }, + } +} + +function readTsEidsCookie(pageWindow) { + const value = pageWindow.document.cookie.match(/(?:^|; )ts-eids=([^;]*)/)?.[1] + if (!value) { + return undefined + } + return JSON.parse( + new TextDecoder().decode( + Uint8Array.from(atob(value), (char) => char.charCodeAt(0)) + ) + ) +} + +function presetTsEidsCookie() { + return `ts-eids=${btoa(JSON.stringify(PRESET_COOKIE_EIDS))}; Path=/; Secure; SameSite=Lax` +} + +function requestBids(pageWindow, adUnits) { + return new Promise((resolve) => { + pageWindow.pbjs.requestBids({ + adUnits, + timeout: 1000, + bidsBackHandler: () => resolve(), + }) + }) +} + +/** An ad unit with no publisher bidders: the shim adds only trustedServer. */ +function trustedServerAdUnits() { + return [ + { + code: 'ad-slot-1', + mediaTypes: { banner: { sizes: [[300, 250]] } }, + bids: [], + }, + ] +} + +/** An ad unit with a client-side bidder that has a GVL ID (appnexus, 32). */ +function clientBidderAdUnits() { + return [ + { + code: 'ad-slot-1', + mediaTypes: { banner: { sizes: [[300, 250]] } }, + bids: [{ bidder: 'appnexus', params: { placementId: 1 } }], + }, + ] +} + +function tcfEnforcementResults(pageWindow) { + return pageWindow.pbjs + .getEvents() + .filter((event) => event.eventType === 'tcf2Enforcement') + .map((event) => event.args) +} + +describe('trustedServer EIDs with real Prebid.js', () => { + it('D1: never forwards an ID restricted to another bidder', async () => { + const page = createPage({ + cookies: ['_sharedid=fictional-shared-id; Path=/'], + }) + try { + page.install() + page.pageWindow.pbjs.setConfig({ + userSync: { + auctionDelay: 300, + userIds: [ + { + name: 'sharedId', + bidders: ['appnexus'], + storage: { type: 'cookie', name: '_sharedid', expires: 30 }, + }, + ], + }, + ortb2: { user: { ext: { eids: [CONTROL_EID] } } }, + }) + + await requestBids(page.pageWindow, trustedServerAdUnits()) + + expect( + page.pageWindow.pbjs.getUserIdsAsEids().map((eid) => eid.source), + 'precondition: the restricted module produced its ID' + ).toContain('pubcid.org') + expect( + page.auctionBodies.map((body) => body.eids), + 'the /auction body should carry the control and not the restricted ID' + ).toEqual([[CONTROL_EID]]) + expect( + readTsEidsCookie(page.pageWindow), + 'ts-eids should copy the body set' + ).toEqual([CONTROL_EID]) + } finally { + await page.close() + } + }, 60_000) + + it('D3: forwards same-source entries separately with their own EID-level fields', async () => { + const first = { + source: 'ids.example.com', + inserter: 'inserter-a.example.com', + mm: 2, + ext: { segments: ['s1'] }, + uids: [{ id: 'ids-uid', atype: 1 }], + } + const second = { + source: 'ids.example.com', + inserter: 'inserter-b.example.com', + matcher: 'matcher-b.example.com', + mm: 3, + uids: [{ id: 'ids-uid', atype: 1 }], + } + const page = createPage() + try { + page.install() + page.pageWindow.pbjs.setConfig({ + ortb2: { user: { ext: { eids: [first, second] } } }, + }) + + await requestBids(page.pageWindow, trustedServerAdUnits()) + + expect(page.auctionBodies.map((body) => body.eids)).toEqual([ + [first, second], + ]) + const { ext: _eidExt, ...firstWithoutExt } = first + expect( + readTsEidsCookie(page.pageWindow), + 'ts-eids should hold both entries without EID-level ext' + ).toEqual([firstWithoutExt, second]) + } finally { + await page.close() + } + }, 60_000) + + it('D4: the first auction after window.load waits for a pending User ID module', async () => { + const page = createPage() + try { + page.install() + expect( + page.pageWindow.document.readyState, + 'step 1 starts before load' + ).toBe('loading') + await page.releaseLoad() + + page.pageWindow.pbjs.setConfig({ + userSync: { + userIds: [ + { + name: 'unifiedId', + params: { url: `${PAGE_ORIGIN}${UNIFIED_ID_PATH}` }, + }, + ], + }, + }) + expect( + page.pageWindow.document.readyState, + 'step 2 runs after load' + ).toBe('complete') + expect( + page.pageWindow.pbjs.getConfig('userSync').auctionDelay, + "Prebid's default auctionDelay should apply" + ).toBe(500) + + await requestBids(page.pageWindow, trustedServerAdUnits()) + + expect( + page.auctionBodies.map((body) => body.eids), + 'the first /auction should carry the delayed unifiedId' + ).toEqual([[UNIFIED_ID_EID]]) + } finally { + await page.close() + } + }, 60_000) + + it('D4 control: the first auction before window.load waits, on every build', async () => { + const page = createPage() + try { + page.install() + page.pageWindow.pbjs.setConfig({ + userSync: { + userIds: [ + { + name: 'unifiedId', + params: { url: `${PAGE_ORIGIN}${UNIFIED_ID_PATH}` }, + }, + ], + }, + }) + expect( + page.pageWindow.document.readyState, + 'the auction runs before load' + ).toBe('loading') + + await requestBids(page.pageWindow, trustedServerAdUnits()) + + // A projection, not the full entry: main drops EID-level fields, and + // this control must pass on main and on the branch alike. + expect( + page.auctionBodies.map((body) => + (body.eids ?? []).map((eid) => [ + eid.source, + eid.uids.map((uid) => uid.id), + ]) + ), + 'the spy and the module should deliver unifiedId to the first auction' + ).toEqual([[['adserver.org', ['fictional-tdid']]]]) + } finally { + await page.close() + } + }, 60_000) + + async function runTcfPage({ withRule }) { + const page = createPage({ + cookies: ['idl_env=fictional-idl-envelope; Path=/', presetTsEidsCookie()], + vendorConsents: { [IDENTITY_LINK_GVL_ID]: true }, + }) + page.install() + expect( + readTsEidsCookie(page.pageWindow), + 'precondition: ts-eids is preset' + ).toEqual(PRESET_COOKIE_EIDS) + page.pageWindow.pbjs.setConfig({ + consentManagement: { + gdpr: { + cmpApi: 'iab', + timeout: 500, + ...(withRule ? { rules: [FULL_BASIC_ADS_RULE] } : {}), + }, + }, + userSync: { + auctionDelay: 300, + userIds: [ + { + name: 'identityLink', + params: { pid: '999', notUse3P: true }, + storage: { type: 'cookie', name: 'idl_env', expires: 15 }, + }, + ], + }, + ortb2: { user: { ext: { eids: [CONTROL_EID] } } }, + }) + await requestBids(page.pageWindow, clientBidderAdUnits()) + return page + } + + it('TCF: without an exception no client EIDs reach TS and ts-eids is cleared', async () => { + const page = await runTcfPage({ withRule: false }) + try { + expect( + tcfEnforcementResults(page.pageWindow).some((result) => + result.biddersBlocked.includes('trustedServer') + ), + 'positive control: Prebid blocked trustedServer' + ).toBe(true) + expect(page.auctionBodies, 'no /auction request should be sent').toEqual( + [] + ) + expect( + readTsEidsCookie(page.pageWindow), + 'ts-eids should be cleared' + ).toBeUndefined() + } finally { + await page.close() + } + }, 60_000) + + it('TCF: with the full basicAds rule TS gets its EIDs and other bidders stay blocked', async () => { + const page = await runTcfPage({ withRule: true }) + try { + // Prebid appends module EIDs after ortb2 entries (spec 3.2 step 3). + expect(page.auctionBodies.map((body) => body.eids)).toEqual([ + [CONTROL_EID, LIVERAMP_EID], + ]) + const results = tcfEnforcementResults(page.pageWindow) + expect( + results.some((result) => result.biddersBlocked.includes('appnexus')), + 'the rule must not switch off enforcement for other bidders' + ).toBe(true) + expect( + results.some((result) => + result.biddersBlocked.includes('trustedServer') + ), + 'trustedServer should not be blocked with the rule' + ).toBe(false) + } finally { + await page.close() + } + }, 60_000) +}) +``` + +- [ ] **Step 2: Run it on the branch.** + +Run: `cd crates/trusted-server-js/lib && npx vitest run test/prebid-eid-fidelity.test.mjs` +Expected: PASS. + +If an arm fails, find out why in `node_modules/prebid.js` before changing anything. Do not relax an assertion to make it pass. Some failures need reporting rather than fixing, for example a different EID order from Prebid, sharedId not producing an ID from the preset cookie, or identityLink needing more storage config to read `idl_env`. Report those in the red log and to the user with the Prebid source line, and change the test only if the spec's expectation still holds. + +- [ ] **Step 3: Prove every arm fails against `main`, except the D4 control.** + +Build the shim from `main`'s sources for one run, then restore: + +```bash +BASE=$(git merge-base HEAD main) +git show "$BASE":crates/trusted-server-js/lib/src/integrations/prebid/index.ts > crates/trusted-server-js/lib/src/integrations/prebid/index.ts +git show "$BASE":crates/trusted-server-js/lib/src/core/auction.ts > crates/trusted-server-js/lib/src/core/auction.ts +(cd crates/trusted-server-js/lib && npx vitest run test/prebid-eid-fidelity.test.mjs) +git checkout -- crates/trusted-server-js/lib/src/integrations/prebid/index.ts crates/trusted-server-js/lib/src/core/auction.ts +git status --short crates/trusted-server-js/lib/src +``` + +Expected against `main`: + +- D1 FAILS: the body carries `pubcid.org` from `getUserIdsAsEids()`, not the control. +- D3 FAILS: no `eids`, because `main` never reads `ortb2`. +- D4 FAILS at the `auctionDelay` assertion: `0`, not `500`. +- The D4 control PASSES. +- TCF without an exception FAILS: `main`'s `bidsBackHandler` rewrites `ts-eids` with `liveramp.com`. +- TCF with the rule FAILS: the body carries `liveramp.com` only. + +The final `git status` must show `src/` clean. Record each result. If an arm passes against `main`, it does not discriminate. Stop and report instead of committing it (spec 7.1 rule 1). + +- [ ] **Step 4: Commit.** + +```bash +git add crates/trusted-server-js/lib/test/prebid-eid-fidelity.test.mjs docs/superpowers/implementation-notes/2026-10-18-prebid-eid-fidelity.md +git commit --signoff -S -m "Prove the trustedServer EID contract with real Prebid.js" -m "A JSDOM suite evaluates the generated bundle and the served shim and captures /auction bodies. It covers a bidder-restricted ID, same-source entries with their own fields, the first auction after window.load, and TCF with and without the full trustedServer exception. Every arm except the timing control fails against main." +``` + +--- + +### Task 10: Delete the dead EID-to-module resolver + +Spec: D6, O6, 7.7 (CLI tests). + +**Files:** + +- Modify: `crates/trusted-server-js/lib/src/integrations/prebid/user_id_modules.json` (drop every `eidSources` key) +- Modify: `crates/trusted-server-js/lib/src/integrations/prebid/user_id_modules.ts` (`:1-60`, `:104-145`) +- Test: `crates/trusted-server-js/lib/test/integrations/prebid/user_id_modules.test.ts` + +**Interfaces:** + +- Removes: `resolvePrebidUserIdModulesFromEids`, `PrebidUserIdEidLike`, `PrebidUserIdModuleResolution`, and `PrebidUserIdModuleRegistryEntry.eidSources`. Run `git grep -n -E "resolvePrebidUserIdModulesFromEids|eidSources|PrebidUserIdEidLike" crates` first. Only these three files may match. The CLI's `PrebidUserIdModuleRegistryEntry` (`crates/trusted-server-cli/src/prebid_bundle.rs:211-215`) reads only `moduleName` and `configNames`, without `deny_unknown_fields`, so the JSON change does not affect it. + +- [ ] **Step 1: Write the failing test.** + +In `user_id_modules.test.ts`, add: + +```ts +it('carries no EID source metadata', () => { + expect( + registry.modules.length, + 'the registry should still list modules' + ).toBeGreaterThan(0) + expect( + registry.modules + .filter((entry) => 'eidSources' in entry) + .map((entry) => entry.moduleName), + 'no entry should keep eidSources' + ).toEqual([]) +}) +``` + +- [ ] **Step 2: Run it and see it fail.** + +Run: `cd crates/trusted-server-js/lib && npx vitest run test/integrations/prebid/user_id_modules.test.ts -t "no EID source metadata"` +Expected: FAIL, listing every module. Record it. + +- [ ] **Step 3: Delete the resolver and the data.** + +1. In `user_id_modules.json`, delete the `"eidSources": […]` line from every module entry. Keep `moduleName`, `configNames`, any `notes`, and `defaultPreset`. +2. In `user_id_modules.ts`, delete `eidSources` from `PrebidUserIdModuleRegistryEntry`. Also delete `PrebidUserIdEidLike`, `PrebidUserIdModuleResolution`, `DEFAULT_PREBID_USER_ID_MODULES`, `MODULE_SORT_ORDER`, `stableModuleSort`, `normalizeSource`, `hasLiveIntentProvider` and `resolvePrebidUserIdModulesFromEids`. Keep `PREBID_USER_ID_MODULE_REGISTRY`, `knownUserIdConfigNames`, `findUserIdModuleEntry`, `userIdConfigNameAliases` and `userIdSubmoduleKey`. Delete a helper only after `git grep` shows no other use. +3. In `user_id_modules.test.ts`, delete the `resolvePrebidUserIdModulesFromEids` import, the `sampleEids` fixture and the five resolver tests: `resolves the target-site EID sample …`, `maps the Google PAIR EID source …`, `maps LiveRamp EIDs …`, `maps unknown LiveIntent provider-backed sources …` and `reports unknown non-LiveIntent sources`. Keep the rest. + +- [ ] **Step 4: Run the JS and CLI tests.** + +Run: + +```bash +(cd crates/trusted-server-js/lib && npx vitest run test/integrations/prebid/user_id_modules.test.ts test/prebid-user-id-registry.test.mjs test/build-prebid-external.test.mjs && npm run format) +./scripts/test-cli.sh +``` + +Expected: PASS. + +- [ ] **Step 5: Commit.** + +```bash +git add crates/trusted-server-js/lib/src/integrations/prebid/user_id_modules.json crates/trusted-server-js/lib/src/integrations/prebid/user_id_modules.ts crates/trusted-server-js/lib/test/integrations/prebid/user_id_modules.test.ts docs/superpowers/implementation-notes/2026-10-18-prebid-eid-fidelity.md +git commit --signoff -S -m "Delete the unused EID-to-module resolver" -m "Only tests read resolvePrebidUserIdModulesFromEids and the registry's eidSources. The data could not be made correct, since several modules share sources. The CLI and bundle builder read only module and config names." +``` + +--- + +### Task 11: Documentation + +Spec: 8.6, 3.7 (the full rule and its warning), 8.2. + +**Files:** + +- Modify: `docs/guide/integrations/prebid.md`, `docs/guide/integrations/lockr.md`, `docs/guide/edge-cookies.md`, `docs/guide/ec-setup-guide.md`, `docs/guide/api-reference.md`, `docs/guide/configuration.md` + +Use only example domains. Replace each passage named below with the text given. + +- [ ] **Step 1: `docs/guide/integrations/prebid.md`.** + +(a) Under `## User ID Modules`, replace the first paragraph (`:662-664`, "Prebid.js can expose … when they are available.") with: + +```markdown +Prebid.js places the EIDs each bidder may receive in that bidder's +`bidderRequest.ortb2`: User ID module output, `setConfig({ortb2})` and +`setBidderConfig` data, and RTD output, with IDs restricted to other bidders +left out and Prebid's activity controls applied. The `trustedServer` adapter +forwards exactly the `user.ext.eids` and `user.eids` entries in its own bidder +request, with their `inserter`, `matcher`, `mm` and `ext`. It never reads +`pbjs.getUserIdsAsEids()`, which also returns IDs restricted to other bidders. + +### Timing and `userSync` + +Trusted Server writes no Prebid `userSync` settings other than the managed +`userIds` entries below (and `autoRefresh` when a late CMP answer seeds them). +The publisher's `auctionDelay`, `syncDelay`, `syncsPerBidder` and +`filterSettings`, or Prebid's defaults, apply from the first auction. Prebid's +default `auctionDelay` is 500 ms: an auction waits up to that long while User +ID modules are still resolving, and not at all once they have settled. Set +`auctionDelay` and `filterSettings` in your own Prebid configuration if the +defaults do not suit you. Prebid's default allows image user syncs only. +Vendors that write `ortb2` after their own network call, such as the Lockr +SDK, are not awaited; their IDs reach later requests. +``` + +(b) In `### A settled CMP result, not a callable API`, replace "with `userSync.autoRefresh` briefly enabled so Prebid initializes modules added after its first pass." (`:342-343`) with "with `userSync.autoRefresh` enabled so Prebid initializes modules added after its first pass. Because Prebid accumulates `userSync` settings, `autoRefresh` then stays enabled for the rest of the page." + +(c) In `### Resolution timing and data flow` (`:842-861`), replace steps 1 to 5 with: + +```markdown +1. Prebid places the `liveramp.com` entry in the `trustedServer` bidder + request's `ortb2.user.ext.eids`. +2. The adapter forwards that entry in the `/auction` body and, before sending + the request, copies the body's EIDs (without EID-level `ext`) into the + bounded `ts-eids` cookie. +3. Trusted Server consent-gates it and forwards it to Prebid Server as + `user.ext.eids`. +4. After the response, the same `/auction` request ingests the body entry into + an EC/KV partner configured with `source_domain = "liveramp.com"`, when that + partner UID is not already stored. +5. Requests without an EID body, such as `GET /_ts/page-bids` and page + navigations, read and ingest the `ts-eids` cookie. + +Where GDPR applies and Prebid's TCF enforcement is active, Prebid sends the +`trustedServer` adapter no bid requests unless the publisher grants it an +exception (see [TCF and the `trustedServer` adapter](#tcf-and-the-trustedserver-adapter)), +and the adapter clears `ts-eids`, so no browser EIDs reach Trusted Server. +``` + +(d) Replace the `## Identity Forwarding` section from its first line (`:937`) through the end of `### What reaches Prebid Server` (`:991`) with: + +````markdown +## Identity Forwarding + +Trusted Server combines browser EIDs with the EC/KV identity graph: + +1. **Browser EIDs** are the entries Prebid placed in the `trustedServer` + bidder request. The adapter sends them in the `/auction` body as plain data + and copies them, without EID-level `ext`, into the `ts-eids` cookie before + the request is sent. +2. **Server-side EIDs** are resolved from the EC/KV identity graph for + partners with `bidstream_enabled = true`. +3. Browser entries are forwarded first, exactly as sent. KV adds entries only + for registered sources the browser did not send. +4. Consent gating applies to the merged set, which goes out as `user.ext.eids` + and is echoed in the `x-ts-eids` response header. +5. After the response, `/auction` adds matched partner UIDs that KV does not + yet hold from its own client set (the body, or the cookie when the body has + none), then from the `sharedId` cookie. Other routes read the `ts-eids` + cookie instead. + +The server validates every browser entry on its own, whether it came in the +body or the cookie. A malformed entry or field is dropped and the rest of the +request proceeds. Identity-graph writes follow the same consent rule as +bidstream forwarding: under GDPR, TCF Purpose 1 and Purpose 4 must both be +consented. + +### Identity flow + +```mermaid +sequenceDiagram + participant B as Browser / Prebid.js + participant T as Trusted Server /auction + participant K as EC + KV identity graph + participant P as Prebid Server + + B->>B: Prebid builds the trustedServer bidder request (ortb2 EIDs) + B->>B: Copy its EIDs into ts-eids (no EID-level ext) + B->>T: POST /auction\n(adUnits + the bidder request's eids) + T->>T: Validate each entry + T->>K: Resolve EC-backed source-domain IDs + K-->>T: KV-derived EIDs + T->>T: Browser entries as sent, then KV-only sources + T->>T: Apply consent gating + T->>P: OpenRTB request\nuser.ext.eids = merged set + P-->>T: OpenRTB bid response + T-->>B: Auction response + x-ts-eids + T->>K: Ingest this request's client EIDs + sharedId cookie\n(TCF Purpose 1 + 4 under GDPR) +``` + +### Merge rules + +- Browser entries are forwarded in the order Prebid sent them and are never + altered or combined. A source can appear in more than one entry, for example + `adserver.org` from two modules with different `matcher` values. +- KV adds one entry for each registered source no browser entry carries. + Sources are compared case-insensitively, ignoring a trailing dot. +- For a source the browser sends, KV's stored UID and its configured + `openrtb_atype` are not used. +- Consent gating is applied to the merged set before forwarding. + +### What reaches Prebid Server + +The downstream Prebid Server request includes: + +- `user.id` when EC forwarding is allowed +- `user.ext.eids` with the browser entries and their `inserter`, `matcher`, + `mm` and `ext`, followed by KV-only sources +- forwarded browser cookies (subject to consent-forwarding mode) + +Before rolling this out, send one request with a repeated source and all four +EID-level fields to each configured provider in staging and confirm it is +accepted. + +### TCF and the `trustedServer` adapter + +The `trustedServer` adapter has no GVL ID. Where GDPR applies and Prebid's TCF +enforcement is active (the publisher configures `consentManagement.gdpr`, or +Trusted Server enables it for managed User IDs), Prebid's default rules remove +`trustedServer` from the auction and withhold its EIDs. Trusted Server then +receives no browser EIDs from the adapter, and the adapter clears `ts-eids` +when Prebid reports the block. + +Granting `trustedServer` an exception is the publisher's decision. Use only +the full rule: + +```js +pbjs.setConfig({ + consentManagement: { + gdpr: { + // ...the publisher's existing CMP settings... + rules: [ + { + purpose: 'basicAds', + enforcePurpose: true, + enforceVendor: true, + softVendorExceptions: ['trustedServer'], + }, + ], + }, + }, +}) +``` + +::: warning +A publisher rule replaces Prebid's default rule for that purpose. A rule +written as `{ purpose: 'basicAds', softVendorExceptions: ['trustedServer'] }`, +without `enforcePurpose: true` and `enforceVendor: true`, switches off both +checks for **every** bidder. Do not use `vendorExceptions`, which also skips +the purpose check. +::: + +With the full rule, `trustedServer` still needs Purpose 2 consent and skips +only the vendor check. A publisher that sets `eidsRequireP4Consent` must also +add the same full rule for `personalizedAds`; without it `trustedServer` gets +bid requests with no EIDs and `ts-eids` is cleared after every auction. +```` + +(e) Under `### OpenRTB request construction`, replace the bullet "Merges current-request browser EIDs with KV-resolved EIDs and forwards the deduplicated result as `user.ext.eids`" (`:1056`) with "Forwards the browser EIDs as sent, followed by KV-resolved EIDs for registered sources the browser did not send, as `user.ext.eids`". + +- [ ] **Step 2: `docs/guide/integrations/lockr.md`.** + +Replace the `### Extended ID (EID) Support` section (`:89-111`) with: + +````markdown +### Extended ID (EID) Support + +The Lockr SDK writes its ID5 EID into Prebid's first-party data with +`pbjs.setConfig({ ortb2: { user: { ext: { eids } } } })`. Prebid includes it in +the `trustedServer` bidder request, and the adapter forwards it to `/auction` +with its EID-level fields, for example: + +```json +{ + "source": "id5-sync.com", + "inserter": "inserter.example.com", + "mm": 2, + "uids": [{ "id": "fictional-id5-id", "atype": 1 }] +} +``` + +The SDK writes the EID after its own network call, so the first auction on a +page may run before it is available. Later auctions include it, and requests +without an EID body get it from the `ts-eids` cookie and KV. + +To store the ID in the EC identity graph, register the source: + +```toml +[[ec.partners]] +name = "ID5 via Lockr" +source_domain = "id5-sync.com" +bidstream_enabled = true +``` + +Where GDPR applies and Prebid's TCF enforcement is active, Prebid sends the +`trustedServer` adapter no EIDs unless the publisher grants the exception +described in +[TCF and the `trustedServer` adapter](./prebid.md#tcf-and-the-trustedserver-adapter). +```` + +Replace the three solution bullets under `### Missing EID in Bid Requests` (`:227-229`) with: + +```markdown +- Confirm `pbjs.getEvents()` shows a `bidRequested` event for `trustedServer` + whose `ortb2.user.ext.eids` contains the `id5-sync.com` entry +- Under GDPR, confirm the publisher's Prebid config grants `trustedServer` the + full TCF exception +- Decode the `/auction` response's `x-ts-eids` header to see what was forwarded +``` + +- [ ] **Step 3: `docs/guide/edge-cookies.md`.** + +(a) In `### Prebid EID Flow`, replace the first paragraph and numbered list (`:151-157`, from "Browser-resolved Prebid EIDs reach the identity graph…" through the paragraph ending "…such as `GET /_ts/page-bids` and page navigations.") with: + +```markdown +Browser EIDs reach the identity graph when Trusted Server finalizes a +response. Which browser input it reads depends on the route: + +- `POST /auction` reads only the client EIDs the auction itself used: its + request body, or the `ts-eids` cookie when the body has none. An earlier + page's cookie is never ingested next to a body. +- Publisher navigations, admitted `GET /_ts/page-bids` requests and newly + generated ECs read the `ts-eids` cookie. +- Every route then applies the `sharedId` cookie last. + +The TSJS Prebid adapter writes `ts-eids` in `buildRequests`, before each +`trustedServer` request is sent: the request's EIDs without EID-level `ext`, +trimmed to 3,072 bytes, as base64 of UTF-8 JSON. It clears the cookie when that +set is empty or when Prebid's TCF rules block `trustedServer`. Auctions that do +not include `trustedServer` leave it unchanged. +``` + +(b) In the sequence diagram that follows (`:160-180`), replace the four lines from `Prebid->>Prebid: Auction completes` through `TSJS->>B: document.cookie = "ts-eids=..."` with: + +```text + Prebid->>TSJS: buildRequests(bidRequests, bidderRequest) + TSJS->>TSJS: Copy bidderRequest.ortb2 EIDs
drop EID-level ext, fit 3,072 bytes + TSJS->>B: document.cookie = "ts-eids=..." (before the request is sent) +``` + +(c) Replace the paragraph "Current TSJS writers preserve the full OpenRTB-style …" (`:182`) with: "`ts-eids` holds OpenRTB-style `{source, uids:[...]}` entries, with `inserter`, `matcher` and `mm` when Prebid set them. The server validates each entry with the same rules as the `/auction` body and drops only what is malformed. Earlier flattened `{source, id, atype}` cookies are still accepted." + +(d) In the `### EID Seeding and Prebid Bidstream Forwarding` diagram (`:205-228`), replace `B->>TSJS: getUserIdsAsEids()` with `B->>TSJS: buildRequests(bidRequests, bidderRequest)`, `B->>B: getUserIdsAsEids() for current auction` with `B->>B: Prebid builds the trustedServer bidder request`, `TS->>TS: Convert stored UIDs to EIDs
Merge + dedupe with request eids[]
Apply consent gating` with `TS->>TS: Request eids[] as sent
+ KV EIDs for sources not sent
Apply consent gating`, and `TS->>KV: Add missing partner UIDs from request eids[]
+ ts-eids/sharedId cookies (TCF Purpose 1 + 4 under GDPR)` with `TS->>KV: Add missing partner UIDs from request eids[]
+ sharedId cookie (TCF Purpose 1 + 4 under GDPR)`. + +(e) Replace the sentence "Server-resolved EIDs and current-request Prebid EIDs are deduplicated by `source + uid.id`." (`:266`) with "Browser EIDs are forwarded as sent; KV adds EIDs only for registered sources the browser did not send." + +- [ ] **Step 4: `docs/guide/ec-setup-guide.md`.** + +After the "Expected decoded payload contains:" list in the `x-ts-eids` verification step (`:186-189`), add: + +```markdown +When the request also carries browser EIDs, the decoded header lists them +first, exactly as the `trustedServer` adapter sent them (including `inserter`, +`matcher` and `mm`), followed by KV-only partner sources. The browser copies +the same EIDs, without EID-level `ext`, into the `ts-eids` cookie, which +navigations and `GET /_ts/page-bids` ingest. `/auction` ingests its own body +EIDs instead of the cookie. +``` + +- [ ] **Step 5: `docs/guide/api-reference.md`.** + +(a) In `### POST /auction`, insert before `#### PBS stored-request intent`: + +````markdown +#### Browser EIDs (`eids`) + +The optional `eids` array carries OpenRTB 2.6 EID objects from the browser: + +```json +{ + "eids": [ + { + "source": "ids.example.com", + "inserter": "inserter.example.com", + "matcher": "ids.example.com", + "mm": 4, + "ext": { "segments": ["s1"] }, + "uids": [ + { "id": "fictional-uid", "atype": 1, "ext": { "partner": "example" } } + ] + } + ] +} +``` + +Each entry is validated on its own. A malformed entry or field is dropped and +the request proceeds; only a malformed body outside `eids` rejects the request. + +| Field | Rule | +| --------------------- | -------------------------------------------------------- | +| entry | JSON object that parses on its own | +| `source` | string, not blank, at most 255 UTF-8 bytes | +| `uids` | at least one valid UID; the first 32 valid UIDs are kept | +| `uids[].id` | string, not blank, at most 512 bytes | +| `uids[].atype`, `mm` | integer from 0 to 2,147,483,647 | +| `uids[].ext`, `ext` | JSON object, at most 2,048 bytes as compact JSON | +| `inserter`, `matcher` | string, not blank, at most 255 bytes | +| array | duplicates dropped, then the first 64 entries kept | + +Entries keep their order and a source may repeat. When the body has no valid +entry, the `ts-eids` cookie is used with the same rules. Browser EIDs are used +only for a request with an allowed EC. + +The response's `x-ts-eids` header carries the consent-gated EIDs sent to +providers, base64-encoded JSON including EID-level fields, capped at 4,096 +bytes. `x-ts-eids-truncated: true` marks a truncated value. +```` + +(b) In `### GET /\_ts/admin/eids` (`:904`), append to the first paragraph: "Cookie entries are validated one by one with the `/auction` body rules, so a mistyped field drops only that field, and the returned `eids` include `inserter`, `matcher` and `mm` when present. The `ingest.unmatched` diagnostics still read the raw cookie, before validation." + +- [ ] **Step 6: `docs/guide/configuration.md`.** + +After the `::: tip Partner keying` block (`:651-653`), add: + +```markdown +`openrtb_atype` sets the `atype` of EIDs that Trusted Server builds from KV. +It applies only where KV supplies a source the browser did not send; for a +source in the browser's EIDs, the browser's own entry and `atype` are +forwarded instead. +``` + +- [ ] **Step 7: Format and check links.** + +Run: + +```bash +(cd docs && npm run format) +docs/node_modules/.bin/prettier --config docs/.prettierrc --check "*.md" ".claude/**/*.md" ".github/**/*.md" "crates/**/*.md" "scripts/**/*.md" "tinybird/**/*.md" +git grep -n -E "getUserIdsAsEids|contribute_eids|deduplicat" docs/guide +``` + +Expected: format clean (fix with `npm run format:write` and the same Prettier command with `--write` in place of `--check`). The `git grep` should list only historical or Prebid-API mentions that are still correct. Rewrite any line that describes TS reading `getUserIdsAsEids()` or deduplicating by source. If `docs/` uses VitePress dead-link checking (`cd docs && npm run build`), run it to confirm the new `#tcf-and-the-trustedserver-adapter` anchor resolves. + +- [ ] **Step 8: Commit.** + +```bash +git add docs/guide +git commit --signoff -S -m "Document Prebid EID forwarding, the ts-eids writer and the TCF exception" -m "Guides now describe forwarding the trustedServer bidder request's EIDs, the browser-first merge, per-entry validation, KV ingestion inputs by route, TS no longer setting userSync, and the full trustedServer TCF rule with its warning." +``` + +--- + +### Task 12: Final verification and hand-off + +Spec: 7.6, 7.7, 8.2, 8.3, A12, A13. + +- [ ] **Step 1: Run every CI gate (spec 7.7, `AGENTS.md` "CI Gates").** + +```bash +cargo fmt --all -- --check +cargo clippy-fastly && cargo clippy-axum && cargo clippy-cloudflare && cargo clippy-cloudflare-wasm && cargo clippy-spin-native && cargo clippy-spin-wasm && cargo clippy-cli && cargo clippy-codegen +cargo test-fastly && cargo test-fastly-reuse && cargo test-axum && cargo test-cloudflare && cargo test-spin +cargo test --manifest-path crates/trusted-server-integration-tests/Cargo.toml --test parity +(cd crates/trusted-server-js/lib && npx vitest run && npm run format) +(cd docs && npm ci && npm run format) +docs/node_modules/.bin/prettier --config docs/.prettierrc --check "*.md" ".claude/**/*.md" ".github/**/*.md" "crates/**/*.md" "scripts/**/*.md" "tinybird/**/*.md" +cargo test -p trusted-server-core --test template_build_digest && cargo clippy -p trusted-server-core --test template_build_digest -- -D warnings +scripts/lint-wasm-clock.sh +./scripts/test-cli.sh +``` + +Expected: all PASS. Report any failure with its output. Do not call the work done while a gate fails. + +- [ ] **Step 2: Check the red log is complete (A12).** + +`docs/superpowers/implementation-notes/2026-10-18-prebid-eid-fidelity.md` must have a `## Task N` entry for Tasks 1 to 10. Every test added or changed must appear either with its failure before the change, or as a regression net with the reverted line and its failure. Run `(cd docs && npm run format)` again if you edited it, and commit it if it changed: + +```bash +git add docs/superpowers/implementation-notes/2026-10-18-prebid-eid-fidelity.md +git commit --signoff -S -m "Complete the Prebid EID fidelity red log" +``` + +- [ ] **Step 3: Hand the live check to the user (spec 7.6).** + +The user runs these steps through `ts dev proxy` on a page with Lockr and the default User ID preset. Give them the list and report only what they observe: + +1. On a `main` build, record `pbjs.getConfig('userSync').auctionDelay` before the first `requestBids` and after `window.load`, note when the CMP answered, and record the sources of the first `/auction` body. +2. On the branch, record the same. Expect the publisher's or Prebid's delay throughout, and asynchronous User ID sources (criteo, LiveIntent) in the first body when they resolve within that delay. Lockr's ID5 may still be missing from the first auction. +3. Decode an `/auction` response's `x-ts-eids` and the cookie with `JSON.parse(new TextDecoder().decode(Uint8Array.from(atob(value), (c) => c.charCodeAt(0))))`. The header's browser entries should equal the request body's, with their own `inserter`, `matcher` and `mm`, followed by KV-only sources. The cookie should equal the body without EID-level `ext`, subject to the trim. +4. `/_ts/admin/eids` (authenticated) lists the cookie's `adserver.org` entries with their own fields. +5. Compare `pbjs.getEvents()` `bidRequested` events for `trustedServer` with the `/auction` bodies. The entries should match. + +If step 1 shows `main`'s first auction already ran with a delay above 0, say that D4 on that page had another cause. Do not claim a fix (spec 4.9). + +- [ ] **Step 4: Prepare the pull request text. Do not open the PR or push unless the user asks.** + +The implementation PR is based on `spec/1246-prebid-eid-fidelity`. It links the spec and issue #1246 and lists, from spec 8.2: + +- Bidders: browser entries first, KV-only sources after. KV's value and `atype` are no longer sent for a browser source, and repeated sources are no longer folded. +- Publishers: TS stops overwriting `syncEnabled`, `filterSettings`, `syncsPerBidder`, `syncDelay` and `auctionDelay`, and no longer force-enables iframe syncs. Auctions wait up to `auctionDelay` (500 ms by default) while IDs are pending. A late managed seed still leaves `autoRefresh: true`. +- Server-side bidders folded into `trustedServer`: IDs restricted to them are no longer forwarded. +- GDPR traffic without a `trustedServer` exception: the cookie is cleared instead of written from `getUserIdsAsEids()`. Body-less routes and KV ingestion lose browser EIDs for that traffic, including the IdentityLink flow. +- Cookie contents: `ortb2` EIDs and provenance fields, never EID-level `ext`, written at request time without a Purpose 1 check of its own. +- KV ingestion on `/auction` no longer reads the cookie next to the body. +- Operator actions (spec 8.3): the staging check of repeated sources and the four fields per provider before production, regenerating bundles with a baked-in shim, telling publishers about `userSync`, and the full TCF rule (plus `personalizedAds` with `eidsRequireP4Consent`). +- Follow-ups (spec 8.5), filed or listed: the Prebid.js identityLink fix (D7), `sharedId` ingestion ignoring restrictions, TS's TCF identity, per-bidder EID forwarding through PBS `eidpermissions`, KV refresh rules, restoring `autoRefresh`, gating the cookie write on Purpose 1, a server-written cookie, and a lenient parse of the rest of the `/auction` body. + +--- + +## Spec coverage + +| Spec item | Task | +| ------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------- | +| D1, G1 (scoped EIDs only) | 7, 9 (D1 arm) | +| D2 (`ortb2` EIDs, #1246) | 6, 7, 9 (D3 and TCF-rule arms) | +| D3 (EID-level fields) | 1, 2, 3, 6, 7, 9 | +| D4, G6 (`auctionDelay`) | 8, 9 (D4 arm and control) | +| D5, G2 (cookie copies the body set) | 6, 7 | +| D6, O6 (dead registry) | 10 | +| D7 (identityLink upstream) | Out of scope; follow-up in the PR text (Task 12) | +| D8, G5 (isolation, budget, one parser) | 2, 3, 6 | +| D9, G4, O3 (browser-first merge) | 4 | +| G3, O7 (KV input selection) | 5 | +| G7 (`x-ts-eids` with fields) | 1 | +| 3.7, O9 (TCF listener, full rule) | 7, 9, 11 | +| 4.4 (`raw_value`, guard list, admin diagnostics) | 2, 3 | +| 4.7 (golden bytes unchanged) | 1 (Step 6) | +| 4.12 (observability) | 2, 7 | +| 7.1 rules 1 to 12 | Global Constraints; each task's red step; 3 (rule 8 Rust); 6 and 7 (rule 8 TSJS); 5 (rule 11); 7 (rule 3); 9 (rule 12) | +| 7.6 live check | 12 | +| 7.7 CI gates | 12 | +| 8.6 docs | 11 | +| A1 to A13 | A1: 7, 9. A2: 3, 5, 7. A3: 4. A4: 2, 3. A5: 1. A6: 8, 9. A7: 7, 9. A8: 5. A9: 6. A10: 9. A11: 1. A12: red log, 12. A13: 12 | diff --git a/docs/superpowers/specs/2026-10-18-prebid-eid-fidelity-design.md b/docs/superpowers/specs/2026-10-18-prebid-eid-fidelity-design.md new file mode 100644 index 0000000000..8867174091 --- /dev/null +++ b/docs/superpowers/specs/2026-10-18-prebid-eid-fidelity-design.md @@ -0,0 +1,1617 @@ +# Prebid EID fidelity + +Status: Reviewed. Round 5 found nothing blocking (see the review log). Backwards +compatibility was explicitly not a constraint; section 8.2 lists what breaks. + +Issue: [#1246](https://github.com/IABTechLab/trusted-server/issues/1246), "Write +Lockr IDs to KV store". + +Designed against `main` at `a9037770a`. Every `file:line` reference below is to +that commit. Prebid.js references are to version 10.26.0, the version +`crates/trusted-server-js/lib/package-lock.json` pins, read from +`crates/trusted-server-js/lib/node_modules/prebid.js/`. References marked +"(11.43)" are to Prebid.js 11.43.0-pre at commit `51e968dba`, audited in +section 3.5 to check how the design holds across a future upgrade. + +Related work: + +- [KV EID request snapshot and EC recovery](2026-07-10-kv-eid-request-snapshot-ec-recovery-design.md) + defines the `/auction` body to KV path this spec keeps. +- Draft PR #1382 (pluggable modules stack, #777) moves the `ts-eids` cookie + parser out of core. Section 8.4 covers the overlap. +- Open PR #1406 changes Lockr API routing. It does not touch EIDs. + +Terms used throughout: + +- **EID**: an OpenRTB extended identifier object, `{source, uids[]}` plus + optional `inserter`, `matcher`, `mm` and `ext` (OpenRTB 2.6 §3.2.27). A + **UID** is one entry of `uids`, `{id, atype?, ext?}` (§3.2.28). +- **EID-level fields**: `inserter`, `matcher`, `mm` and the EID's own `ext`, as + opposed to a UID's `atype` and `ext`. They say who added an ID and how it was + matched. +- **`ortb2` / FPD**: Prebid's first-party data, the OpenRTB fragments that + Prebid merges into each bidder's request. +- **Shim**: the `tsjs-prebid` script Trusted Server (TS) serves. It registers + the `trustedServer` Prebid bid adapter, which sends bids to `/auction`. +- **EC / KV identity graph**: the Edge Cookie ID and the KV store row that maps + it to partner UIDs registered under `[[ec.partners]]`. +- **`ts-eids`**: the first-party cookie in which the shim copies the browser's + EIDs for requests that carry no EID body. +- **Body-less routes**: requests that carry no EID body and read `ts-eids` + instead: the server-side auction at page navigation, `/_ts/page-bids`, and + `/auction` calls from `tsjs.requestAds`. +- **`x-ts-eids`**: the `/auction` response header that shows the consent-gated + EIDs TS sent to providers. Used for validation and troubleshooting. + +## 1. Problem statement + +The `trustedServer` adapter should forward to `/auction`, and copy into +`ts-eids`, the same EIDs Prebid.js gives that bidder, and KV should learn only +those. Today the adapter reads a different Prebid API, misses whole EID +sources, leaks IDs Prebid withholds, drops OpenRTB fields, and copies a +different set into the cookie than it sends. + +### D1. IDs Prebid withholds from `trustedServer` are forwarded (privacy, contract) + +- `collectAuctionEids` reads only `pbjs.getUserIdsAsEids()` + (`crates/trusted-server-js/lib/src/integrations/prebid/index.ts:1891-1914`). +- In Prebid that call returns `getEids(initializedSubmodules.combined)` + (`modules/userId/index.ts:675-677`). `combined` folds every bidder-restricted + module map into the global one (`modules/userId/index.ts:440`). A + `userSync.userIds[]` entry with `bidders: ['otherBidder']` is therefore + returned too. +- It also skips per-bidder redaction. Prebid applies the `transmitEids` + activity to `bidderRequest.ortb2` and `bid.userIdAsEids` only + (`src/activities/redactor.ts:127-160`). EIDs that TCF enforcement + (`modules/tcfControl.ts:291-316`) or US-state GPP rules + (`libraries/mspa/activityControls.ts:130-131`, 11.43) strip from + `trustedServer` are still returned. It does honor the module-level + `enrichEids` activity (`modules/userId/index.ts:385`). +- The result feeds the `/auction` body (`index.ts:2361`, `:2365`) and the + `ts-eids` cookie (`index.ts:2990`). The server forwards client EIDs to every + server-side provider (`auction/endpoints.rs:347-350`) and ingests registered + sources into KV (`ec/finalize.rs:84`, `:142`). The server's own consent gates + (4.8) catch most consent cases, but not bidder restrictions. +- Observed: not seen on the investigated live page, which had no restricted + entries and no consent denial. It follows directly from the Prebid source. + +### D2. EIDs from `ortb2` never reach Trusted Server (#1246) + +- `buildRequests(validBidRequests)` (`index.ts:2357`) ignores the second + argument Prebid passes, `bidderRequest` + (`src/adapters/bidderFactory.ts:140`, called at `:438`). Nothing in the + collector reads `ortb2`. +- So EIDs set with `setConfig({ortb2})`, `mergeConfig`, `setBidderConfig`, + per-call `requestBids({ortb2})` or RTD modules are invisible. The Lockr SDK + writes its ID5 EID through `setConfig({ortb2: {user: {ext: {eids}}}})`. +- Without the userId module, `buildRequests` sends no EIDs at all and + `syncPrebidEidsCookie` clears the cookie (`index.ts:2984-2987`), even when + `ortb2` holds EIDs. +- Observed: #1246 reports `getUserIdsAsEids()` returning only `criteo.com` and + `pubcid.org` on a Lockr page whose client-side bidders received Lockr's ID5. + In a later live session, 13 of 14 `bidRequested` events for `trustedServer` + carried Lockr's `id5-sync.com` entry in `bidderRequest.ortb2`. The + `/auction` body is built from `getUserIdsAsEids()`, so it cannot carry that + entry. Lockr does put the ID into Prebid; TS reads the wrong API. + +### D3. EID-level OpenRTB fields are dropped + +- TSJS: `AuctionEid` is `{source, uids}` (`src/core/auction.ts:30-33`), the + collector pushes `{source: eid.source, uids}` (`index.ts:1910`) and the + cookie writer rebuilds `{source, uids}` (`index.ts:2926`). +- Rust: `Eid` holds `source` and `uids` with `deny_unknown_fields` + (`crates/trusted-server-core/src/openrtb.rs:67-75`). The body parser builds + `Eid { source, uids }` (`auction/endpoints.rs:533`). The cookie parser's + structs have no such fields (`ec/prebid_eids.rs:33-47`). +- `merge_auction_eids` folds every entry of a source into one + (`auction/endpoints.rs:564-622`), so even with the fields carried it could + keep only one entry's values per source. +- Prebid sets them. unifiedId's `adserver.org` entry carries `inserter` and + `matcher` `adserver.org` and `mm: 4` (`modules/unifiedIdSystem.js:78-85`). + LiveIntent puts `segments` in the EID's own `ext` + (`libraries/liveIntentId/shared.js:156-168`). sharedId adds an `inserter` + when configured (`modules/sharedIdSystem.ts:194-203`). Lockr's `ortb2` entry + was observed with `inserter` and `mm: 2`. Section 3.5 lists the rest. + +### D4. TS forces `auctionDelay: 0` (yield) + +Prebid 10.26.0 behavior: + +- `auctionDelay` defaults to 500 ms (`src/userSync.ts:59-70`). +- `userSync` settings accumulate across `setConfig` calls. The userSync module + merges each new `userSync` config into the object Prebid also uses as that + topic's defaults (`src/userSync.ts:103-120`, `:383-384`), so a later call + keeps earlier keys such as `userIds`. A repo test characterizes this + (`test/prebid-artifact-integration.test.mjs:432-446`). +- The userId module reads `auctionDelay` and `syncDelay` whenever a `userSync` + config containing `userIds` is set (`modules/userId/index.ts:1245-1256`). +- At its first initialization the module decides when asynchronous `getId` + calls start: immediately if `auctionDelay > 0`, otherwise only after the + first auction ends plus `syncDelay` (`modules/userId/index.ts:536-547`). +- Each auction waits for pending IDs or `auctionDelay`, whichever comes first + (`modules/userId/index.ts:596-606`). + +Trusted Server behavior: + +- `installUserIdModules()` (`index.ts:2881-2898`) runs on `window.load`, or at + install if the page has already loaded (`index.ts:3020-3033`). It calls + `setConfig({userSync: {syncEnabled: true, filterSettings: {all: …}, +auctionDelay: 0, syncsPerBidder: 5, syncDelay: 3000}})`. +- Because settings accumulate, the result still carries `userIds`, so the + userId module applies `auctionDelay: 0` to every auction after `window.load`. + The call also overwrites whatever the publisher set for `syncEnabled`, + `syncsPerBidder` and `syncDelay`, and its `filterSettings.all` enables iframe + user syncs for every bidder, where Prebid's default allows image syncs only. +- A module first initialized after `window.load` starts with TS's `0`. How it + then behaves depends on the path: + - A publisher `userIds` config set after load defers every vendor call until + after the first auction ends plus `syncDelay`. + - TS's managed User ID seed after a late CMP answer sets `autoRefresh: true` + (`index.ts:2247-2256`). Prebid then runs the new modules' callbacks at once + (`modules/userId/index.ts:1093-1097`, `:549-566`). Vendor calls start, but + with `0` no auction waits for them. +- `auctionDelay` never covers vendors that write `ortb2` asynchronously, such + as Lockr. Only User ID module `getId` calls are awaited. + +Observed: the first of 14 `trustedServer` requests on the live page carried +only `pubcid.org` and `adserver.org`, both read from storage synchronously. The +other 13 carried 13 sources, including criteo, LiveIntent and 8 LiveIntent +partner sources, and Lockr's ID5. The page's effective `auctionDelay` and the +order of `window.load`, CMP answer and first auction were not captured, so +TS's `0` is the likely cause, not a proven one. Section 7.5 proves the +mechanism; the live check in 7.6 decides whether it was the cause on that page. + +### D5. The cookie copies a different set from the request + +- `syncPrebidEidsCookie()` (`index.ts:2982-3009`) collects EIDs again in the + chained `bidsBackHandler` (`index.ts:2537-2541`), without the bidder request, + after every auction routed through the shim. +- Once D2 is fixed in `buildRequests` alone, the cookie and the body would + carry different sets. Body-less routes (`publisher.rs:5531-5556`, + `auction/endpoints.rs:472-477`) and KV ingestion read the cookie. +- Keying a copy of the body by auction in `bidsBackHandler` does not work: + `bidderRequest.auctionId` is `null` unless the publisher sets `enableTIDs` + (`src/adapters/bidderFactory.ts:200-232`, + `src/activities/redactor.ts:225-230`). + +### D6. Dead registry metadata (low) + +`user_id_modules.json` carries `eidSources` per module, and +`resolvePrebidUserIdModulesFromEids` +(`src/integrations/prebid/user_id_modules.ts:104-112`) maps sources back to +modules. Only tests read either; the CLI reads only `moduleName` and +`configNames`. The data is also wrong in places: ID5 emits +`true-link-id5-sync.com` (`modules/id5IdSystem.js:41`), and LiveIntent's +`adserver.org` and `uidapi.com` entries cannot be listed without taking those +sources from unifiedId and uid2. + +### D7. Prebid identityLink disables itself on a malformed `_lr_env` (upstream) + +`modules/identityLinkIdSystem.js` calls `JSON.parse(envelope).envelope` without +a guard in the ATS branch (`:83`) and the storage branch (`:93`). The userId +core catches the exception, so the module never falls back to the LiveRamp +endpoint and never stores an ID. On the live page `_lr_env` base64-decoded to +text that is not JSON. Which script writes that value is unconfirmed. + +### D8. One bad EID fails a whole request or cookie + +- `JSON.stringify` writes a lone UTF-16 surrogate as an escape such as + `"\ud800"`. The server parses the body in one `serde_json::from_slice` + (`auction/endpoints.rs:161`), and serde_json rejects a lone surrogate escape + (`src/read.rs:900-960` in 1.0.150 as locked; the error is + `UnexpectedEndOfHexEscape` or `LoneLeadingSurrogateInHexEscape`, depending on + what follows). One bad string in any EID, from any vendor or publisher, turns + the request into a 400 and drops all `trustedServer` demand for that auction. +- UID-level `ext` has no size limit in TSJS or Rust. An oversized vendor `ext` + can push the body past 256 KiB (`auction/endpoints.rs:46`), with the same + result. +- The cookie is validated differently from the body. The body parser drops a + bad field and keeps the entry (`auction/endpoints.rs:491-562`); the cookie + parser deserializes into typed structs (`ec/prebid_eids.rs:33-47`, `:90-95`), + so one mistyped field such as `"atype": "1"` rejects the whole cookie, and no + caps apply. The shim's `btoa` throws on characters above U+00FF + (`index.ts:2931`, `:3002`), leaving the previous cookie in place. + +### D9. KV's stale copy overrides the browser's + +`merge_auction_eids` lists KV-resolved EIDs first, deduplicates UIDs by `id`, +and keeps the first copy's `atype` and `ext` (`auction/endpoints.rs:564-622`, +pinned by the test at `:1721`). KV keeps the first value it stored and never +replaces it from the browser (KV snapshot spec, "Finalization Contract"; +`ec/kv.rs:560-600` defers a different existing value), and its `atype` comes +from operator config (`openrtb_atype`, `ec/registry.rs:28`). So for rotating +IDs bidders receive the old KV value next to the fresh browser value, and KV's +operator-configured `atype` overrides the vendor's own. + +| # | Severity | Fixed by | +| --- | --------------------------- | -------------- | +| D1 | Privacy / contract | 4.2, 4.6 | +| D2 | High | 4.2 | +| D3 | Medium | 4.3, 4.4, 4.5 | +| D4 | Medium (yield) | 4.9 | +| D5 | Medium | 4.3 | +| D6 | Low | Section 6, O6 | +| D7 | External | Follow-up, 8.5 | +| D8 | Low likelihood, high impact | 4.2, 4.3, 4.4 | +| D9 | Medium | 4.5 | + +## 2. Goals and non-goals + +Goals: + +- **G1.** The `/auction` body carries exactly the EIDs Prebid placed in the + `trustedServer` bidder request, as plain data. (D1, D2) +- **G2.** `ts-eids` copies the body set of the latest `trustedServer` request, + without EID-level `ext` and within 3,072 bytes. It is cleared when that set + is empty or Prebid's TCF rules block `trustedServer`, and left alone by + auctions that do not include `trustedServer`. (D5) +- **G3.** KV ingestion on `/auction` uses exactly the client EIDs the auction + used. (D1 for KV; #1246) +- **G4.** Browser entries reach the outbound `user.ext.eids` as Prebid sent + them, EID-level fields included. KV adds entries only for registered sources + the browser did not send. (D3, D9) +- **G5.** One parser for body and cookie. A bad entry costs only that entry, + and EIDs cannot push the body over its limit. (D8) +- **G6.** TS stops overriding the publisher's Prebid `userSync` config, so the + publisher's or Prebid's `auctionDelay` applies from the first auction. (D4) +- **G7.** The `x-ts-eids` response header stays, now with EID-level fields, for + validation and troubleshooting. + +Non-goals: + +- **Changing who writes `ts-eids`, its lifetime, or moving it server-side.** + The cookie stays a browser-written first-party cookie that TS reads before + KV (O1). +- **Fixing D7 in Trusted Server.** The bug is in a Prebid module that TS does + not fork. An upstream fix is a follow-up (8.5). +- **Waiting for asynchronous `ortb2` vendors before the first auction.** After + their first `/auction`, registered partners such as Lockr's ID5 come from the + cookie and KV on later requests. +- **Forwarding first-party data other than EIDs** (`user.data` segments, + `site`, `device`) from `bidderRequest.ortb2`. A separate contract; Permutive + segments already travel through the auction `config` (3.6). +- **Allowing RTD modules in generated bundles.** The CLI rejects them today + (3.5). The collector handles their EIDs if that changes. +- **Changing KV storage rules or the identity graph schema**, including how + browser-sourced partner IDs are refreshed. Those belong to the identity + architecture work; this spec stores nothing new. +- **Moving outbound EIDs from `user.ext.eids` to OpenRTB 2.6 `user.eids`.** + Prebid Server moves `user.ext.eids` to `user.eids` itself + (`moveEIDFrom25To26` in `openrtb_ext/convert_up.go`), Prebid normalizes to + `user.ext.eids` (11.43 `src/fpd/normalize.js:28-45`), and APS receives that + shape from Prebid's own adapter. Moving gains nothing and risks APS. +- **TS's own TCF identity**: registering `trustedServer` as Prebid's + `VENDORLESS_GVLID`, a server-side per-provider vendor check, and the upstream + fix it needs. Needs legal review; its own spec (3.7, 8.5). +- **Server-side ingestion of Prebid's `_sharedid` cookie**, which ignores + Prebid's bidder restrictions. A follow-up issue (O10, 8.5). +- **Restricting EIDs per server-side bidder** through Prebid Server + `eidpermissions`. The adapter cannot see other bidders' fragments (4.8). +- **Adding Permutive identity (`permutiveIdentityManagerIdSystem`).** Product + decision. +- **A public `tsjs.addEids` API or server-side reads of vendor cookies.** + Separate features with their own contracts; #1246 does not need them. +- **A lenient parse of the rest of the `/auction` body.** Only `eids` is + isolated (4.4). + +## 3. Background + +### 3.1 Where Prebid comes from + +- `ts prebid client` generates a pure Prebid.js external bundle. Its entry + imports Prebid core, the consent modules, `tcfControl` and `userId` + (`crates/trusted-server-js/lib/build-prebid-external.mjs:519-556`). The + operator hosts it and sets `external_bundle_url`. +- TS serves the shim as a deferred script that runs after the external bundle + (`integrations/prebid.rs:1377`, asserted at `:4567-4570`). +- The shim registers the adapter, wraps `requestBids`, then calls + `pbjs.processQueue()` (`index.ts:2592`). Publisher `pbjs.que` commands + therefore run after TS's wrappers exist. The external bundle drains the queue + itself only if the shim never installs (`build-prebid-external.mjs:547-553`). +- External bundles generated before the shim split carry a baked-in shim. + `window.__tsjsPrebidShimInstalled` (`index.ts:2002-2009`) lets the first one + win (`docs/guide/integrations/prebid.md:236-241`). + +### 3.2 How Prebid builds a bidder's EIDs + +1. `requestBids` builds `ortb2Fragments`: `global` is the `ortb2` config merged + with per-call `ortb2`; `bidder[code]` is that bidder's `setBidderConfig` + data (`src/prebid.ts:800-803`). +2. `normalizeFPD` moves `user.eids` into `user.ext.eids`, removing only + deep-equal duplicates (`src/fpd/normalize.js:25-47`, called at + `src/prebid.ts:804`). +3. The userId hook waits (3.3), then `enrichEids` appends unrestricted module + EIDs to `global.user.ext.eids` and restricted ones to + `bidder[code].user.ext.eids` (`modules/userId/index.ts:446-465`). RTD + modules write into the same fragments, after normalization, so an RTD + module that writes `user.eids` leaves it there (3.5). +4. `adapterManager` sets each bidder's `bidderRequest.ortb2` to global data + merged with that bidder's fragment, redacted by activity controls + (`src/adapterManager.ts:519-545`, `:588-597`). Redaction covers both + `user.eids` and `user.ext.eids` (`ORTB_EIDS_PATHS`, + `src/activities/redactor.ts:23`). +5. The adapter receives it as `buildRequests(validBidRequests, bidderRequest)` + through a proxy that hides `auctionId` and `transactionId` unless + `transmitTid` is allowed (`src/adapters/bidderFactory.ts:200-232`, `:438`). + Each bid's `userIdAsEids` is a getter for the same `user.ext.eids` array + (`modules/userId/index.ts:612-621`). + +`pbjs.getUserIdsAsEids()` sits outside this pipeline. It returns every module +EID, restricted and redacted ones included, and nothing from `ortb2` (D1). + +`createEidsArray` (`modules/userId/eids.js:49-90`) keys module EIDs by the +lowercased source plus the values of every other EID-level field. Same-source +entries with different `inserter`, `matcher`, `mm` or `ext` stay separate. For +example, unifiedId's TDID (`matcher: adserver.org`) and LiveIntent's TDID +(`matcher: liveintent.com`) are two `adserver.org` entries. Prebid passes +`ortb2` entries through without grouping. + +### 3.3 Prebid timing + +D4 lists the mechanics. In page order today: + +1. The HTML loads the `pbjs` stub, the external bundle and the deferred shim. +2. The shim installs and runs the publisher queue. The first `userSync` config + with `userIds` initializes the userId module with the publisher's + `auctionDelay`, or Prebid's 500 ms. +3. Managed User ID seeding runs once a TCF CMP answers or the publisher + configures `consentManagement` (`index.ts:2261-2323`), building its config + from the effective `userSync`. +4. `window.load` fires and `installUserIdModules()` sets `auctionDelay: 0` and + TS's sync settings. Every later auction waits for nothing. A module a + publisher first configures after this point defers its vendor calls; one TS + seeds after a late CMP answer calls its vendor at once, but no auction waits + for it. +5. Vendors that call their own API, such as Lockr, write `ortb2` when their + response arrives. Auctions that start after that see it. + +### 3.4 Current Trusted Server data flow + +```mermaid +flowchart LR + subgraph Browser["Browser: tsjs-prebid shim"] + GU["pbjs.getUserIdsAsEids()
all module EIDs, restricted included;
nothing from ortb2"] + BR["trustedServer.buildRequests
(ignores bidderRequest)"] + BB["bidsBackHandler:
syncPrebidEidsCookie()"] + GU --> BR + GU --> BB + end + BR -- "POST /auction
body.eids {source, uids}" --> PB + BB -- "ts-eids cookie
base64 JSON, at most 3 KB" --> PC + subgraph Edge["trusted-server-core"] + PB["parse_client_auction_eids
(lenient)"] + PC["parse_prebid_eids_cookie
(strict serde)"] + SEL["body, else cookie"] + NAV["navigation auction, page-bids:
cookie only"] + KV[("KV identity graph")] + RK["resolve_auction_eids"] + M["merge_auction_eids
by source, KV first"] + G["gate_eids_by_consent"] + OUT["providers:
user.ext.eids"] + ING["collect_eid_updates:
cookie, then body, then sharedId"] + PB --> SEL + PC --> SEL + PC --> NAV --> M + SEL --> M + KV --> RK --> M --> G --> OUT + PC --> ING + SEL --> ING + ING -- "fill absent partner IDs" --> KV + end +``` + +- `/auction` uses the body EIDs, or the cookie when the body has none + (`auction/endpoints.rs:472-477`), only when the request has an allowed EC + (`auction/endpoints.rs:293-300`). `tsjs.requestAds` never sends a body set + (`src/core/request.ts:45`), so it always uses the cookie. +- The navigation auction and `/_ts/page-bids` read only the cookie, through + `apply_auction_eids_and_device` (`publisher.rs:5526-5556`). +- `merge_auction_eids` (`auction/endpoints.rs:564-622`) lists KV-resolved EIDs + first, then client EIDs, grouped by `source`, deduplicates UIDs by `id`, and + lets the KV value win for `atype` and UID `ext` (D9). +- Outbound EIDs go to `user.ext.eids` through one shared `UserExt` + (`openrtb.rs:33-48`): config-first providers (`auction/openrtb.rs:372`), + Prebid Server (`integrations/prebid.rs:2731`) and APS + (`integrations/aps.rs:1119`). No other code path serializes EIDs; the + orchestrator, routing, telemetry and mock ad server only build empty ones. +- The `/auction` response carries the consent-gated outbound set in + `x-ts-eids` (`auction/formats.rs:536-548`). +- On response finalization (`ec/finalize.rs:84`, `:142`), `collect_eid_updates` + (`ec/prebid_eids.rs:138-161`) reads the cookie, then the request's client + EIDs, then the `sharedId` cookie, keeps one UID per registered partner, and + writes only partners KV does not yet hold (`ec/kv.rs:145-155`). This runs on + navigation, `/auction` and `/_ts/page-bids` responses. The Fastly adapter + reads both cookies before routing + (`trusted-server-adapter-fastly/src/app.rs:420`, `:740`). + +### 3.5 Prebid module audit + +All 1,579 entries of Prebid.js 11.43 `modules/` were checked against 10.26.0 +for anything that produces, filters or moves EIDs. Everything below reaches +the adapter through `bidderRequest.ortb2`, so the collector (4.2) handles it +without module-specific code. + +The EID pipeline in 3.2 is the same in 11.43: `enrichEids` still writes +`user.ext.eids` (11.43 `modules/userId/index.ts:461-480`), `normalizeFPD` and +`createEidsArray` are unchanged apart from formatting, `auctionDelay` is read +only with `userIds` (11.43 `:1349-1353`), callbacks start at initialization +only when it is above 0 (11.43 `:599-606`), and `getUserIdsAsEids()` still +returns `combined` (11.43 `:757-758`). 11.43 adds batching of in-flight +callbacks and an `acpId` (Google Additional Consent provider ID) for adapters +without a GVL ID. + +| Category | What they do with EIDs | +| ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| User ID modules (63 in 10.26, 67 in 11.43) | `enrichEids` adds them to `user.ext.eids`, globally or per bidder. `lockrAIMIdSystem`, `growthCodeIdSystem` and `adriverIdSystem` have no `eids` mapping in either version, so they never produce an EID through Prebid | +| Modules that set EID-level fields | unifiedId (`inserter`, `matcher`, `mm: 4`), openPair (`mm: 3`, `inserter` and `matcher` from config and possibly `undefined`), sharedId (`inserter` from params), LiveIntent (TDID `matcher`; `ext.segments`), oneKey (`ext.preferences`), pubProvidedId (whatever the publisher passes, deep-cloned) | +| Sources emitted by more than one module | `adserver.org` (unifiedId, LiveIntent), `uidapi.com` (uid2, LiveIntent, Permutive identity manager), `id5-sync.com` (ID5, Permutive, Lockr via `ortb2`), `liveramp.com` (identityLink, Permutive), `google.com` (pairId, Permutive). Prebid keeps them as separate entries when their EID-level fields differ | +| Sources not known in advance | ID5 (an `euid` entry whose source comes from the response), intentIq, merkle | +| RTD providers (70 in 11.43) | Only a1Media (global `user.ext.eids`, `a1MediaRtdProvider.js:70-92`) and 51Degrees (11.43 only; global `user.eids`, one `51d.es` entry per `mm`, `51DegreesRtdProvider.js:262`, `:422-470`) write EIDs. Their `user.eids` survives to the adapter (3.2 step 3). TS bundles cannot include RTD modules today: the CLI rejects `real_time_data` (`trusted-server-cli/src/prebid_bundle.rs:1541-1554`) | +| Filters that run before the adapter | `tcfControl` (`enrichEids`, `transmitEids`, `eidsRequireP4Consent`), US-state GPP rules (11.43 `libraries/mspa/activityControls.ts:130-131`), `enrichmentLiftMeasurement` (blocks `enrichEids` for a control group, 11.43 `index.js:48-49`), `dataControllerModule` (`filterEIDwhenSDA` empties `user.ext.eids`, 11.43 `index.js:134-157`), the RTD write guard (11.43 `libraries/objectGuard/ortbGuard.js:15-17`), `rules` (11.43; can deny `fetchBids`, `rules/index.ts:3`). The collector inherits whichever of them the bundle contains (4.8) | +| Prebid Server adapter | Sends every distinct EID object in `user.ext.eids`, repeated sources included (11.43 `prebidServerBidAdapter/bidderConfig.js:47-82`, `:98-153`). Evidence for 4.5 | +| Not bidder EIDs | Google encrypted signals (`registerSignalSources`, `getEncryptedEidsForSource`) go to GPT, not to bidders, and correctly stay out | + +Shapes the server rules (4.4) must accept: integer `mm` and `atype` (including +PAIR's `571187`), EID-level `ext` objects of a few hundred bytes (LiveIntent +segments, oneKey preferences), `undefined` provenance fields (openPair), and +anything a publisher passes through pubProvidedId. + +### 3.6 Trusted Server integrations + +- **prebid** is the only integration that reads or writes EIDs. +- **lockr**: TS proxies the Lockr API, and until #1406 lands the shim rewrites + the SDK's API host (`integrations/lockr/index.ts:51-70`). The Lockr SDK + writes its EID into Prebid `ortb2`, which the collector reads like any other. +- **permutive** sends segments through a context provider in the auction + `config` (`integrations/permutive/index.ts:102-110`), not as EIDs. +- **aps, gpt, google_tag_manager, gpt_diagnostics, creative, datadome, + testlight**: no EID reads or writes. Their `setConfig` calls configure tsjs + or `googletag`, not Prebid. +- **didomi, osano, sourcepoint** (CMPs) affect EIDs only through Prebid's + consent modules and TS's server-side consent gates, which this design + inherits. + +### 3.7 TCF and the `trustedServer` adapter + +- The shim registers `trustedServer` without a `gvlid` (`index.ts:2349`). + Nothing in TS sets `gvlMapping`, `vendorExceptions` or + `softVendorExceptions`. +- Generated bundles include `tcfControl` (`build-prebid-external.mjs:536`). + TCF enforcement is active when the publisher configures + `consentManagement.gdpr`, or when the shim enables it on finding a TCF CMP + for managed User IDs (`index.ts:849-855`). +- With no rules configured, Prebid's defaults require vendor consent for + Purposes 1 and 2 (`modules/tcfControl.ts:50-99`). A module without a GVL ID + never has vendor consent (`validateRules`, `:232-243`). So where enforcement + is active and GDPR applies, `fetchBidsRule` (`:287`) removes `trustedServer` + from the auction and `transmitEidsRule` (`:291-316`) redacts its EIDs, + unless the publisher lists `trustedServer` as an exception. +- Today such traffic gets no `trustedServer` bid requests at all, but the + `ts-eids` cookie, written from `getUserIdsAsEids()`, still carries EIDs to + body-less routes and KV. The documented IdentityLink flow relies on that + (`docs/guide/integrations/prebid.md:845-856`). +- **A publisher rule replaces Prebid's default rule for that purpose + wholesale** (`ACTIVE_RULES[...] = rules[name] ?? opts.default`, `:398`; + 11.43 `:468`), and `validateRules` reads `enforcePurpose` and + `enforceVendor` from it directly (`:239-242`). A rule written as + `{purpose: 'basicAds', softVendorExceptions: ['trustedServer']}` therefore + switches off both checks for every bidder. The only safe form is the full + rule: + + ```js + { + purpose: 'basicAds', + enforcePurpose: true, + enforceVendor: true, + softVendorExceptions: ['trustedServer'], + } + ``` + + With it, `trustedServer` still needs Purpose 2 consent and skips only the + vendor check. For EIDs, Purpose 2 is then the only purpose that qualifies + `trustedServer`, because the soft exception sits on that purpose's rule + alone (`:295-307`, `:301`). `vendorExceptions` skips the purpose check too + and is not recommended. + +- A publisher that sets `eidsRequireP4Consent` makes the EID check use the + `personalizedAds` rule alone (`:310-314`). That rule needs the same full + form, with `enforcePurpose: true`, `enforceVendor: true` and + `softVendorExceptions: ['trustedServer']`. Without it `trustedServer` gets + bid requests with no EIDs, and the listener (4.3) clears `ts-eids` after + every auction. + +- After each auction Prebid reports what it blocked in the `tcf2Enforcement` + event (`biddersBlocked`, `eidsBlocked`, `:319-336`). The event fires on + `AUCTION_END`, which Prebid emits before it calls `bidsBackHandler` + (`src/auction.ts:257-264`). + +## 4. Architecture + +### 4.1 Overview + +```mermaid +flowchart LR + subgraph Browser["Browser: tsjs-prebid shim"] + P["Prebid bidderRequest.ortb2
for trustedServer"] + C["eids.ts collectBidderEids
plain-data copy, 64 KiB UTF-8 budget"] + W["eids.ts cookie writer
no EID ext, 3 KB, UTF-8 base64"] + T["tcf2Enforcement listener:
clear on block"] + P --> C --> W + end + C -- "POST /auction body.eids" --> R + W -- "ts-eids cookie" --> K + subgraph Edge["trusted-server-core"] + R["AdRequest.eids
raw JSON"] + K["ts-eids decode"] + N["ec::client_eids
parse_client_eids, per entry"] + SEL["/auction: body, else cookie
other routes: cookie"] + KV[("KV identity graph")] + M["merge_auction_eids
browser entries as sent,
KV fills absent sources"] + G["consent gate"] + O["providers: user.ext.eids
and x-ts-eids header"] + I["collect_eid_updates
/auction: its client set only;
other routes: cookie; then sharedId"] + R --> N + K --> N + N --> SEL --> M --> G --> O + KV --> M + SEL --> I --> KV + end +``` + +| Component | Owns | Does not own | +| -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | --------------------------------------------------- | +| Browser collector, new `integrations/prebid/eids.ts` | Reading `bidderRequest.ortb2`, the plain-data copy, the size budget, cookie fitting and encoding | Scoping and redaction (Prebid's), validation, merge | +| Adapter glue, `integrations/prebid/index.ts` | Calling the collector and the cookie writer in `buildRequests`, the TCF listener; no `userSync` writes | | +| Server parser, new `ec/client_eids.rs` | Isolating and validating each body or cookie entry into `Eid` | Merging, consent | +| Merge, `merge_auction_eids` | Browser entries as sent, plus KV entries for absent registered sources | Parsing | +| KV ingestion, `collect_eid_updates` and `ec/finalize.rs` | Choosing the input set, matching partners | KV write policy (`ec/kv.rs`, unchanged) | +| Bidstream, `openrtb::Eid` | Serializing the new fields for every provider and for `x-ts-eids` | | + +### 4.2 Browser: collection + +`integrations/prebid/eids.ts` holds pure functions, testable without mocking +`pbjs`: + +```ts +export function collectBidderEids(bidderRequest: unknown): unknown[] | undefined +``` + +1. It reads `bidderRequest.ortb2.user.ext.eids`, then + `bidderRequest.ortb2.user.eids`. Any shape other than an array contributes + nothing. Reading `user.eids` is required, not defensive: RTD modules write + after normalization, and 51Degrees writes there (3.5). Prebid redacts both + paths for the bidder (3.2 step 4), so reading both does not widen what + `trustedServer` receives. +2. It copies each entry through a JSON round trip, so the body holds plain data + and `undefined` fields (openPair) disappear. An entry whose serialization + throws, such as a circular object or a `BigInt`, is dropped. Without this a + single such entry would make the whole body's `JSON.stringify` throw. +3. It keeps entries in order while the total size of their serializations, + measured in UTF-8 bytes with `TextEncoder`, stays within + `MAX_AUCTION_EIDS_BYTES = 65,536`, and drops the rest. JavaScript string + length is not used: one UTF-16 code unit can be three UTF-8 bytes. The body + limit is 256 KiB (`auction/endpoints.rs:46`); realistic EID sets are a few + KB. This budget is what keeps EIDs from failing the request through size + (G5). +4. It returns `undefined` when nothing is left. + +It does no other validation. The server validates every entry, from the body +and the cookie alike (4.4), and a browser-side copy of the rules would only +drift. Lone surrogates are forwarded as `JSON.stringify` writes them; the +server drops that entry. + +The collector never reads `getUserIdsAsEids()`, the `ortb2` config or bidder +config, and entries are not merged in the browser. Everything `trustedServer` +may receive is already in its bidder request, scoped and redacted by Prebid. + +`buildRequests(validBidRequests, bidderRequest)`: + +1. If `bidderRequest` is not an object, it sends no `eids` and leaves the + cookie alone, with a debug log. Prebid 10 and 11 always pass it. +2. `eids = collectBidderEids(bidderRequest)`. +3. If `eids` is `undefined`, it clears `ts-eids`. Otherwise it writes `ts-eids` + from `eids` (4.3). Both happen synchronously, before Prebid sends the + request, so the server never falls back to a stale cookie for this request. +4. It passes `eids` to `buildAdRequest`, as today. +5. It logs counts at debug level: entries forwarded, dropped as + unserializable, dropped for the budget, and the cookie decision. Never + values. + +Nothing reads `bidderRequest.auctionId`. Deleted from the shim: +`collectAuctionEids` (`index.ts:1891-1914`), `sanitizeAuctionUid` +(`:1076-1101`), the userId-module condition on the clear (`:2362-2364`), +`syncPrebidEidsCookie` (`:2982-3009`) and its call in the chained +`bidsBackHandler` (`:2537-2541`). `AuctionEid` in `src/core/auction.ts:30-33` +becomes `unknown`, since the server defines the contract. + +### 4.3 Browser: the `ts-eids` cookie + +- **When.** Written or cleared in every `buildRequests`, before the request + (4.2). Auctions whose ad units do not include `trustedServer` never call it, + so they leave the cookie as it is. +- **Content.** The collected entries, each without its EID-level `ext`. Entries + that become identical after the removal are dropped, compared by a + serialization with sorted keys. +- **Size.** Fitted to 3,072 encoded bytes with today's tail trim + (`index.ts:2925-2954`): drop UIDs from the last entry, then the entry + itself, and repeat. The collector does not validate, so the last entry may + be `null`, a string, or an object without a `uids` array; such an entry is + removed whole. +- **Encoding.** Base64 (standard alphabet, padded) of the UTF-8 bytes of the + JSON array, via `TextEncoder`. For ASCII payloads this matches today's + `btoa` output, and it no longer throws on characters above U+00FF. +- **Failure.** If nothing fits or encoding throws, the cookie is cleared + rather than left stale. +- **Attributes** are unchanged: `Path=/; Secure; SameSite=Lax; Max-Age=86400`. +- **Overlapping auctions.** The last `buildRequests` wins. Every value is one + complete `trustedServer` request's set. +- **TCF block (O9).** A `pbjs.onEvent('tcf2Enforcement')` listener, registered + at install, clears `ts-eids` when `biddersBlocked` or `eidsBlocked` contains + `trustedServer`. A blocked bidder never reaches `buildRequests`, so without + the listener a cookie written before consent changed would live for a day. +- **Storage consent.** Where the bidder request's `gdprConsent` says GDPR + applies, `buildRequests` writes the cookie only with TCF Purpose 1 and + Purpose 4 consent in `vendorData.purpose.consents`. That is the server's + rule for using or storing these EIDs (`allows_eid_persistence`, + `gate_eids_by_consent`), so the cookie never holds IDs no server path would + read. Otherwise, including GDPR data without a purpose record, it clears + the cookie and still forwards the EIDs in the body. Forwarding follows + Prebid's `transmitEids` decision, which any purpose from 2 to 10 satisfies, + so it establishes neither purpose. The check applies only when + `gdprApplies` is `true`: many CMPs answer outside the EU with + `gdprApplies: false` and an empty purpose record, and the server keeps + using browser EIDs for that traffic. +- **Why no EID-level `ext`.** It carries vendor data such as LiveIntent + `segments` and oneKey `preferences`, which should not persist for a day in a + script-readable cookie or reach body-less routes. A few hundred bytes of it + would also push whole sources out of 3,072 bytes and reduce KV ingestion. + Today's cookie has no EID-level `ext` either. The `/auction` body keeps it. + +### 4.4 Server: one parser for body and cookie + +`crates/trusted-server-core/src/ec/client_eids.rs`, new: + +```rust +/// Parses and validates browser-supplied EID entries from an `/auction` body +/// or a `ts-eids` cookie. +pub(crate) fn parse_client_eids(raw: &RawValue) -> Vec +``` + +1. It parses `raw` as `Vec>`. Anything other than an array + yields no entries. +2. It parses each element into a `serde_json::Value` on its own. An element + that fails, such as one holding a lone surrogate, is dropped. +3. It applies the rules below to each entry. +4. It drops entries equal to an earlier kept entry, comparing the validated + `Eid` values (`ext` compared as serde_json values). +5. It keeps the first 64 remaining entries, so a duplicate never takes a + place. + +Callers: + +- **Body.** `AdRequest.eids` becomes `Option>` + (`auction/formats.rs:46`), which needs serde_json's `raw_value` feature + (`Cargo.toml:105`). serde_json does not validate escapes while capturing a + raw value (`src/read.rs:1025-1040`), so a lone surrogate inside `eids` no + longer fails the body parse. `AdRequest` has no `flatten` or untagged enum, + which would break raw capture. `/auction` still treats an empty result as + absent. +- **Cookie.** The legacy flattened branch is still tried first + (`ec/prebid_eids.rs:90-92`). It now drops an entry whose `id` is blank or + longer than `MAX_UID_LENGTH`, or whose `source` is blank, the parser's UID + and blank-source rules, because the merge (4.5) lets any browser entry + displace KV's entry for its source. The structured branch base64-decodes the value, + captures it with `serde_json::from_slice::>`, and calls + `parse_client_eids`. A mistyped field or a bad entry now costs only itself; + previously it rejected the whole cookie. The body's caps now apply to the + cookie too. +- **Admin diagnostics** keep reporting raw sources and raw UID strings from the + decoded JSON, so `/_ts/admin/eids` can still say "no valid UID" for a + configured source (`ec/admin.rs:577-660`). + +| Field | Rule | On violation | Status | +| --------------------- | ----------------------------------------------------------- | ----------------------------- | ---------------------------------------------------- | +| entry | parses on its own, JSON object | entry dropped | parse isolation new | +| `source` | string, not blank, at most 255 UTF-8 bytes | entry dropped | existing (body); new for cookie | +| `uids` | array with at least one valid UID; first 32 valid UIDs kept | entry dropped when none valid | existing (body); new for cookie | +| `uids[].id` | string, not blank, at most 512 bytes | UID dropped | existing (body); new for cookie | +| `uids[].atype` | integer from 0 to 2,147,483,647 | field dropped | existing; integral floats such as `1.0` now accepted | +| `uids[].ext` | JSON object, at most 2,048 bytes as compact JSON | field dropped | cap new | +| `inserter`, `matcher` | string, not blank, at most 255 bytes | field dropped | new | +| `mm` | integer from 0 to 2,147,483,647 | field dropped | new | +| `ext` (EID level) | JSON object, at most 2,048 bytes as compact JSON | field dropped | new | +| duplicates | equal to an earlier kept entry | dropped, before the cap below | new | +| array | first 64 remaining entries kept | rest dropped | existing (body); new for cookie | +| unknown keys | ignored | | existing | +| order | preserved; a source may repeat | | existing | + +- **Blank** means empty or only whitespace by `str::trim`, as the body parser + checks today (`auction/endpoints.rs:510`). Text is kept as supplied. +- **Integer** means a JSON number with no fractional part, so `4`, `4.0` and + `4e0` are the same value, as they are in the browser. +- A blank `matcher` is dropped. OpenRTB 2.6 treats an absent `matcher` as equal + to `source`, so nothing is lost. +- `mm` is not checked against AdCOM's list. Vendor values from 500 up are + valid, and other integers are passed through. +- The limits move here from `auction/endpoints.rs:38-40`, with + `MAX_CLIENT_EID_EXT_BYTES = 2048` added. +- Each dropped entry or field logs at debug level with the field name and + source, never the value. +- `ec_context.set_client_eids` (`auction/endpoints.rs:304-306`) still carries + the `/auction` client set to finalization. +- The `raw_value` feature has one workspace-wide effect: a JSON object whose + first key is `"$serde_json::private::RawValue"` is read back as raw JSON + (`src/value/de.rs:131-135`). No real payload uses that key. +- The new module is added to the Fastly-SDK guard list in + `migration_guards.rs`. + +`Eid` (`openrtb.rs:67-75`) gains `inserter: Option`, +`matcher: Option`, `mm: Option` and `ext: Option`, each with +`#[serde(default, skip_serializing_if = "Option::is_none")]`. +`deny_unknown_fields` stays. A constructor that leaves the new fields empty +replaces the 26 existing struct literals in 9 files, all of which build EIDs +that never carry these fields. + +### 4.5 Merge + +The client set is the body, else the cookie, on `/auction`, and the cookie on +the navigation auction and `/_ts/page-bids`. + +| Concern | Rule | Status | +| ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------- | +| Precondition for client EIDs | EC present and allowed (`ec_id.is_some()`) | Unchanged | +| Browser entries | Forwarded as parsed (4.4), in wire order, never altered or combined | New; were folded by source | +| KV-resolved EIDs | Appended after the browser entries, one per registered source that no browser entry carries. Sources are compared as the partner registry normalizes them: lowercase, trailing dot removed (`ec/registry.rs:219-221`) | New; were listed first | +| KV values for a browser source | Not used. The browser's UIDs, `atype` and `ext` go out unchanged | New; KV used to win (D9) | +| KV entry fields | No EID-level fields | Unchanged | +| Entry with no UIDs | Dropped | Unchanged | +| Consent | `gate_eids_by_consent` on the merged set (TCF Purposes 1 and 4) | Unchanged | + +Examples, where `S` is `ids.example.com`, `T` is `other.example.com`, and `F1` +and `F2` are different sets of EID-level fields: + +| Case | Inputs | Output entries | +| ---- | -------------------------------------------------------------- | ----------------------------- | +| 1 | Browser `S F1 [u1]` | `S F1 [u1]` | +| 2 | KV `S [u1]` with `atype` 3; browser `S F1 [u1]` with `atype` 1 | `S F1 [u1]` with `atype` 1 | +| 3 | KV `S [u0]`; browser `S F1 [u1]` | `S F1 [u1]` | +| 4 | KV `T [t1]`; browser `S [u1]` | `S [u1]`, then `T [t1]` | +| 5 | Browser `S F1 [u1]`, then `S F2 [u1]` | `S F1 [u1]`, then `S F2 [u1]` | +| 6 | KV `S [u0]`; browser `IDS.Example.com [u1]` | `IDS.Example.com [u1]` | +| 7 | KV `S [u0]`, `T [t1]`; no browser EIDs | `S [u0]`, then `T [t1]` | + +Why: + +- The browser entries are exactly what Prebid sends every client adapter. + Prebid does not group `ortb2` entries, and its Prebid Server adapter sends + repeated sources (3.5). Current Prebid Server does not require unique + sources: `validateEIDs` only removes entries with an empty source and UIDs + with an empty id + ([source](https://raw.githubusercontent.com/prebid/prebid-server/master/endpoints/openrtb2/auction.go)). +- KV is the staler copy for a source the browser sends (D9). Keeping the two + kinds of data in separate entries means fields are never mixed. +- Section 8.3 adds a staging check that each deployed provider accepts + repeated sources and the new fields. + +### 4.6 KV ingestion + +- **Input.** Both finalization branches, for a returning EC + (`ec/finalize.rs:84-94`) and a newly generated one (`:132-150`), choose the + browser input by `ec_context.eid_sync_source()`. The Fastly adapter sets it + to `EidSyncSource::Auction` before `handle_auction` + (`trusted-server-adapter-fastly/src/app.rs:673`). + - `Auction`: only the request's client set (`ec_context.client_eids()`: the + body, else the cookie). When `/auction` sets none, as on the + auction-disabled early return (`auction/endpoints.rs:184-215`), nothing + from `ts-eids` is ingested. + - Any other source, or none: the `ts-eids` cookie. That covers navigation, + `/_ts/page-bids`, and new ECs generated on integration-proxy navigations, + which set no source (`integrations/registry.rs:1047-1050`). + - On every route: the `sharedId` cookie last, as today. + - Without usable consent, finalization returns before any ingestion + (`ec/finalize.rs:63-77`, `:446-457`), as today. +- **Why.** Today a cookie from an earlier page, possibly written by older TSJS + with restricted IDs, is ingested next to a scoped body, though the auction + itself ignores the cookie when the body is present. After the change KV on + `/auction` sees exactly what the auction forwarded. +- **Unchanged.** One UID per partner (the first valid UID; with repeated + sources the later entry wins, as `dedupe_partner_updates` does today), only + sources registered as `[[ec.partners]] source_domain`, the consent gate + (`gate_eid_updates_by_consent`), the fill-absent write rule + (`ec/kv.rs:145-155`, `:560-600`), and nothing new stored. Ingestion reads the + client set directly, not the merged output. +- **Write frequency.** A browser-sourced write happens only when KV holds no + UID for that partner; a different existing value is deferred, never + overwritten. So each registered partner is written about once per EC, on the + first response that carries it. +- **Where it runs.** Only adapters that call `ec_finalize_response` ingest. + That is the Fastly adapter today (`trusted-server-adapter-fastly/src/main.rs:606`). +- **#1246.** Lockr's `id5-sync.com` entry reaches the body and the cookie + (4.2, 4.3). With a matching `[[ec.partners]]` entry and EC consent, + finalization writes it to KV when KV holds no ID5 UID for that EC. Where TCF + enforcement is active and GDPR applies, this needs the publisher exception + in 3.7. + +### 4.7 Bidstream output and `x-ts-eids` + +- `Eid` serialization adds the new fields when present. All three outbound + paths (3.4) share the type and need no further change. +- Serializing a given EID set without the new fields is byte-identical to + today. The golden requests in `integrations/prebid.rs:9994`, `:10033` and + `integrations/aps.rs:2400` must not change. The merge itself does change + output when browser and KV EIDs mix (4.5, 8.2). +- A source can now appear in more than one entry (4.5). +- Whether Prebid Server and APS act on `inserter`, `matcher` and `mm` under + `user.ext.eids` is unknown. The keys are additive. A reader that does not + know them ignores them, unless it rejects unknown fields; the staging check + (8.3) covers that. +- **`x-ts-eids` stays (G7).** The `/auction` response carries the + consent-gated outbound set as base64 JSON (`auction/formats.rs:536-548`), + now including EID-level fields, so it shows exactly what each provider + received. It reaches its 4,096-byte cap (`ec/eids.rs:17`, `:104-140`) sooner + and sets `x-ts-eids-truncated` as today. TS lists it among internal headers + (`constants.rs:67-71`). The live check reads it (7.6). + +### 4.8 Privacy model + +By construction: + +- The browser's only input is `bidderRequest.ortb2` for `trustedServer`. Prebid + built it from global data, `trustedServer`'s own fragment and the module EIDs + `trustedServer` may receive, then applied whichever filters in 3.5 the bundle + contains. Generated bundles contain the consent modules and `tcfControl` but + no US-state activity controls (`docs/guide/integrations/prebid.md:820-823`), + so US-state opt-outs stay enforced by the server gate below, as today. +- Where TCF enforcement is active and GDPR applies, `trustedServer` gets no bid + request and no EIDs without a publisher exception (3.7), and the cookie is + cleared (4.3). Nothing falls back to `getUserIdsAsEids()`. +- The cookie holds only the latest `trustedServer` request's set, minus + EID-level `ext`. +- KV ingestion on `/auction` uses only the auction's own client set (4.6). +- Server-side bidders folded into `trustedServer` (`index.ts:2392-2480`): IDs a + publisher restricts to such a bidder, through `userIds[].bidders` or + `setBidderConfig`, land in that bidder's fragment, which `trustedServer` + never sees. They are no longer forwarded at all. Today `getUserIdsAsEids()` + forwards them to every provider. Per-bidder forwarding through Prebid Server + `eidpermissions` is a follow-up (8.5). + +Never forwarded or persisted **from the browser**: + +- EIDs from `userIds[]` entries restricted to other bidders. +- Other bidders' `setBidderConfig` data. +- EIDs Prebid redacted for `trustedServer`. +- UID values in logs or metrics. Logs carry source names and counts only. + +Server gates, unchanged: + +- Client EIDs are used only with an allowed EC (US opt-out and GPC included). +- `gate_eids_by_consent` strips bidstream EIDs without TCF Purposes 1 and 4. +- KV writes go through `gate_eid_updates_by_consent`. + +Residual exposure: + +1. **The cookie outlives the page.** It is a script-readable, one-day copy of + the latest `trustedServer` set. Body-less routes on later pages forward it + before Prebid runs there. The server re-checks only the EC and TCF purposes, + so a later change in Prebid's other decisions (bidder restrictions, + per-vendor consent, browser US-state rules) applies only once the next + `trustedServer` request rewrites or clears it. Accepted to keep browser IDs + on body-less routes (O1). +2. **The cookie now holds `ortb2` EIDs, gated on Purposes 1 and 4 but not + on a vendor.** IDs any vendor or the publisher put into Prebid's `ortb2` for + `trustedServer`, and their provenance fields, now persist in it. Today it + holds only User ID module output, which passed Prebid's Purpose 1 + `enrichEids` rule (`modules/tcfControl.ts:285`). An `ortb2` EID needs only + what `transmitEids` requires for `trustedServer`: under TCF, a purpose from + 2 to 10 with vendor consent or a soft exception. The shim therefore checks + Purposes 1 and 4 itself before storing, as the server does before using + the cookie (4.3). It cannot check vendor consent, + because `trustedServer` has no GVL ID; that waits for TS's TCF identity + (8.5). +3. **`x-ts-eids` is visible to the page.** The `/auction` response shows the + consent-gated outbound set, including KV-only partner IDs. Any page script + that wraps `fetch` can read it. Kept for troubleshooting (G7). +4. **KV fills sources Prebid withheld.** KV adds every registered source the + browser did not send, including sources Prebid withheld from + `trustedServer` (restricted, redacted by `transmitEids`, vendor-denied). The + server gate checks purposes only. Unchanged from today, where KV EIDs are + always added. +5. A cookie written by older TSJS may hold restricted IDs until the next + `trustedServer` request rewrites or clears it. +6. IDs that older TSJS already ingested into KV stay there. KV forwarding of a + registered partner follows operator config (`bidstream_enabled`). +7. `sharedId` cookie ingestion ignores Prebid restrictions, as today (O10). + +Validation only ever drops data, so it cannot widen what is forwarded. + +### 4.9 Timing model + +| Where the ID comes from | Present in `bidderRequest.ortb2` | In the first auction? | +| ---------------------------------------------------------------------------- | ----------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | +| User ID module with a stored value (sharedId, stored TDID) | At module initialization | Yes | +| User ID module that calls its vendor (criteo, LiveIntent, ID5, identityLink) | When its callback resolves | Only if calls started at init (`auctionDelay > 0`) and resolved within `auctionDelay` | +| `setConfig` / `mergeConfig({ortb2})`, `setBidderConfig` | From the next `requestBids` after the call | Only if called before the first `requestBids` | +| `requestBids({ortb2})` | That auction only | Yes, for that auction | +| RTD module | Each auction, within the RTD module's own delay | Per RTD config | +| Vendor writing `ortb2` after its own network call (Lockr) | From the next auction after the write | Usually not. After its first `/auction`, a registered partner reaches later requests via the cookie and KV | + +**Change: delete `installUserIdModules()`** (`index.ts:2881-2898`) and its +load hook (`index.ts:3020-3033`). TS then writes no `userSync` values other +than managed `userIds` and, on a late seed, `autoRefresh`. Managed seeding +still builds its config from the effective `userSync` (`index.ts:2238-2256`) +and adds its entries, which carries the publisher's values or Prebid's +defaults through unchanged. The late seed's `autoRefresh: true` +(`index.ts:2248-2256`) stays: it is what initializes managed modules added +after Prebid's first pass. Because `userSync` settings accumulate, it remains +`true` for the rest of the page even though the code means to restore the +publisher's policy. That is unchanged by this work and listed in 8.2 and 8.5. + +Effects: + +- The userId module runs with the publisher's `auctionDelay`, or Prebid's + 500 ms default (`src/userSync.ts:59-70`), whenever it initializes, including + after `window.load`. Vendor calls start at initialization, and each auction + waits up to that delay, but only while IDs are pending. Once all modules + settle, later auctions do not wait. A module that never settles makes every + auction wait the full delay; that is Prebid's behavior for any publisher. +- Auctions after `window.load`, such as refreshes and lazy-loaded units, wait + for pending IDs again instead of running with `0`. +- TS no longer overwrites the publisher's `syncEnabled`, `filterSettings`, + `syncsPerBidder` or `syncDelay`. It no longer force-enables iframe user syncs + for every bidder; Prebid's default allows image syncs only. +- No new configuration. Publishers tune `auctionDelay` and syncs in their own + Prebid config, where every other Prebid setting lives. + +D4's cause on the live page is likely, not proven. The timing arm in 7.5 +proves the mechanism: it must fail against `main` and pass with the change, +next to a control arm that passes on both. The live check (7.6, steps 1 and 2) +decides whether it was the live cause. If `main`'s first auction there already +ran with a delay above 0 when the module initialized, the report says that D4 +on that page had another cause, instead of claiming a fix. + +### 4.10 Configuration + +None added, changed or removed. `MAX_AUCTION_EIDS_BYTES` (TSJS) and the server +limits (4.4) are constants, not operator settings. `openrtb_atype` now applies +only where KV supplies a source the browser did not send (4.5). + +### 4.11 Failure modes + +| Condition | Behavior | +| ----------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `bidderRequest` missing or not an object | No `eids` in the body, cookie unchanged, debug log | +| `ortb2.user.ext.eids` and `user.eids` absent or not arrays | No `eids` in the body; cookie cleared before the request | +| An entry that cannot be serialized in the browser | Entry dropped in TSJS | +| EIDs over 64 KiB as UTF-8 | Later entries dropped in TSJS | +| Cookie payload does not fit or cannot be encoded | Cookie cleared | +| An entry that does not parse on the server (lone surrogate) | Entry dropped, in body or cookie; the request proceeds | +| `eids` or the cookie JSON not an array | No client EIDs from it; the request proceeds | +| Malformed entry or field, in body or cookie | Entry or field dropped, others kept | +| More than 64 entries or 32 UIDs | First 64 entries and first 32 UIDs kept | +| `ts-eids` undecodable or over 8 KiB | Ignored, as today | +| Lone surrogate outside `eids` | Request rejected, as today (non-goal) | +| `/auction` body over 256 KiB | Rejected, as today. EIDs alone cannot cause it | +| userId module not bundled | No effect on collection; `ortb2` EIDs still flow and are written to the cookie | +| External bundle failed to load | `installPrebidNpm` bails out as today (`index.ts:1994-2000`); no adapter, no EIDs, cookie unchanged | +| External bundle with a baked-in old shim | The old shim runs: it sends `{source, uids}` from `getUserIdsAsEids()` and writes the cookie from it, both parsed by the new parser. Fixed by regenerating the bundle | +| Prebid's TCF rules block `trustedServer` bids | No `/auction` from the adapter; the listener clears the cookie; body-less routes get KV EIDs only | +| Prebid's TCF rules block only `trustedServer`'s EIDs | `/auction` is sent without EIDs; the cookie is cleared before it and by the listener | +| KV unavailable | Client EIDs still reach the auction; KV EIDs and ingestion skipped, as today | +| Consent denied | Client EIDs unused without an allowed EC; bidstream EIDs stripped without TCF Purposes 1 and 4; KV writes gated | +| A User ID module never settles | Each auction waits the full `auctionDelay`, as in Prebid without TS | +| A provider rejects repeated sources or the new fields | That provider's request fails. Caught by the staging check before rollout (8.3) | + +### 4.12 Observability + +- TSJS, debug level, in `buildRequests`: entries forwarded, dropped as + unserializable, dropped for the budget, and the cookie decision (written, + trimmed, cleared). Never values. The existing trim log stays. +- TSJS, debug level: the cookie cleared for a TCF block. +- Rust, debug level: each dropped entry or field, with field name and source. +- `x-ts-eids` on the `/auction` response shows the consent-gated outbound set, + EID-level fields included (4.7). +- `/_ts/admin/eids` serializes `Eid`, so the cookie's new fields appear in its + output without code changes, alongside the raw diagnostics. +- Auction telemetry is unchanged. + +### 4.13 Adapters + +All four adapters (Fastly, Cloudflare, Spin, Axum) route `/auction`, +`/_ts/page-bids`, publisher auctions and `/_ts/admin/eids` into +`trusted-server-core`, where parsing, merging and output live. The Fastly +adapter keeps reading the raw `ts-eids` cookie before routing (`app.rs:420`, +`:740`) and passing it as a string to `ec_finalize_response` (`main.rs:606`). +No adapter code changes. Doc comments that describe the old writer or the old +ingestion order, such as `collect_eid_updates`'s (`ec/prebid_eids.rs:130-137`), +are updated. Nothing new reads the clock, so the Wasm clock lint is +unaffected. The parity test proves routing on all four adapters; EID handling +is covered by core and Fastly adapter tests (7.4). + +## 5. Alternatives considered + +1. **Keep `getUserIdsAsEids()` and filter by `userIds[].bidders` in TSJS.** + Rejected. It reimplements Prebid's scoping, misses `setBidderConfig`, + per-call `ortb2`, RTD output and every activity filter in 3.5, and breaks + when Prebid changes `idPriority` or restriction logic. +2. **Merge the `ortb2` config with `getUserIdsAsEids()` (the staged + `fix/1246` attempt).** Rejected. It keeps D1, bypasses redaction and bidder + data, and duplicates what `bidderRequest.ortb2` already holds. +3. **Read EIDs from Prebid events such as `bidRequested`.** Rejected. Events + fire after the request is built, so they cannot feed it. +4. **A TS Prebid hook or module reading `ortb2Fragments`.** Rejected. That data + is pre-redaction, so TS would have to reimplement activity controls. +5. **Delete `ts-eids` (the design pass's recommendation).** Rejected by the + author. The product needs IDs in a first-party cookie as well as KV, with + the cookie read first for speed, and browser IDs on body-less routes. +6. **A server-written, `HttpOnly`, short-lived cookie set from `/auction`.** + Deferred. It would remove the script exposure and shorten the stale window + (4.8, residual 1). It fits server-side ID provider work, which would also + write the cookie from the server. +7. **Write the cookie in `bidsBackHandler`, keyed by auction (an earlier + draft).** Rejected. Prebid hides `auctionId` from adapters by default + (D5), so the copy would never be written. +8. **A per-EC KV snapshot of the last client set.** Rejected. A write per + auction contends with ingestion and pull-sync writes on the same row or + costs another read, is eventually consistent across POPs, and stores + unregistered sources server-side. +9. **Validate in both TSJS and Rust under a shared fixture (an earlier + draft).** Rejected. The server validates both transports anyway, and two + implementations disagree on numbers, whitespace and equality. +10. **A TSJS-only lone-surrogate rule (an earlier draft).** Rejected. It does + not protect against old TSJS, baked-in shims or other clients. Isolating + entries on the server does. +11. **Group by source and EID-level fields, union KV UIDs and let KV's `atype` + win (an earlier draft).** Rejected. KV is the staler copy (D9), and the + union mixes two kinds of data in one entry. +12. **One entry per source.** Rejected. It drops or misattributes provenance: + 51Degrees splits `51d.es` by `mm`, and several sources come from more than + one module (3.5). +13. **An operator `user_id_auction_delay_ms` applied through `setConfig` and + `mergeConfig` wrappers (an earlier draft).** Rejected. TS's own override + is the likely cause of D4, and deleting it is simpler than managing it. +14. **Register `trustedServer` as `VENDORLESS_GVLID` in this work.** Deferred + to its own spec. It moves the vendor check for TS-routed vendors out of + Prebid, so it needs a server-side per-provider vendor check and legal + review first. Prebid's EID check also never passes for a vendorless module + without a soft exception (`modules/tcfControl.ts:295-307`), which needs an + upstream fix. +15. **When TCF blocks `trustedServer`, write the cookie from + `getUserIdsAsEids()` as today.** Rejected. It forwards exactly the EIDs + Prebid's consent rules withheld. +16. **When TCF blocks `trustedServer`, leave the cookie unchanged.** Rejected. + A cookie written before consent changed would live for its full day. +17. **Bump to Prebid 11 in this work.** Rejected. 11.43 is a pre-release, and + the EID pipeline, `auctionDelay` handling and `getUserIdsAsEids()` are the + same (3.5). +18. **A `tsjs.addEids` API and server-side vendor cookie reads.** Deferred. + They have their own contracts and are not needed for #1246. + +## 6. Decisions + +| # | Decision | Reason | +| --- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| O1 | Keep `ts-eids` as a browser-written copy of the `/auction` body set, minus EID-level `ext`, written or cleared in `buildRequests` (4.3) | Product requirement: IDs in a first-party cookie and KV, cookie read first. Writing in `buildRequests` needs no auction key and copies exactly what was sent | +| O2 | Validate on the server only, with one parser that isolates each entry of the body or the cookie (4.4). TSJS forwards Prebid's arrays as plain-data copies within a 64 KiB UTF-8 budget (4.2) | One implementation. A bad entry costs only itself, whichever client or transport sent it | +| O3 | Forward browser entries as sent; KV adds entries only for registered sources the browser did not send (4.5) | Fidelity to what Prebid sends; KV is the staler copy (D9); no mixed fields. Guarded by the staging check (8.3) | +| O4 | The staged `fix/1246` work in the main checkout is superseded and not committed | It reads `getUserIdsAsEids()` and the `ortb2` config, so it keeps D1 | +| O5 | Fix D4 by deleting `installUserIdModules()`; no new config (4.9) | TS's own `auctionDelay: 0` is the likely cause. Prebid's and the publisher's settings already cover the need | +| O6 | Delete `resolvePrebidUserIdModulesFromEids` and the `eidSources` registry field (D6) | Only tests read them, and the data cannot be made correct | +| O7 | KV ingestion on `/auction` reads only the auction's own client set; other routes read the cookie; `sharedId` as today (4.6) | KV must not ingest IDs the auction did not use | +| O8 | Prove D1, D3, D4 and the TCF behavior with real Prebid in the CI suites that load the generated bundle and the shim in JSDOM (7.5). The local Viceroy harness is not landed | Those suites already run in CI, so a regression fails every later pull request. Round 2 found a bug only real Prebid shows | +| O9 | Under TCF, fail closed: clear the cookie on a `tcf2Enforcement` block, never fall back to `getUserIdsAsEids()`, and document the full exception rule (3.7) | TS receives only what Prebid gives `trustedServer`. Granting an exception is the publisher's call, and a partial rule would switch off TCF checks for every bidder | +| O10 | `sharedId` cookie ingestion stays as is; a follow-up issue covers it (8.5) | Author's decision, to keep this change focused. Recorded as residual exposure 7 (4.8) | + +## 7. Testing strategy + +### 7.1 Guarding against false passes + +A test that cannot fail proves nothing. Every test this work adds or changes +follows these rules, and the implementation plan checks each one. + +1. **Red first.** Each new test is run against the unchanged code and must + fail for the expected reason. A regression net that passes before the change + is proven by temporarily reverting the line it guards. The plan records the + failing output for every test. +2. **Absence is paired with presence.** Every assertion that something is + missing (a restricted ID, a cookie-only KV source, a canary) sits next to an + assertion in the same request, cookie or output that a control value is + there. An empty body cannot pass an absence check. +3. **A canary for D1 in every adapter test.** The hoisted `getUserIdsAsEids` + mock (`test/integrations/prebid/index.test.ts:260`) returns a canary entry + (`canary.example.com`) for the whole file instead of `[]`. The four + describe-level `beforeEach` blocks that reset it to `[]` (`:580-581`, + `:3363-3364`, `:4465-4466`, `:7552-7553`) restore the canary instead. The + tests that set other values (among them `:2421`-`:2565` and + `:3164`-`:3346`) encode the old collector and are deleted or rewritten. + - **Capture point.** Tests call `buildRequests` only through a helper that + records each returned request body, and the file's `fetch` mock records + every body sent to `/auction`. + - **Checks.** A shared `afterEach` first asserts that the mock still returns + the canary, so a test that switched it off fails. It then fails if the + canary appears in any recorded body or in `ts-eids`. + - **Non-vacuous.** An `afterAll` asserts that at least one body was + recorded, and a positive control asserts once that the mock is the + function the shim's `pbjs` exposes. +4. **Exact equality.** EID payloads are compared whole: `toEqual` in Vitest and + full `serde_json::Value` equality in Rust. No `objectContaining`, + `toMatchObject`, `.contains()` or length-only checks, which let extra or + leaked entries through. +5. **The cookie environment is checked, not assumed.** jsdom silently drops a + `Secure` cookie on a non-HTTPS URL other than `localhost` (verified with the + repo's jsdom: kept at `http://localhost:3000/` and `https://example.com/`, + dropped at `http://example.com/`). A "cleared" or "unchanged" assertion + would then pass without testing anything. Each suite that checks the cookie + asserts in `beforeAll` that a cookie written with the shim's attributes + reads back, and "cleared" and "unchanged" tests preset the cookie and read + it back before acting. +6. **Prebid's real call shapes.** Unit tests call + `buildRequests(validBidRequests, bidderRequest)` with a frozen + `bidderRequest.ortb2` and `auctionId: null`, as Prebid's proxy passes it by + default (3.2 step 5). No code path or test relies on `auctionId`. +7. **Inputs whose JSON spelling matters are raw text.** Rust parser tests use + raw JSON for `4.0`, `4e0`, `-0`, values just above 2,147,483,647 and above + 2^53, multibyte text at exactly 255 and 256 bytes, an `ext` at exactly 2,048 + and 2,049 bytes, 65 entries of which one is a duplicate, and lone surrogate + escapes in `source`, `id`, `inserter`, `matcher`, and in the keys and values + of EID-level and UID-level `ext`. `json!` cannot express a number's spelling + or a lone surrogate, so this is an intended exception to the `AGENTS.md` + preference for `json!`. +8. **Two cross-language fixtures, tested end to end.** In + `crates/trusted-server-js/lib/test/fixtures/`: + - `auction-body-eids.txt` holds the exact body TSJS produces for a fixed + input: one entry with a lone surrogate in a UID `id` between two good + entries, one of which has non-Latin-1 text in `inserter` and a UID + `ext`. The surrogate sits in a UID `id` because the cookie writer keeps + it there; in an EID-level `ext` it would be stripped from the cookie. + - `ts-eids-cookie.txt` holds the exact cookie value TSJS writes for the same + input. + + The Vitest tests assert that TSJS output equals each file. Rust tests read + them with `include_str!` and send each through `handle_auction` with an + allowed EC: the body as the request body, the cookie as a `Cookie` header + with no body EIDs. Both assert that the good entries reach + `auction_request.user.eids` unchanged, the bad entry is dropped, and the + request is not rejected. Calling `parse_client_eids` alone would pass even + if `AdRequest.eids` stayed a `Value` and the full parse still failed. The + `.txt` extension keeps Prettier from rewriting the files (`package.json:15` + checks `ts`, `tsx`, `js`, `json`, `css` and `md`). A core test already reads + a sibling crate's file this way (`settings.rs:8475`). + +9. **Rust literals list every field.** Tests that assert EID-level fields build + `Eid` with all fields written out, never `..Default::default()`, so a future + field breaks them at compile time instead of defaulting silently. +10. **Logs are proven, then checked.** Logging tests set debug level, assert the + expected log was emitted, then assert no argument contains a UID value. +11. **KV ingestion is tested in two layers**, because the Fastly adapter's test + router cannot observe a KV write: finalization builds a real Fastly store + with no injection point, and the test settings have no EC store. + - **Adapter layer.** A Fastly adapter test sends a real `Cookie` header and + asserts what the response's `EcFinalizeState` carries: the raw + `ts-eids` value and the sync source, as `eid_sync_source_of` does + (`app.rs:2291-2298`). This proves the adapter's cookie extraction. The + client set is left to the core layer, since setting it needs an allowed + EC, which no adapter test arranges. + - **Core layer.** Core tests drive `ec_finalize_response` with an + in-memory KV, as + `auction_body_eids_reach_kv_even_when_the_ts_eids_cookie_is_absent` + does (`auction/endpoints.rs:807`): + - `/auction` with a `ts-eids` cookie holding registered source A and a + body holding registered source B ingests B and not A. + - `/auction` on the auction-disabled early return, with an allowed EC + and the same cookie, ingests nothing from it, while a `sharedId` + cookie in the same request is ingested. + - A new EC generated with no sync source ingests the cookie's + registered source. + - A navigation response with that cookie ingests A, which proves the + registry matches A. +12. **Real Prebid backs the mocks.** Vitest unit tests can only check what a + mocked `pbjs` receives. The arms in 7.5 run in the JSDOM suites that load + the real generated bundle and the real shim and capture `/auction` bodies + (`test/prebid-artifact-integration.test.mjs:236-280`, + `test/prebid-consent-enforcement.test.mjs`). They run in CI. + +### 7.2 TSJS unit (Vitest), `test/integrations/prebid/eids.test.ts`, new + +- `collectBidderEids`: `user.ext.eids` only, `user.eids` only, both in that + order, neither, non-array shapes, a frozen input, a `bidderRequest` that is + not an object. +- The plain-data copy: `undefined` provenance fields removed; a circular `ext` + and a `BigInt` value each drop their entry and keep the others. +- The budget: a set that fits exactly is kept whole; one byte over drops the + last entry; a multibyte set whose JavaScript length fits but whose UTF-8 + size does not is cut; order and repeated sources are preserved. +- Lone surrogates are forwarded unchanged (rule 8 fixtures). +- Shapes from 3.5: a 51Degrees-style `user.eids` set with one `51d.es` entry + per `mm`, openPair with `undefined` `inserter`, LiveIntent with + `ext.segments`, oneKey with `ext.preferences`, PAIR's `atype: 571187`. +- Cookie writer: EID-level `ext` removed and the duplicates it creates dropped; + today's trim cases unchanged; a last entry that is `null`, a string, or an + object without a `uids` array removed whole; UTF-8 encoding of non-Latin-1 text that round-trips through `atob` and + `TextDecoder`; clearing when nothing fits. + +### 7.3 TSJS adapter (Vitest), `test/integrations/prebid/index.test.ts` + +- `buildRequests` forwards `bidderRequest.ortb2` EIDs with EID-level fields, + and never calls `getUserIdsAsEids` or `getConfig('ortb2')`. The canary + (rule 3) covers every test. +- After each `buildRequests`, the cookie equals that request's body set without + EID-level `ext`, after trimming. It is cleared before the request when the + set is empty. It is unchanged when an auction built no `trustedServer` + request. With two overlapping auctions, the later `buildRequests` wins. +- TCF (O9): a `tcf2Enforcement` event that lists `trustedServer` in + `biddersBlocked`, and one that lists it in `eidsBlocked`, each clear a preset + cookie. An event that lists only another bidder leaves it unchanged. A + positive control proves the shim registered the listener. +- Without the userId module, `ortb2` EIDs still flow and are written to the + cookie. +- `installUserIdModules` is gone: after `window.load`, and with + `document.readyState === 'complete'` at install, TS writes no `userSync` + values other than managed `userIds` and, on a late seed, `autoRefresh`. The + seeded config's `auctionDelay`, `syncDelay` and `filterSettings` equal the + effective config's. +- `tsjs.requestAds` sends no `eids`, as today. + +### 7.4 Rust unit + +- `ec/client_eids.rs`: every row of the 4.4 table, valid and invalid, with the + raw inputs of rule 7; input that is not an array; one unparsable entry among + good ones; duplicates removed before the 64-entry cap; dropped entries and + fields logged without values. +- `ec/prebid_eids.rs`: legacy cookies; structured cookies with and without the + new fields; a mistyped field, now dropped instead of rejecting the cookie; a + bad entry, now isolated; the caps; diagnostics that still report raw UIDs. +- `auction/endpoints.rs`: the rule 8 fixtures through `handle_auction`; body + fields reach `auction_request.user.eids`; body wins over cookie; cookie used + when the body has no EIDs. +- Merge: every example in 4.5, each asserted on the full output. The three + existing merge tests (`auction/endpoints.rs:1656-1745`) get the new + expectations. +- `publisher.rs`: navigation and page-bids auctions merge the cookie's entries + first and KV-only sources after (4.5, cases 3 and 4). +- Fastly adapter and `ec/finalize.rs`: the two layers of rule 11. +- `auction/formats.rs`: `x-ts-eids` carries the new fields and a repeated + source, decoded and compared in full. +- Serialization: the new fields appear in all three outbound paths when + present, and a repeated source serializes as two entries; existing golden + JSON stays byte-identical. +- `migration_guards.rs` includes `ec/client_eids.rs`. + +### 7.5 Real Prebid in CI (O8) + +These arms run in the JSDOM suites named in rule 12, which build the real +external bundle and evaluate it with the shim in a page window. Every arm must +fail against `main` (rule 1). The suites need this harness work, which the plan +does first: + +- One harness with both the consent suite's `__tcfapi` stub and the artifact + suite's `/auction` body capture, by extending one suite or sharing helpers. +- Bundles that add `unifiedIdSystem` and `identityLinkIdSystem`, next to a + client bidder with a GVL ID such as `appnexus`. +- A `tcData` fixture that can grant Purpose 2 and per-vendor consents. +- `TextEncoder` and `TextDecoder` on the page window if jsdom lacks them, as + the harness already copies `Request`, `Headers`, `Response` and + `AbortController`. +- Control of `window.load`. The page's load is held by a sub-resource the + suite's request interceptor leaves pending, and the test releases it. Each + arm asserts `document.readyState` at every step, so an arm cannot silently + run in the wrong order. +- Delayed vendor responses inside the `fetch` spy installed before the bundle + is evaluated. Prebid's `ajax` uses `fetch` and binds `window.fetch` at + evaluation (`src/ajax.ts:8-9`), so a spy installed later is never called. + +- **Restriction (D1).** A sharedId entry restricted to another bidder, with a + preset storage cookie, next to an unrestricted `ortb2` control. The arm first + asserts that `pbjs.getUserIdsAsEids()` contains the restricted source, which + proves the module produced it. Then the control is present and the + restricted source is absent in the `/auction` body and `ts-eids`. +- **Fields (D3).** An `ortb2` entry with `inserter` and `mm`, plus a second + entry for the same source with different fields: both reach the body as + separate entries with their own fields, and the cookie holds both without + EID-level `ext`. +- **Timing (D4).** No CMP, so TCF plays no part. A fixed order of events: + 1. The page loads with no User ID config, and the test releases + `window.load` (`readyState` is `complete`). + 2. The publisher calls `setConfig({userSync: {userIds: [unifiedId]}})`. + unifiedId's `params.url` points at the `fetch` spy, which answers after + 200 ms, and it has no `storage` param. + 3. The test asserts `pbjs.getConfig('userSync').auctionDelay` and calls + `requestBids` while the unifiedId response is still pending. + + With the change, the delay is Prebid's 500 ms and the first `/auction` body + carries `adserver.org`. Against `main`, the delay is `0`, the module defers + its vendor call until after the first auction, and the body does not carry + it. + + A control arm sets the same config and runs the first auction before + releasing `window.load`. It must carry `adserver.org` on both builds, which + proves the spy and the module work. + +- **TCF (O9).** A `__tcfapi` stub reports `gdprApplies: true` with Purposes 1 + to 4 consented. Vendor consent is granted to identityLink (GVL 97) only, not + to the control bidder. The page has: + - identityLink with a preset stored envelope, so it produces a + `liveramp.com` EID at once; + - an `ortb2` control EID; + - a client bidder with a GVL ID as the control bidder; + - a preset `ts-eids`. + + Arms: + - **Without an exception,** `pbjs.getEvents()` holds a `tcf2Enforcement` + event listing `trustedServer` (the positive control), no `/auction` + request is sent at all, and the cookie is cleared. Against `main` this arm fails: identityLink's + vendor consent lets `getUserIdsAsEids()` return `liveramp.com`, and + `main`'s `bidsBackHandler` rewrites the cookie with it. + - **With the full rule from 3.7** in the page's + `consentManagement.gdpr.rules`, `/auction` carries the control `ortb2` EID, + and the control bidder is still listed in `biddersBlocked`, which proves + the rule did not switch off enforcement for other bidders. Against `main` + this arm fails because `main` never reads `ortb2`. + +### 7.6 Manual live check + +Through `ts dev proxy` on a page with Lockr and the default User ID preset. The +user runs the snippets in DevTools. Report only what was observed. + +1. On a `main` build, record `pbjs.getConfig('userSync').auctionDelay` before + the first `requestBids` and after `window.load`, when the CMP answered, and + the sources of the first `/auction` body. +2. On the branch, record the same. Expect the publisher's or Prebid's delay + throughout, and asynchronous User ID sources (criteo, LiveIntent) in the + first body when they resolve within it. Lockr's ID5 may still be missing + from the first auction (4.9). +3. Decode an `/auction` response's `x-ts-eids` and the cookie with + `JSON.parse(new TextDecoder().decode(Uint8Array.from(atob(value), (c) => c.charCodeAt(0))))`. + The header's browser entries should equal the request body's, with their + own `inserter`, `matcher` and `mm`, followed by KV-only sources. The cookie + should equal the body without EID-level `ext`, subject to the trim. +4. `/_ts/admin/eids` (authenticated) lists the cookie's `adserver.org` entries + with their own fields. +5. Compare `pbjs.getEvents()` `bidRequested` events for `trustedServer` with + the `/auction` bodies: the entries should match. + +### 7.7 CI gates + +This change touches JS and core, so all ten gates in `AGENTS.md` apply: +`cargo fmt`, all clippy aliases, `cargo test-fastly`, +`cargo test-fastly-reuse`, `cargo test-axum`, `cargo test-cloudflare`, +`cargo test-spin`, the parity test, Vitest, JS format, docs format, Markdown +format outside `docs/`, the native build-digest test and lint, and the Wasm +clock lint. CLI tests run too, because the CLI reads `user_id_modules.json`. + +## 8. Rollout + +### 8.1 Ordering + +TSJS is compiled into the server binary, so JS and Rust ship together. +Compatibility with older TSJS, servers and cookies was not a constraint. The +change ships as one pull request, in this commit order so each step is +reviewable: + +1. Rust: `Eid` fields and constructor, `raw_value`, `ec/client_eids.rs` for + body and cookie. +2. Rust: the merge. +3. Rust: KV input selection. +4. TSJS: the collector, the cookie writer in `buildRequests`, the TCF listener, + removal of the old collector and `bidsBackHandler` cookie sync. +5. TSJS: deletion of `installUserIdModules()`. +6. Registry: deletion of the resolver and `eidSources` (O6). +7. Docs. + +### 8.2 What breaks + +| Who | Change | +| ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Bidders, when browser and KV EIDs mix | Browser entries come first, KV-only sources after. For a source the browser sends, KV's value and `atype` are no longer sent. Repeated sources are no longer folded into one entry | +| Publishers | TS stops overwriting `syncEnabled`, `filterSettings`, `syncsPerBidder`, `syncDelay` and `auctionDelay`. Iframe syncs are no longer force-enabled. Auctions wait up to `auctionDelay` (500 ms by default) while IDs are pending, including after `window.load`. Unchanged: a late managed seed still leaves `autoRefresh: true` for the page | +| Server-side bidders folded into `trustedServer` | IDs restricted to them are no longer forwarded (4.8) | +| GDPR traffic without a `trustedServer` exception | Already no `trustedServer` demand. The cookie is now cleared instead of written from `getUserIdsAsEids()`, so body-less routes and KV ingestion lose browser EIDs for that traffic, including the IdentityLink flow | +| Cookie contents | Holds `ortb2` EIDs and provenance fields, written only with TCF Purpose 1 and 4 consent where GDPR applies (4.8); never EID-level `ext`. Written at request time, not at auction end | +| KV ingestion on `/auction` | No longer reads the cookie next to the body | +| Providers | Additive fields and possibly repeated sources | +| `/_ts/admin/eids` consumers | New fields in the cookie entries. A mistyped field is dropped instead of failing the whole cookie | +| Old TSJS, baked-in shims, rolled-back servers | Old TSJS's body and cookie are parsed by the new parser. A rolled-back server reads only `source` and `uids` from new bodies and cookies | + +### 8.3 Operator actions + +- Before production, send one request with repeated sources and all four + fields to each configured provider in staging and confirm it is accepted. +- Regenerate external bundles that carry a baked-in shim (`ts prebid client`). +- Tell publishers about the `userSync` change: set `auctionDelay` and + `filterSettings` in their own Prebid config if Prebid's defaults do not suit + them. +- For sites with GDPR traffic, decide with the publisher whether their Prebid + config grants `trustedServer` a soft exception, using only the full rule in + 3.7. A rule without `enforcePurpose: true` and `enforceVendor: true` + switches off TCF checks for every bidder. Publishers that set + `eidsRequireP4Consent` need the full `personalizedAds` rule too. The release + notes say so. + +### 8.4 Overlap with draft PR #1382 + +#1382 moves `ec/prebid_eids.rs` into `crates/auction/prebid/src/eids.rs` and +feeds core through an `IdentifierModule`. The shared parser lives in core +(`ec/client_eids.rs`). If #1382 lands first, `parse_client_eids` must become +`pub` so the Prebid crate can call it. Whichever lands second rebases; the +cookie decoder change is small. + +### 8.5 Follow-ups + +- Prebid.js upstream fix for D7: guard both `JSON.parse` calls in + `modules/identityLinkIdSystem.js` and fall back to the LiveRamp endpoint. +- `sharedId` cookie ingestion ignores Prebid's restrictions (O10). +- TS's TCF identity, as its own spec: a server-side per-provider vendor check + (`TcfConsent::has_vendor_consent`, `consent/types.rs:251`, has no production + callers), then registering `trustedServer` as `VENDORLESS_GVLID`, then an + upstream fix so Prebid's EID check accepts vendorless modules + (`modules/tcfControl.ts:295-307`). +- `trustedServer` gets no client-side bid requests where GDPR applies unless + the publisher grants an exception (3.7). This predates this work and affects + demand, not only EIDs. +- Per-bidder EID forwarding for server-side bidders folded into + `trustedServer`, through Prebid Server `eidpermissions` (4.8). +- Refresh rules for browser-sourced partner IDs in KV, which today keeps the + first value forever (identity redesign). +- Restore the publisher's `autoRefresh` after a late managed seed; today it + stays `true` for the page because `userSync` settings accumulate (4.9). +- Add a vendor-level storage check to the `ts-eids` write (4.8, residual 2), + together with TS's TCF identity. The Purpose 1 and 4 check ships with this + work. +- A server-written cookie (alternative 6), with server-side ID provider work. +- A lenient parse of the rest of the `/auction` body. + +### 8.6 Documentation to update + +- `docs/guide/integrations/prebid.md`: User ID modules and timing (TS no longer + sets `userSync`), identity forwarding (which still names + `getUserIdsAsEids()`), the IdentityLink flow, merge rules (which promise UIDs + deduplicated by source and id), and a TCF section with the full exception + rule, its warning, and the `personalizedAds` rule for publishers that set + `eidsRequireP4Consent`. +- `docs/guide/integrations/lockr.md`: it describes a `contribute_eids()` path + that does not exist and omits the `[[ec.partners]]` `id5-sync.com` setup and + the TCF exception. +- `docs/guide/edge-cookies.md` and `docs/guide/ec-setup-guide.md`: the + `ts-eids` writer and contents, and KV ingestion inputs. +- `docs/guide/api-reference.md`: the `/auction` body `eids` fields and + per-entry dropping, `x-ts-eids` contents, and the `/_ts/admin/eids` section + (`:902-936`). +- `docs/guide/configuration.md`: `openrtb_atype` applies only where KV supplies + a source the browser did not send. + +## 9. Acceptance criteria + +- **A1.** With a `userIds` entry restricted to `bidders: ['otherBidder']`, its + EID appears in neither the `/auction` body nor `ts-eids`, while a control EID + in the same request does (7.3, 7.5). The canary never appears in any adapter + test (7.1, rule 3). +- **A2.** An EID set with `setConfig({ortb2: {user: {ext: {eids}}}})` appears + in the `/auction` body, `ts-eids` and the provider request. With a matching + `[[ec.partners]]` entry and EC consent, finalization writes it to KV (7.4). +- **A3.** Every case in 4.5 holds, asserted on the full output. Two browser + entries for one source with different EID-level fields reach the provider as + two entries with their own fields. +- **A4.** The rule 8 fixtures pass through `handle_auction` as body and as + cookie: the good entries arrive, the bad entry is dropped, the request is + accepted. Every malformed field in 4.4 drops only itself, in body and cookie. +- **A5.** Serializing an EID set without the new fields is byte-identical to + today (golden tests). +- **A6.** TS writes no `userSync` values other than managed `userIds` and, on a + late seed, `autoRefresh`. In the timing arm, the first auction carries the delayed unifiedId with the change + and not against `main`, and the control arm carries it on both (7.5). +- **A7.** After each `buildRequests`, `ts-eids` equals that body set without + EID-level `ext`, after trimming. It is cleared when the set is empty or + Prebid's TCF rules block `trustedServer`, and unchanged when an auction built + no `trustedServer` request (7.3, 7.5). +- **A8.** On `/auction` with body EIDs, a registered source present only in + `ts-eids` is not written to KV, while the body's source is. An `/auction` + that sets no client set ingests nothing from the cookie. A navigation + response ingests the cookie's registered source (7.1, rule 11). +- **A9.** EIDs over 64 KiB as UTF-8 are cut from the tail in TSJS, including a + multibyte set that fits by JavaScript length (7.2). +- **A10.** Where TCF enforcement is active, GDPR applies and `trustedServer` + has no exception, no client EIDs reach TS and the cookie is cleared, where + `main` rewrites it from a vendor-consented module. With the full rule from + 3.7 the control `ortb2` EID reaches `/auction`, and a control bidder with a + GVL ID stays blocked (7.5). +- **A11.** `x-ts-eids` carries the consent-gated outbound set with its + EID-level fields (7.4). +- **A12.** Every new test was seen failing first (7.1, rule 1), and the plan + records the output. +- **A13.** All gates in 7.7 pass, and the staging provider check in 8.3 passes + before production rollout. + +## Review log + +- Round 1 (Claude Code, Opus 5.5, fresh session): two blocking findings. + `trustedServer` has no GVL ID, so under TCF Prebid blocks it and the cookie + would never be refreshed for that traffic; added 3.7 and O9. The D1 + real-Prebid case could pass without the restricted module running; it now + first proves the source exists in `pbjs.getUserIdsAsEids()`. Should-fix items + addressed: merge wording, precise validation rules, Prettier-safe fixtures, a + lone-surrogate test that can fail, canary resets, test isolation, the GPT + refresh claim, the bundled-filter list, EID-level `ext` out of the cookie. +- Round 2 (Claude Code, Sonnet 5.5, fresh session): one blocking finding. An + earlier draft keyed its cookie copy on `bidderRequest.auctionId`, which + Prebid hides from adapters by default, so the cookie would never be written, + and mock tests would have passed anyway. The cookie is now written in + `buildRequests` (O1), and rule 6 forbids relying on `auctionId`. Also + addressed: UID `ext` cap and the EID budget (G5), folded server-side bidders + (4.8, 8.2), a Rust test for a TSJS-written cookie (rule 8). +- Design pass (Claude Code, Opus 5.5, fresh session, told that backwards + compatibility is not a constraint): recommended server-only validation with + per-entry isolation, a browser-wins merge, deleting `installUserIdModules()`, + deleting the dead resolver, moving TS's TCF identity out, and deleting + `ts-eids`. The author adopted all but two. `sharedId` ingestion stays a + follow-up (O10). `ts-eids` stays, because the product requires IDs in a + first-party cookie as well as KV, with the cookie read first (O1). + `x-ts-eids` stays for validation and troubleshooting (G7). +- Round 3 (Claude Code, Opus 5.5, fresh session, reviewing the version without + the cookie): three blocking findings. + - Prebid's `userSync` settings accumulate, so `installUserIdModules()` sets + `auctionDelay: 0` for every later auction, and it never dropped `userIds` + or `ppid`. D4, 3.3, 4.9 and 8.2 were rewritten. + - The timing arm could not tell `main` from the branch. It now runs a fixed + order of events with a control arm. + - The recommended TCF exception, written as a partial rule, switches off TCF + checks for every bidder. 3.7, 7.5 and 8.3 now give the full rule and test + that other bidders stay blocked. + + Should-fix items addressed: + - Real-Prebid arms moved into the CI JSDOM suites (O8). + - Budget measured in UTF-8 bytes. + - Privacy model qualified, with `x-ts-eids` and KV fill as residual exposures. + - Byte-identity limited to serialization. + - Vacuous-pass gaps closed: the canary capture point, ingestion arms through + the Fastly adapter, fixtures through `handle_auction`. + - Managed seeding expectations corrected. + - Missing docs added. + + All nits fixed. Findings about deleting the cookie are moot now that the + cookie stays. + +- Round 4 (Claude Code, Sonnet 5.5, fresh session): two blocking findings. + - The TCF arm without an exception also passed against `main`, because with + no vendor consents `main` clears the cookie too. Vendor consent now goes to + identityLink, so `main` rewrites the cookie and the arm fails there. + - The KV ingestion arms could not be built in the Fastly adapter's test + router, which cannot observe a KV write. Rule 11 now splits them: the + adapter test checks what `EcFinalizeState` carries, and core tests drive + `ec_finalize_response` with an in-memory KV. + + Should-fix items addressed: + - D4 and 3.3 corrected. A late managed seed refreshes at once, so its vendor + calls start but no auction waits. Only a publisher config after load + defers vendor calls, which is what the timing arm now uses. + - 7.5 lists the harness work: control of `window.load` with `readyState` + checks, a combined CMP and body-capture harness, the bundle modules, + Purpose 2 in `tcData`, `TextEncoder`, and delays inside the `fetch` spy. + - `/auction` without a client set ingests nothing from the cookie (4.6). + - The cookie write's missing Purpose 1 check is a residual exposure and a + follow-up. + + Nits fixed: trim wording and the non-object case, the literal count, the + `from_value` limit for raw values. + +- Round 5 (Claude Code, Opus 5.5, fresh session): no blocking findings. Its + should-fix items are addressed: + - TS's late-seed `autoRefresh` write is an explicit exception in 4.9, 7.3, + A6 and 8.2, with a follow-up to restore the publisher's value. + - The adapter-layer ingestion test no longer asserts the client set, which + needs an allowed EC. + - The core ingestion test names the auction-disabled path. 4.6 now says + that without usable consent nothing is ingested, and that new ECs and + routes with no sync source keep the cookie as input. + - 3.7, 8.3 and 8.6 cover publishers that set `eidsRequireP4Consent`. + - D4 and 4.9 say that 7.5 proves the mechanism and 7.6 decides the live + cause. + + Nits fixed: + - The `from_value` claim is removed. + - The no-exception TCF arm asserts that no `/auction` request is sent, with + the event as the positive control. + - The fixture puts its lone surrogate in a UID `id`. + - 4.11 splits bid blocking from EID blocking. + - Two line references are corrected. + - 8.4 notes that `parse_client_eids` must become `pub` if #1382 lands first. + + This round meets the `CONTRIBUTING.md` bar: a round with nothing blocking.