diff --git a/crates/trusted-server-core/src/auction/endpoints.rs b/crates/trusted-server-core/src/auction/endpoints.rs index ab3585e3d..f36446456 100644 --- a/crates/trusted-server-core/src/auction/endpoints.rs +++ b/crates/trusted-server-core/src/auction/endpoints.rs @@ -298,6 +298,12 @@ pub async fn handle_auction( } else { None }; + // Carry the full request-local EID set to response finalization so KV + // ingestion uses what this request actually sent, not just whatever fits + // in the size-capped `ts-eids` cookie (see `ec::finalize::ec_finalize_response`). + if let Some(eids) = &client_eids { + ec_context.set_client_eids(eids.clone()); + } // Resolve partner EIDs from the KV identity graph when the user has a valid // EC and both KV and partner stores are available. Gate the read on a @@ -797,6 +803,99 @@ mod tests { ); } + #[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 + // request body, but response finalization used to ingest identity + // graph updates only from the `ts-eids` cookie — which the browser + // caps in size and may not have sent at all. `handle_auction` must + // hand its parsed body EIDs to `ec_context` so finalization ingests + // them regardless of what the cookie carried. + 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 = PartnerRegistry::from_config(&[counting_test_partner("id5-sync.com")]) + .expect("should build partner registry"); + + let graph = KvIdentityGraph::in_memory("test_store"); + let ec_id = format!("{}.eidbdy", "a".repeat(64)); + let mut live = crate::ec::kv_types::KvEntry::tombstone(1000); + live.consent.ok = true; + graph.create(&ec_id, &live).expect("should seed live row"); + + let mut ec_context = make_ec_context(Jurisdiction::NonRegulated, Some(&ec_id)); + ec_context.set_eid_sync_source(crate::ec::EidSyncSource::Auction); + let req = Request::builder() + .method("POST") + .uri("https://test-publisher.com/auction") + .body(EdgeBody::from( + serde_json::to_vec(&json!({ + "adUnits": [ + { + "code": "div-gpt-ad-1", + "mediaTypes": { "banner": { "sizes": [[300, 250]] } } + } + ], + "eids": [ + {"source": "id5-sync.com", "uids": [{"id": "ID5_from_body", "atype": 1}]} + ] + })) + .expect("should serialize body"), + )) + .expect("should build auction request"); + + // The capturing provider deliberately fails its launch; identity + // resolution — the subject of this test — completes before dispatch. + let _ = handle_auction( + &settings, + &orchestrator, + Some(&graph), + Some(®istry), + &mut ec_context, + &noop_services(), + req, + ) + .await; + + assert_eq!( + ec_context.client_eids().map(|eids| eids + .iter() + .map(|eid| eid.source.as_str()) + .collect::>()), + Some(vec!["id5-sync.com"]), + "the endpoint must hand its parsed body EIDs to the request context" + ); + + let mut response = http::Response::new(EdgeBody::empty()); + crate::ec::finalize::ec_finalize_response( + &settings, + &mut ec_context, + Some(&graph), + ®istry, + None, // No `ts-eids` cookie on this request at all. + None, + &mut response, + ); + + let (stored, _) = graph + .get(&ec_id) + .expect("should read store") + .expect("row should exist"); + assert_eq!( + stored.ids.get("id5-sync.com").map(|id| id.uid.as_str()), + Some("ID5_from_body"), + "the body's EID must be ingested into KV without a ts-eids cookie" + ); + } + /// Provider that fails the test if it is ever contacted. Used to prove the /// `/auction` consent gate short-circuits before any outbound bid request. struct PanicOnBidProvider; diff --git a/crates/trusted-server-core/src/consent/mod.rs b/crates/trusted-server-core/src/consent/mod.rs index ba77366ca..76aa6ad85 100644 --- a/crates/trusted-server-core/src/consent/mod.rs +++ b/crates/trusted-server-core/src/consent/mod.rs @@ -476,6 +476,29 @@ pub fn gate_eids_by_consent( } } +/// Returns whether consent allows EIDs to be written to the identity graph. +/// +/// Applies the same decision as [`gate_eids_by_consent`] for a present +/// [`ConsentContext`], without logging: the effective TCF consent (standalone +/// TC string or GPP EU TCF section) must grant Purpose 1 (storage/access) +/// **and** Purpose 4 (personalized ads). With no TCF data, EIDs are allowed +/// only when GDPR does not apply. +/// +/// A TCF signal means TCF rules apply. In decode mode a TC signal always sets +/// `gdpr_applies`, so checking the decoded TCF first is equivalent to checking +/// `gdpr_applies` first. In [`ConsentMode::Proxy`] the TC string is not +/// decoded, so a request carrying a TC cookie has no effective TCF and GDPR +/// applies: EID writes are withheld even when Purpose 4 is granted. This +/// fails closed, matching how [`gate_eids_by_consent`] strips egress EIDs in +/// proxy mode. +#[must_use] +pub(crate) fn allows_eid_persistence(ctx: &ConsentContext) -> bool { + match effective_tcf(ctx) { + Some(tcf) => allows_eid_transmission(tcf), + None => !ctx.gdpr_applies, + } +} + // --------------------------------------------------------------------------- // EC consent gating // --------------------------------------------------------------------------- @@ -659,7 +682,7 @@ mod tests { use http::Request; use super::{ - ConsentPipelineInput, allows_ec_creation, apply_expiration_check, + ConsentPipelineInput, allows_ec_creation, allows_eid_persistence, apply_expiration_check, apply_tcf_conflict_resolution, build_consent_context, build_context_from_signals, consent_allows_server_side_auction, gate_eids_by_consent, has_explicit_ec_withdrawal, }; @@ -762,6 +785,94 @@ mod tests { } } + #[test] + fn eid_persistence_matches_gate_eids_by_consent() { + let gpp_only = |allows_eids: bool| GppConsent { + version: 1, + section_ids: vec![2], + eu_tcf: Some(make_tcf(0, allows_eids)), + us_sale_opt_out: None, + }; + let cases = [ + ( + "Purpose 1 + 4 granted", + ConsentContext { + gdpr_applies: true, + tcf: Some(make_tcf(0, true)), + ..ConsentContext::default() + }, + true, + ), + ( + "Purpose 1 only", + ConsentContext { + gdpr_applies: true, + tcf: Some(make_tcf(0, false)), + ..ConsentContext::default() + }, + false, + ), + ( + "Purpose 4 only", + ConsentContext { + gdpr_applies: true, + tcf: Some( + TcfBuilder::new() + .with_storage(false) + .with_personalized_ads(true) + .build(), + ), + ..ConsentContext::default() + }, + false, + ), + ( + "GPP EU TCF section grants Purpose 1 + 4", + ConsentContext { + gdpr_applies: true, + gpp: Some(gpp_only(true)), + ..ConsentContext::default() + }, + true, + ), + ( + "GPP EU TCF section denies Purpose 4", + ConsentContext { + gdpr_applies: true, + gpp: Some(gpp_only(false)), + ..ConsentContext::default() + }, + false, + ), + ( + "GDPR applies without TCF", + ConsentContext { + gdpr_applies: true, + ..ConsentContext::default() + }, + false, + ), + ( + "GDPR does not apply without TCF", + ConsentContext::default(), + true, + ), + ]; + + for (label, ctx, expected) in cases { + assert_eq!( + allows_eid_persistence(&ctx), + expected, + "should decide EID persistence for case: {label}" + ); + assert_eq!( + gate_eids_by_consent(Some(vec![1_u8]), Some(&ctx)).is_some(), + expected, + "should match gate_eids_by_consent for case: {label}" + ); + } + } + #[test] fn auction_allowed_for_known_non_gdpr_jurisdiction_without_tcf_signal() { let ctx = ConsentContext { diff --git a/crates/trusted-server-core/src/ec/admin.rs b/crates/trusted-server-core/src/ec/admin.rs index 67b13c4b6..e21fab702 100644 --- a/crates/trusted-server-core/src/ec/admin.rs +++ b/crates/trusted-server-core/src/ec/admin.rs @@ -600,8 +600,10 @@ pub fn handle_admin_eids_lookup( }; // Collect matches from both cookies, then dedupe the same way as response - // finalization so the preview reports exactly what an eligible request - // would store. + // finalization. The preview is cookie-only and ungated: it ignores the + // `/auction` request-body EIDs, the consent gate on identity-graph writes, + // and UIDs already stored in KV, so it lists candidate matches rather than + // exactly what a request would store. if let Some(value) = &sharedid_cookie && let Some(update) = collect_sharedid_update(value, registry) { diff --git a/crates/trusted-server-core/src/ec/finalize.rs b/crates/trusted-server-core/src/ec/finalize.rs index 2c5bbde09..33125f0ab 100644 --- a/crates/trusted-server-core/src/ec/finalize.rs +++ b/crates/trusted-server-core/src/ec/finalize.rs @@ -9,6 +9,7 @@ use edgezero_core::body::Body as EdgeBody; use http::Response; use super::consent::{ec_consent_granted, ec_consent_withdrawn}; +use crate::consent::{ConsentContext, allows_eid_persistence}; use crate::settings::Settings; use super::EcContext; @@ -19,7 +20,7 @@ use super::kv::{ apply_partner_id_updates, }; use super::kv_types::KvEntry; -use super::prebid_eids::collect_eid_cookie_updates; +use super::prebid_eids::collect_eid_updates; use super::pull_sync_marker::{expire_marker, reconcile_marker}; use super::registry::PartnerRegistry; use super::{EcKvSnapshot, EidSyncSource, current_timestamp, log_id}; @@ -78,10 +79,37 @@ pub fn ec_finalize_response( if ec_context.ec_was_present() && !ec_context.ec_generated() && consent_allows_ec { if let (Some(graph), Some(ec_id)) = (kv, ec_context.ec_value().map(str::to_owned)) { let source = ec_context.eid_sync_source(); - let updates = source - .map(|_| collect_eid_cookie_updates(eids_cookie, sharedid_cookie, registry)) + let collected = source + .map(|_| { + collect_eid_updates( + eids_cookie, + sharedid_cookie, + ec_context.client_eids(), + registry, + ) + }) .unwrap_or_default(); - if let Some(source) = source { + let had_eid_updates = !collected.is_empty(); + let updates = gate_eid_updates_by_consent(collected, ec_context.consent()); + // `sync_eid_cookie_updates` early-returns without refreshing the + // snapshot when `updates` is empty, which is correct when there + // was never anything to write (an unconfigured registry, no sync + // source, or no EID cookies/body this request — the case + // `finalize_not_read_snapshot_does_not_rotate` covers). But when + // consent gating (e.g. TCF Purpose 4 denial) is what emptied + // `updates`, the request *did* have EID data, and orphan recovery + // below still needs an actual `Missing` read to detect an + // orphaned cookie. An unread `NotRead` snapshot proves nothing, so + // consent gating must not also skip that one read on a + // recovery-eligible request. + if updates.is_empty() + && had_eid_updates + && ec_context.recovery_eligible() + && matches!(ec_context.kv_snapshot(), EcKvSnapshot::NotRead) + { + let snapshot = graph.load_snapshot(&ec_id); + ec_context.set_kv_snapshot(snapshot); + } else if let Some(source) = source { sync_eid_cookie_updates(graph, ec_context, &ec_id, &updates, source); } if matches!(ec_context.kv_snapshot(), EcKvSnapshot::Missing { .. }) @@ -110,7 +138,15 @@ pub fn ec_finalize_response( return; }; - let updates = collect_eid_cookie_updates(eids_cookie, sharedid_cookie, registry); + let updates = gate_eid_updates_by_consent( + collect_eid_updates( + eids_cookie, + sharedid_cookie, + ec_context.client_eids(), + registry, + ), + ec_context.consent(), + ); sync_eid_cookie_updates(graph, ec_context, &ec_id, &updates, EidSyncSource::NewEc); if ec_context.kv_snapshot().entry_for(&ec_id).is_some() { set_ec_cookie_on_response(settings, ec_context, response); @@ -206,6 +242,32 @@ fn reconcile_pull_sync_marker( ); } +/// Withholds EID-derived KV updates when consent does not allow EID +/// persistence, on top of the Purpose 1 (EC) gate `ec_finalize_response` +/// already enforces before reaching this point. +/// +/// `consent_allows_ec` only requires Purpose 1 (device storage), but EIDs +/// additionally require Purpose 4 (personalized ads) — the same rule +/// [`gate_eids_by_consent`](crate::consent::gate_eids_by_consent) applies to +/// the outbound `/auction` bid request, evaluated here through +/// [`allows_eid_persistence`]. Without this, a user who denies Purpose 4 +/// would have their EIDs correctly stripped from the bid request but still +/// written to the identity graph from the `ts-eids`/`sharedId` cookies or the +/// `/auction` request body. +fn gate_eid_updates_by_consent( + updates: Vec, + consent: &ConsentContext, +) -> Vec { + if updates.is_empty() || allows_eid_persistence(consent) { + return updates; + } + log::debug!( + "EC KV: withholding {} EID updates, EID consent (TCF Purpose 1 + 4) missing", + updates.len() + ); + Vec::new() +} + fn recover_orphaned_ec( settings: &Settings, ec_context: &mut EcContext, @@ -464,9 +526,12 @@ where mod tests { use http::HeaderValue; + use base64::Engine as _; + use super::*; use crate::consent::jurisdiction::Jurisdiction; - use crate::consent::types::{ConsentContext, ConsentSource}; + use crate::consent::types::{ConsentContext, ConsentSource, TcfConsent}; + use crate::openrtb::{Eid, Uid}; use crate::redacted::Redacted; use crate::settings::EcPartner; use crate::test_support::tests::create_test_settings; @@ -1027,6 +1092,102 @@ mod tests { ); } + /// GDPR consent granting TCF Purpose 1 (storage/EC) but denying Purpose 4 + /// (personalized ads), so EC is allowed while EID persistence is not. + fn purpose_one_only_consent() -> ConsentContext { + ConsentContext { + jurisdiction: Jurisdiction::Gdpr, + gdpr_applies: true, + tcf: Some(TcfConsent { + version: 2, + cmp_id: 1, + cmp_version: 1, + consent_screen: 0, + consent_language: "EN".to_owned(), + vendor_list_version: 1, + tcf_policy_version: 4, + created_ds: 0, + last_updated_ds: 0, + // Purpose 1 (index 0) granted; Purpose 4 (index 3) denied. + purpose_consents: { + let mut purposes = vec![false; 24]; + purposes[0] = true; + purposes + }, + purpose_legitimate_interests: vec![false; 24], + vendor_consents: Vec::new(), + vendor_legitimate_interests: Vec::new(), + special_feature_opt_ins: vec![false; 12], + }), + source: ConsentSource::Cookie, + ..Default::default() + } + } + + #[test] + fn finalize_recovers_orphaned_ec_when_purpose_four_denial_empties_updates() { + // Regression test: this request has real EID data (a configured + // partner and a captured client EID), but TCF Purpose 4 denial gates + // it away, emptying the update list. That must not also suppress the + // snapshot refresh that orphan recovery depends on. Without a + // preloaded snapshot (a non-GET publisher navigation never calls + // `should_preload_ec_snapshot`), the context starts at `NotRead`; only + // an actual KV read can prove the row is missing and let recovery run. + // + // This is distinct from `finalize_not_read_snapshot_does_not_rotate`, + // which covers a request with no EID data at all (nothing gated it + // away) and must still not rotate. + let settings = create_test_settings(); + let orphaned_ec = sample_ec_id("orphn2"); + let purpose1_only_consent = purpose_one_only_consent(); + let mut ec_context = EcContext::new_for_test_with_ip( + Some(orphaned_ec.clone()), + purpose1_only_consent, + Some("192.0.2.11".to_owned()), + ); + 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 mut response = empty_response(); + + ec_finalize_response( + &settings, + &mut ec_context, + Some(&graph), + ®istry, + None, + None, + &mut response, + ); + + let replacement = ec_context.ec_value().expect( + "should rotate the orphan even though Purpose 4 denial emptied the gated update list", + ); + assert_ne!(replacement, &orphaned_ec); + let (stored, _) = graph + .get(replacement) + .expect("should read replacement") + .expect("replacement cookie should have a backing row"); + assert!( + get_header(&response, "set-cookie").is_some(), + "should emit replacement cookie after persistence" + ); + assert!( + !stored.ids.contains_key("id5-sync.com"), + "denied Purpose 4 EID must not be persisted even on the recovered row" + ); + } + #[test] fn valid_marker_with_unread_snapshot_defers_orphan_recovery() { let settings = create_test_settings(); @@ -1352,6 +1513,153 @@ mod tests { ); } + #[test] + fn finalize_persists_every_configured_partner_from_client_eids_over_a_trimmed_cookie() { + // Regression test for #1184: the `ts-eids` cookie only carries what + // the browser could fit under its size cap, but `/auction` also + // hands finalization the full EID set from the request body via + // `EcContext::set_client_eids`. That full set must land in KV even + // when the cookie alone would have dropped a configured partner. + let settings = create_test_settings(); + let ec_id = sample_ec_id("cleids1"); + 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 the live row this request updates"); + let mut ec_context = returning_user_context( + &ec_id, + EcKvSnapshot::Missing { + ec_id: ec_id.clone(), + }, + false, + ); + ec_context.set_eid_sync_source(EidSyncSource::Auction); + // Only `id5-sync.com` "fit" in the (simulated) trimmed cookie; + // `liveramp.com` was dropped by the browser's size cap. + let eids_cookie = base64::engine::general_purpose::STANDARD.encode( + serde_json::to_vec(&serde_json::json!([ + {"source": "id5-sync.com", "uids": [{"id": "ID5_from_cookie", "atype": 1}]} + ])) + .expect("should serialize test cookie payload"), + ); + ec_context.set_client_eids(vec![ + Eid { + source: "id5-sync.com".to_owned(), + uids: vec![Uid { + id: "ID5_from_body".to_owned(), + atype: Some(1), + ext: None, + }], + }, + Eid { + source: "liveramp.com".to_owned(), + uids: 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"); + 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 exist after ingestion"); + assert_eq!( + stored.ids.get("id5-sync.com").map(|id| id.uid.as_str()), + Some("ID5_from_body"), + "the request body's EID should win over the cookie's stale value" + ); + assert_eq!( + stored.ids.get("liveramp.com").map(|id| id.uid.as_str()), + Some("LR_from_body"), + "a partner the cookie trimmed must still be ingested from the body" + ); + } + + #[test] + fn finalize_withholds_eid_kv_writes_when_purpose_four_is_denied() { + // A GDPR user can grant TCF Purpose 1 (storage/EC) while denying + // Purpose 4 (personalized ads). `gate_eids_by_consent` already strips + // EIDs from the outbound /auction bid request in that case; the KV + // write path must apply the same Purpose 4 check, or a user's opt-out + // is silently ignored for what gets persisted to the identity graph. + let settings = create_test_settings(); + let ec_id = sample_ec_id("purp4x"); + let graph = KvIdentityGraph::in_memory("test_store"); + let purpose1_only_consent = purpose_one_only_consent(); + let live = KvEntry::new( + &purpose1_only_consent, + None, + current_timestamp(), + &settings.publisher.domain, + ); + graph + .create(&ec_id, &live) + .expect("should seed the live row this request updates"); + let mut ec_context = EcContext::new_for_test_with_cookie( + Some(ec_id.clone()), + Some(ec_id.clone()), + true, + false, + purpose1_only_consent, + ); + ec_context.set_kv_snapshot(EcKvSnapshot::Missing { + 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 { + 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(); + + ec_finalize_response( + &settings, + &mut ec_context, + Some(&graph), + ®istry, + None, + None, + &mut response, + ); + + let (stored, _) = graph + .get(&ec_id) + .expect("should read store") + .expect("row should remain"); + assert!( + !stored.ids.contains_key("id5-sync.com"), + "denying TCF Purpose 4 must keep the EID out of KV even though Purpose 1 \ + (EC) consent is granted" + ); + } + #[test] fn finalize_auction_confirmed_miss_does_not_create_a_row() { // The same path with a genuinely absent row must stay a no-op: a route diff --git a/crates/trusted-server-core/src/ec/mod.rs b/crates/trusted-server-core/src/ec/mod.rs index 16fd016ea..31a57fece 100644 --- a/crates/trusted-server-core/src/ec/mod.rs +++ b/crates/trusted-server-core/src/ec/mod.rs @@ -78,6 +78,7 @@ use crate::cookies::handle_request_cookies; use crate::ec::cookies::ec_id_has_only_allowed_chars; use crate::error::TrustedServerError; use crate::geo::GeoInfo; +use crate::openrtb::Eid; use crate::platform::RuntimeServices; use crate::settings::Settings; use device::DeviceSignals; @@ -271,6 +272,12 @@ pub struct EcContext { kv_snapshot: EcKvSnapshot, /// Whether this request may rotate an orphaned EC identity. recovery_eligible: bool, + /// EIDs the route resolved from the current request: the `/auction` JSON + /// body, or the `ts-eids` cookie when the body carried none. Carried to + /// response finalization so KV ingestion can use the full set the client + /// sent instead of being limited to whatever fits in the size-capped + /// `ts-eids` cookie. + client_eids: Option>, /// Browser-carried proof of recent pull-partner completeness. pull_sync_marker: PullSyncMarkerState, /// Allowed returning-user EID persistence source, assigned only after request filters pass. @@ -354,6 +361,7 @@ impl EcContext { device_signals: None, kv_snapshot: EcKvSnapshot::NotRead, recovery_eligible: false, + client_eids: None, pull_sync_marker: PullSyncMarkerState::from_cookie(parsed.pull_sync_marker), eid_sync_source: None, }) @@ -556,6 +564,18 @@ impl EcContext { self.recovery_eligible } + /// Records the current request's own EIDs (e.g. an `/auction` body) for + /// use by response finalization's KV ingestion. + pub(crate) fn set_client_eids(&mut self, eids: Vec) { + self.client_eids = Some(eids); + } + + /// Returns the current request's own EIDs, if the route captured any. + #[must_use] + pub(crate) fn client_eids(&self) -> Option<&[Eid]> { + self.client_eids.as_deref() + } + /// Validates a browser completeness marker against the active EC and partner set. pub(crate) fn validate_pull_sync_marker( &mut self, @@ -642,6 +662,7 @@ impl EcContext { device_signals: None, kv_snapshot: EcKvSnapshot::NotRead, recovery_eligible: false, + client_eids: None, pull_sync_marker: PullSyncMarkerState::Absent, eid_sync_source: None, } @@ -666,6 +687,7 @@ impl EcContext { device_signals: None, kv_snapshot: EcKvSnapshot::NotRead, recovery_eligible: false, + client_eids: None, pull_sync_marker: PullSyncMarkerState::Absent, eid_sync_source: None, } @@ -693,6 +715,7 @@ impl EcContext { device_signals: None, kv_snapshot: EcKvSnapshot::NotRead, recovery_eligible: false, + client_eids: None, pull_sync_marker: PullSyncMarkerState::Absent, eid_sync_source: None, } diff --git a/crates/trusted-server-core/src/ec/prebid_eids.rs b/crates/trusted-server-core/src/ec/prebid_eids.rs index c4e95bc4e..d823cb32f 100644 --- a/crates/trusted-server-core/src/ec/prebid_eids.rs +++ b/crates/trusted-server-core/src/ec/prebid_eids.rs @@ -127,9 +127,18 @@ impl DecodedCookieEids { } /// Collects validated request-local partner updates without performing KV I/O. -pub(crate) fn collect_eid_cookie_updates( +/// +/// `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. +pub(crate) fn collect_eid_updates( eids_cookie: Option<&str>, sharedid_cookie: Option<&str>, + body_eids: Option<&[Eid]>, registry: &PartnerRegistry, ) -> Vec { if registry.is_empty() { @@ -140,6 +149,9 @@ pub(crate) fn collect_eid_cookie_updates( if let Some(cookie) = eids_cookie { updates.extend(collect_prebid_eid_updates(cookie, registry)); } + if let Some(eids) = body_eids { + updates.extend(collect_prebid_eid_updates_from_eids(eids, registry)); + } if let Some(cookie) = sharedid_cookie && let Some(update) = collect_sharedid_update(cookie, registry) { @@ -596,13 +608,13 @@ mod tests { } #[test] - fn collect_eid_cookie_updates_merges_prebid_and_sharedid_without_kv() { + fn collect_eid_updates_merges_prebid_and_sharedid_without_kv() { let registry = make_registry(vec![("id5", "id5-sync.com"), ("sharedid", "sharedid.org")]); let eids_cookie = encode_json(&json!([ {"source": "id5-sync.com", "uids": [{"id": "ID5_abc", "atype": 1}]} ])); - let updates = collect_eid_cookie_updates(Some(&eids_cookie), Some(" shared-1 "), ®istry); + let updates = collect_eid_updates(Some(&eids_cookie), Some(" shared-1 "), None, ®istry); assert_eq!( updates.len(), @@ -614,13 +626,56 @@ mod tests { } #[test] - fn collect_eid_cookie_updates_empty_registry_returns_no_updates() { + fn collect_eid_updates_body_eids_ingest_partners_the_cookie_trimmed() { + // Simulates a `ts-eids` cookie that the browser trimmed to fit + // `MAX_EID_COOKIE_BYTES`, dropping the `liveramp.com` source, while + // the `/auction` request body still carried it (and the newer, + // updated `id5-sync.com` uid) in full. + let registry = make_registry(vec![("id5", "id5-sync.com"), ("liveramp", "liveramp.com")]); + let eids_cookie = encode_json(&json!([ + {"source": "id5-sync.com", "uids": [{"id": "ID5_stale", "atype": 1}]} + ])); + let body_eids = vec![ + Eid { + source: "id5-sync.com".to_owned(), + uids: vec![Uid { + id: "ID5_fresh".to_owned(), + atype: Some(1), + ext: None, + }], + }, + Eid { + source: "liveramp.com".to_owned(), + uids: 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); + + assert_eq!( + updates, + vec![ + PartnerIdUpdate::new("id5-sync.com", "ID5_fresh"), + PartnerIdUpdate::new("liveramp.com", "LR_xyz"), + ], + "body EIDs should ingest every configured partner present in the \ + request, including the one the cookie trimmed, and should win \ + over a stale cookie value for a partner both carry" + ); + } + + #[test] + fn collect_eid_updates_empty_registry_returns_no_updates() { let registry = PartnerRegistry::empty(); let eids_cookie = encode_json(&json!([ {"source": "id5-sync.com", "uids": [{"id": "ID5_abc", "atype": 1}]} ])); - let updates = collect_eid_cookie_updates(Some(&eids_cookie), Some("shared-1"), ®istry); + let updates = collect_eid_updates(Some(&eids_cookie), Some("shared-1"), None, ®istry); assert!( updates.is_empty(), @@ -650,7 +705,7 @@ mod tests { } #[test] - fn collect_eid_cookie_updates_combines_cookie_sources() { + fn collect_eid_updates_combines_cookie_sources() { let registry = make_registry(vec![ ("id5", "id5-sync.com"), ("liveramp", "liveramp.com"), @@ -661,8 +716,7 @@ mod tests { {"source": "liveramp.com", "uids": [{"id": "LR_xyz", "atype": 3}]} ])); - let updates = - collect_eid_cookie_updates(Some(&cookie), Some("shared-cookie-id"), ®istry); + let updates = collect_eid_updates(Some(&cookie), Some("shared-cookie-id"), None, ®istry); assert_eq!( updates, @@ -685,7 +739,7 @@ mod tests { } ])); - let updates = collect_eid_cookie_updates(Some(&cookie), None, ®istry); + let updates = collect_eid_updates(Some(&cookie), None, None, ®istry); assert_eq!( updates, @@ -695,13 +749,13 @@ mod tests { } #[test] - fn collect_eid_cookie_updates_prefers_direct_sharedid_cookie() { + fn collect_eid_updates_prefers_direct_sharedid_cookie() { let registry = make_registry(vec![("sharedid", "sharedid.org")]); let cookie = encode_json(&json!([ {"source": "sharedid.org", "uids": [{"id": "prebid-shared", "atype": 3}]} ])); - let updates = collect_eid_cookie_updates(Some(&cookie), Some("cookie-shared"), ®istry); + let updates = collect_eid_updates(Some(&cookie), Some("cookie-shared"), None, ®istry); assert_eq!( updates, @@ -711,13 +765,13 @@ mod tests { } #[test] - fn collect_eid_cookie_updates_ignores_unknown_sources() { + fn collect_eid_updates_ignores_unknown_sources() { let registry = make_registry(vec![("id5", "id5-sync.com")]); let cookie = encode_json(&json!([ {"source": "unknown.example", "uids": [{"id": "unknown", "atype": 1}]} ])); - let updates = collect_eid_cookie_updates(Some(&cookie), None, ®istry); + let updates = collect_eid_updates(Some(&cookie), None, None, ®istry); assert!( updates.is_empty(), diff --git a/crates/trusted-server-core/src/ec/pull_sync.rs b/crates/trusted-server-core/src/ec/pull_sync.rs index 0f79d2a1d..7558465ae 100644 --- a/crates/trusted-server-core/src/ec/pull_sync.rs +++ b/crates/trusted-server-core/src/ec/pull_sync.rs @@ -13,6 +13,7 @@ use http::{Method, StatusCode, header}; use serde::Deserialize; use url::Url; +use crate::consent::allows_eid_persistence; use crate::platform::{ DEFAULT_FIRST_BYTE_TIMEOUT, PlatformBackendSpec, PlatformHttpRequest, PlatformPendingRequest, PlatformResponse, RuntimeServices, @@ -65,7 +66,14 @@ pub fn build_pull_sync_context( ec_context: &EcContext, registry: &PartnerRegistry, ) -> Option { - if registry.pull_enabled_partners().is_empty() || !ec_context.ec_allowed() { + // Without EID-persistence consent (TCF Purpose 1 + 4), finalization + // withholds browser-supplied partner UIDs. That leaves partner slots empty, + // which pull sync would otherwise read as eligible and disclose the EC ID + // to partners for exactly the users who opted out. + if registry.pull_enabled_partners().is_empty() + || !ec_context.ec_allowed() + || !allows_eid_persistence(ec_context.consent()) + { return None; } @@ -593,6 +601,57 @@ mod tests { ); } + #[test] + fn build_pull_sync_context_skips_when_eid_persistence_is_denied() { + // Purpose 1 granted keeps the EC, but Purpose 4 denied withholds EID + // writes and leaves partner slots empty. Pull sync must not read those + // empty slots as eligible and disclose the EC ID to partners. + let consent = ConsentContext { + jurisdiction: crate::consent::jurisdiction::Jurisdiction::Gdpr, + gdpr_applies: true, + tcf: Some(crate::consent::types::TcfConsent { + version: 2, + cmp_id: 1, + cmp_version: 1, + consent_screen: 0, + consent_language: "EN".to_owned(), + vendor_list_version: 1, + tcf_policy_version: 4, + created_ds: 0, + last_updated_ds: 0, + // Purpose 1 (index 0) granted; Purpose 4 (index 3) denied. + purpose_consents: { + let mut purposes = vec![false; 24]; + purposes[0] = true; + purposes + }, + purpose_legitimate_interests: vec![false; 24], + vendor_consents: Vec::new(), + vendor_legitimate_interests: Vec::new(), + special_feature_opt_ins: vec![false; 12], + }), + source: crate::consent::types::ConsentSource::Cookie, + ..ConsentContext::default() + }; + let ec_id = format!("{}.ABC123", "a".repeat(64)); + let mut ec_context = EcContext::new_for_test(Some(ec_id.clone()), consent); + let graph = KvIdentityGraph::in_memory("pull_store"); + ec_context.set_kv_snapshot(seed_present_snapshot(&graph, &ec_id)); + let registry = PartnerRegistry::from_config(&[pull_enabled_ec_partner("ssp.example.com")]) + .expect("should build registry"); + assert!( + ec_context.ec_allowed(), + "should keep the EC allowed when Purpose 1 is granted" + ); + + let context = build_pull_sync_context(&ec_context, ®istry); + + assert!( + context.is_none(), + "should skip pull sync when TCF Purpose 4 is denied" + ); + } + #[test] fn build_pull_sync_context_skips_empty_registry_and_complete_snapshot() { let consent = ConsentContext { 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 2ca2fe6b1..ab70d67c6 100644 --- a/crates/trusted-server-js/lib/src/integrations/prebid/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/prebid/index.ts @@ -2915,27 +2915,65 @@ function clearPrebidEidsCookie(): void { document.cookie = `${EID_COOKIE_NAME}=; Path=/; Secure; SameSite=Lax; Max-Age=0`; } +/** + * 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. + */ function fitAuctionEidsToCookie(eids: AuctionEid[]): AuctionEid[] | undefined { - let payload = eids.map((eid) => ({ source: eid.source, uids: [...eid.uids] })); + 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; } - payload = payload.slice(0, payload.length - 1); + 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); + } } + logTrimmedEidSources(droppedSources, trimmedSources); return undefined; } +/** + * 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. + */ +function logTrimmedEidSources(droppedSources: Set, trimmedSources: Set): void { + if (droppedSources.size === 0 && trimmedSources.size === 0) { + return; + } + const parts: string[] = []; + if (droppedSources.size > 0) { + parts.push(`dropped sources: ${[...droppedSources].join(', ')}`); + } + if (trimmedSources.size > 0) { + parts.push(`trimmed uids from sources: ${[...trimmedSources].join(', ')}`); + } + log.debug( + `[tsjs-prebid] ts-eids cookie exceeded ${MAX_EID_COOKIE_BYTES} bytes; ${parts.join('; ')}` + ); +} + /** * Collects EIDs from Prebid's User ID Module and writes them as a * base64-encoded OpenRTB-style JSON cookie (`ts-eids`) for backend ingestion 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 9ab8f53ae..0c209b89a 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 @@ -3215,6 +3215,129 @@ describe('prebid/installPrebidNpm', () => { ]); }); + 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 }) => { diff --git a/docs/guide/api-reference.md b/docs/guide/api-reference.md index e444ec147..78f45ca86 100644 --- a/docs/guide/api-reference.md +++ b/docs/guide/api-reference.md @@ -900,7 +900,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. +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. 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 09a59a01f..4e8d958a8 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -716,6 +716,11 @@ KV store name only when consent should persist with the EC identity graph. | `conflict_resolution.mode` | String | `"restrictive"` | `restrictive`, `newest`, or `permissive` | | `conflict_resolution.freshness_threshold_days` | Integer | `30` | Age difference required by `newest` | +In `proxy` mode Trusted Server does not decode the TC string, so it cannot +confirm TCF Purpose 1 + 4. When a TC cookie is present, it fails closed: +browser-supplied EIDs are neither forwarded in bid requests nor written to the +EC identity graph, and server-side pull sync is skipped. + ```toml [consent] mode = "interpreter" @@ -1770,7 +1775,8 @@ unchanged. Persisting a resolved ID into the Edge Cookie identity graph additionally requires a matching `[[ec.partners]]` entry whose `source_domain` equals the -module's OpenRTB EID source. +module's OpenRTB EID source, and, when a TCF signal is present, consent to +TCF Purpose 1 and Purpose 4. `managed_user_ids` is an array of tables, so it cannot be set through a `TRUSTED_SERVER__` environment variable; the scalar overlay only replaces leaves diff --git a/docs/guide/edge-cookies.md b/docs/guide/edge-cookies.md index 2d83a9fe1..7c5ace160 100644 --- a/docs/guide/edge-cookies.md +++ b/docs/guide/edge-cookies.md @@ -119,7 +119,7 @@ flowchart TD J -- "Unknown
(no geo data)" --> Deny ``` -- **GDPR**: Opt-in required. TCF Purpose 1 (store/access device) must be explicitly consented. +- **GDPR**: Opt-in required. TCF Purpose 1 (store/access device) must be explicitly consented. Writing browser-supplied Prebid EIDs into the identity graph additionally requires TCF Purpose 4 (personalized ads); see [Prebid EID Flow](#prebid-eid-flow). Without Purpose 4, server-side pull sync is also skipped, so the EC ID is not sent to pull-sync partners. - **US State**: Opt-out model with three-tier fallback — GPC always blocks, then TCF if a CMP uses it, then US Privacy string, then fail-closed. - **Non-regulated**: EC always allowed. - **Unknown**: Fail-closed when jurisdiction cannot be determined. @@ -133,7 +133,7 @@ Partner identities flow into the KV identity graph through three channels. Each ```mermaid flowchart LR subgraph Browser-initiated - Prebid["Prebid EID Cookies
ts-eids + sharedId
Passive cookie ingestion"] + Prebid["Prebid EIDs
/auction body + ts-eids + sharedId
Response-time ingestion"] end subgraph Server-initiated @@ -146,9 +146,17 @@ flowchart LR Pull --> KV ``` -### Prebid EID Cookie Flow +### Prebid EID Flow -The `ts-eids` cookie bridges client-side Prebid user ID modules with the server-side identity graph. +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. + +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. ```mermaid sequenceDiagram @@ -168,7 +176,7 @@ sequenceDiagram Note over B,TS: Next eligible EID-sync request B->>TS: Document navigation, POST /auction,
or admitted GET /_ts/page-bids TS->>TS: Base64 decode → parse OpenRTB-style EIDs
match source domains to partners - TS->>KV: Add missing partner IDs in one conditional write
skip when UIDs already match + 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. @@ -179,7 +187,7 @@ Returning-user cookie persistence runs only on publisher document navigations, ` Each eligible request attempts at most one conditional cookie update. If another writer wins, Trusted Server reads the row once and does not write again from that request. A matching value completes the sync; an absent or different value is deferred. -Browser cookies do not carry a trustworthy value timestamp or sequence. Trusted Server therefore adds missing partner IDs but does not replace a different stored UID from a browser cookie, even on later eligible requests. Pull- or push-enabled partners can replace that value through their authoritative synchronization path. A cookie-only partner retains the stored UID until an authoritative freshness rule is introduced or the EC row expires. +Browser cookies and the `/auction` request body do not carry a trustworthy value timestamp or sequence. Trusted Server therefore adds missing partner IDs but does not replace a different stored UID from a browser-supplied EID, even on later eligible requests. Pull- or push-enabled partners can replace that value through their authoritative synchronization path. A cookie-only partner retains the stored UID until an authoritative freshness rule is introduced or the EC row expires. ### EID Seeding and Prebid Bidstream Forwarding @@ -201,7 +209,7 @@ sequenceDiagram else Prebid sync seeds browser EIDs B->>B: Prebid User ID modules resolve IDs B->>TSJS: getUserIdsAsEids() - TSJS->>B: Write ts-eids cookie
Base64 OpenRTB-style EIDs + 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 @@ -217,6 +225,7 @@ sequenceDiagram DSP-->>PS: OpenRTB bid response PS-->>TS: OpenRTB seatbid response TS-->>B: Auction response + x-ts-eids header when available + TS->>KV: Upsert matched partner UIDs from request eids[]
+ ts-eids/sharedId cookies (TCF Purpose 1 + 4 under GDPR) ``` The relevant OpenRTB structure forwarded to Prebid Server and downstream partners is: @@ -254,7 +263,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`. When a partner UID already exists in KV, pull sync does not periodically refresh it; browser-side Prebid sync can still replace the stored UID if a later `ts-eids` cookie carries a different value for the same configured partner source. +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). ### Pull-Sync Completeness Marker diff --git a/docs/guide/integrations/prebid.md b/docs/guide/integrations/prebid.md index 3610cbde7..1aba6434c 100644 --- a/docs/guide/integrations/prebid.md +++ b/docs/guide/integrations/prebid.md @@ -812,7 +812,9 @@ takes ownership after the shim removes its automatically registered IAB listener. Purpose 1 and LiveRamp's GVL vendor consent (vendor 97) gate IdentityLink resolution and storage. Purpose 3 has no standalone default rule. Purpose 4 controls user-provided-data activity, but -denying it alone does not block IdentityLink resolution or storage. +denying it alone does not block IdentityLink resolution or browser-side storage. +Persisting the RampID into the Edge Cookie identity graph is a separate, +server-side step that requires TCF Purpose 1 and Purpose 4. Default EID transmission accepts a qualifying purpose and vendor basis from any of Purposes 2–10. Publishers can require Purpose 4 specifically by enabling @@ -847,9 +849,12 @@ page or auction. When available, the opaque value follows the standard path: 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`. -4. The browser persists the same opaque value in the bounded `ts-eids` cookie. -5. A later request can ingest it into an EC/KV partner configured with - `source_domain = "liveramp.com"`. +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. Trusted Server treats the RampID envelope as an opaque string. Do not log, decode, publish, or dimension metrics by the value. Source names, counts, @@ -937,7 +942,9 @@ Trusted Server uses a **hybrid EID forwarding model** for Prebid-routed auctions 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. The `ts-eids` cookie is still ingested after the response so later requests can reuse the IDs even when the current auction does not provide them again. +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. @@ -959,7 +966,7 @@ sequenceDiagram T->>P: OpenRTB request\nuser.ext.eids = merged set P-->>T: OpenRTB bid response T-->>B: Auction response - T->>K: Ingest ts-eids cookie for future requests + T->>K: Ingest body eids + ts-eids/sharedId cookies\n(TCF Purpose 1 + 4 under GDPR) ``` ### Merge and deduplication rules