From 2de95eeb4d99e8b3e5f1f000381e64cb5b5f1d49 Mon Sep 17 00:00:00 2001 From: dhruv8sh Date: Mon, 21 Sep 2026 15:30:28 +0530 Subject: [PATCH 1/8] Ingest /auction EIDs from the request body instead of the trimmed ts-eids cookie Signed-off-by: dhruv8sh --- .../src/auction/endpoints.rs | 98 +++++++++++++++++ crates/trusted-server-core/src/ec/finalize.rs | 101 +++++++++++++++++- crates/trusted-server-core/src/ec/mod.rs | 22 ++++ .../trusted-server-core/src/ec/prebid_eids.rs | 67 ++++++++++-- .../lib/src/integrations/prebid/index.ts | 25 ++++- .../test/integrations/prebid/index.test.ts | 43 ++++++++ 6 files changed, 346 insertions(+), 10 deletions(-) diff --git a/crates/trusted-server-core/src/auction/endpoints.rs b/crates/trusted-server-core/src/auction/endpoints.rs index c0c0a7792..c14e7486a 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 @@ -796,6 +802,98 @@ 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)); + 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/ec/finalize.rs b/crates/trusted-server-core/src/ec/finalize.rs index 1fe61fa1f..72b15167f 100644 --- a/crates/trusted-server-core/src/ec/finalize.rs +++ b/crates/trusted-server-core/src/ec/finalize.rs @@ -20,7 +20,7 @@ use super::kv::{ CreateIfAbsentOutcome, KvIdentityGraph, TombstoneOutcome, 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::registry::PartnerRegistry; use super::{EcKvSnapshot, current_timestamp, log_id}; @@ -70,7 +70,12 @@ pub fn ec_finalize_response( // Returning user: consent is granted and EC came from request. 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 updates = collect_eid_cookie_updates(eids_cookie, sharedid_cookie, registry); + let updates = collect_eid_updates( + eids_cookie, + sharedid_cookie, + ec_context.client_eids(), + registry, + ); let snapshot = graph.upsert_partner_ids_from_snapshot( &ec_id, &updates, @@ -100,7 +105,12 @@ pub fn ec_finalize_response( return; }; - let updates = collect_eid_cookie_updates(eids_cookie, sharedid_cookie, registry); + let updates = collect_eid_updates( + eids_cookie, + sharedid_cookie, + ec_context.client_eids(), + registry, + ); let snapshot = graph.upsert_partner_ids_from_snapshot( &ec_id, &updates, @@ -392,9 +402,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::openrtb::{Eid, Uid}; use crate::redacted::Redacted; use crate::settings::EcPartner; use crate::test_support::tests::create_test_settings; @@ -1005,6 +1018,88 @@ 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, + ); + // 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_named_route_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 3dc6e299e..24cff7a3d 100644 --- a/crates/trusted-server-core/src/ec/mod.rs +++ b/crates/trusted-server-core/src/ec/mod.rs @@ -77,6 +77,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; @@ -233,6 +234,11 @@ pub struct EcContext { kv_snapshot: EcKvSnapshot, /// Whether this request may rotate an orphaned EC identity. recovery_eligible: bool, + /// EIDs parsed from the current request's own payload (e.g. the `/auction` + /// JSON body), when the route has one. Carried to response finalization so + /// KV ingestion can use the full set the client actually sent instead of + /// being limited to whatever fits in the size-capped `ts-eids` cookie. + client_eids: Option>, } impl EcContext { @@ -314,6 +320,7 @@ impl EcContext { device_signals: None, kv_snapshot: EcKvSnapshot::NotRead, recovery_eligible: false, + client_eids: None, }) } @@ -502,6 +509,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 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 fn client_eids(&self) -> Option<&[Eid]> { + self.client_eids.as_deref() + } + /// Replaces an orphaned active ID after its new backing row is persisted. pub(crate) fn replace_with_generated(&mut self, ec_id: String, snapshot: EcKvSnapshot) { self.ec_value = Some(ec_id); @@ -556,6 +575,7 @@ impl EcContext { device_signals: None, kv_snapshot: EcKvSnapshot::NotRead, recovery_eligible: false, + client_eids: None, } } @@ -578,6 +598,7 @@ impl EcContext { device_signals: None, kv_snapshot: EcKvSnapshot::NotRead, recovery_eligible: false, + client_eids: None, } } @@ -603,6 +624,7 @@ impl EcContext { device_signals: None, kv_snapshot: EcKvSnapshot::NotRead, recovery_eligible: false, + client_eids: None, } } } diff --git a/crates/trusted-server-core/src/ec/prebid_eids.rs b/crates/trusted-server-core/src/ec/prebid_eids.rs index 3072473dd..5e58ad57a 100644 --- a/crates/trusted-server-core/src/ec/prebid_eids.rs +++ b/crates/trusted-server-core/src/ec/prebid_eids.rs @@ -166,9 +166,18 @@ pub fn ingest_eid_cookies( } /// 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() { @@ -179,6 +188,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) { @@ -210,7 +222,7 @@ fn ingest_eid_cookies_with_writer( writer: &dyn PartnerIdBulkWriter, registry: &PartnerRegistry, ) { - let updates = collect_eid_cookie_updates(eids_cookie, sharedid_cookie, registry); + let updates = collect_eid_updates(eids_cookie, sharedid_cookie, None, registry); if updates.is_empty() { return; } @@ -715,13 +727,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(), @@ -733,13 +745,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(), 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 92af6c04a..14d40747e 100644 --- a/crates/trusted-server-js/lib/src/integrations/prebid/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/prebid/index.ts @@ -1956,12 +1956,21 @@ 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 droppedSources = new Set(); while (payload.length > 0) { const encoded = btoa(JSON.stringify(payload)); if (encoded.length <= MAX_EID_COOKIE_BYTES) { + warnAboutDroppedEidSources(droppedSources); return payload; } @@ -1971,12 +1980,26 @@ function fitAuctionEidsToCookie(eids: AuctionEid[]): AuctionEid[] | undefined { continue; } - payload = payload.slice(0, payload.length - 1); + const dropped = payload.pop(); + if (dropped) { + droppedSources.add(dropped.source); + } } + warnAboutDroppedEidSources(droppedSources); return undefined; } +/** Logs which EID sources `fitAuctionEidsToCookie` had to drop, if any. */ +function warnAboutDroppedEidSources(droppedSources: Set): void { + if (droppedSources.size === 0) { + return; + } + log.warn( + `[tsjs-prebid] ts-eids cookie exceeded ${MAX_EID_COOKIE_BYTES} bytes; dropped sources: ${[...droppedSources].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 679088341..33c0fc1c4 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 @@ -1551,6 +1551,49 @@ describe('prebid/installPrebidNpm', () => { ]); }); + it('trims an oversized ts-eids cookie deterministically and warns which sources were dropped', () => { + const warnSpy = vi.spyOn(console, '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 warnedMessage = warnSpy.mock.calls + .map((call) => call.at(-1)) + .find( + (arg): arg is string => typeof arg === 'string' && arg.includes('ts-eids cookie exceeded') + ); + expect(warnedMessage).toBeDefined(); + expect(warnedMessage).toContain('dropped.example'); + }); + 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 }) => { From 5cceaf54025ee44e05d5e250577201214db2b58f Mon Sep 17 00:00:00 2001 From: dhruv8sh Date: Mon, 21 Sep 2026 16:40:44 +0530 Subject: [PATCH 2/8] Gate EID identity-graph writes on TCF Purpose 4 consent Signed-off-by: dhruv8sh --- crates/trusted-server-core/src/ec/finalize.rs | 130 ++++++++++++++++-- 1 file changed, 121 insertions(+), 9 deletions(-) diff --git a/crates/trusted-server-core/src/ec/finalize.rs b/crates/trusted-server-core/src/ec/finalize.rs index 72b15167f..f807eaef6 100644 --- a/crates/trusted-server-core/src/ec/finalize.rs +++ b/crates/trusted-server-core/src/ec/finalize.rs @@ -10,6 +10,7 @@ use error_stack::Report; use http::Response; use super::consent::{ec_consent_granted, ec_consent_withdrawn}; +use crate::consent::gate_eids_by_consent; use crate::error::TrustedServerError; use crate::settings::Settings; @@ -70,10 +71,10 @@ pub fn ec_finalize_response( // Returning user: consent is granted and EC came from request. 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 updates = collect_eid_updates( + let updates = collect_consent_gated_eid_updates( eids_cookie, sharedid_cookie, - ec_context.client_eids(), + ec_context, registry, ); let snapshot = graph.upsert_partner_ids_from_snapshot( @@ -105,12 +106,8 @@ pub fn ec_finalize_response( return; }; - let updates = collect_eid_updates( - eids_cookie, - sharedid_cookie, - ec_context.client_eids(), - registry, - ); + let updates = + collect_consent_gated_eid_updates(eids_cookie, sharedid_cookie, ec_context, registry); let snapshot = graph.upsert_partner_ids_from_snapshot( &ec_id, &updates, @@ -125,6 +122,32 @@ pub fn ec_finalize_response( } } +/// Collects EID-derived KV updates and applies TCF Purpose 4 (personalized +/// ads) consent gating 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 before they may be transmitted — the same +/// rule [`gate_eids_by_consent`](crate::consent::gate_eids_by_consent) applies +/// to the outbound `/auction` bid request. 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 collect_consent_gated_eid_updates( + eids_cookie: Option<&str>, + sharedid_cookie: Option<&str>, + ec_context: &EcContext, + registry: &PartnerRegistry, +) -> Vec { + let updates = collect_eid_updates( + eids_cookie, + sharedid_cookie, + ec_context.client_eids(), + registry, + ); + gate_eids_by_consent(Some(updates), Some(ec_context.consent())).unwrap_or_default() +} + fn recover_orphaned_ec( settings: &Settings, ec_context: &mut EcContext, @@ -406,7 +429,7 @@ mod tests { 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; @@ -1100,6 +1123,95 @@ mod tests { ); } + #[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 = 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() + }; + 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_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_named_route_confirmed_miss_does_not_create_a_row() { // The same path with a genuinely absent row must stay a no-op: a route From 94b8d18e8a6d3a2b17e831e8c8ba5d5784c25a52 Mon Sep 17 00:00:00 2001 From: dhruv8sh Date: Mon, 21 Sep 2026 17:05:37 +0530 Subject: [PATCH 3/8] Fix prefer-const lint failure in fitAuctionEidsToCookie Signed-off-by: dhruv8sh --- crates/trusted-server-js/lib/src/integrations/prebid/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 14d40747e..2f2f2df69 100644 --- a/crates/trusted-server-js/lib/src/integrations/prebid/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/prebid/index.ts @@ -1964,7 +1964,7 @@ function clearPrebidEidsCookie(): void { * (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(); while (payload.length > 0) { From 2c2e1c870e7fa2cd0cb3bf8bebb0d774f0d21cab Mon Sep 17 00:00:00 2001 From: dhruv8sh Date: Tue, 22 Sep 2026 10:18:07 +0530 Subject: [PATCH 4/8] Address PR review: preserve orphan recovery under Purpose 4 gating, narrow client EID visibility, report partial UID trims Purpose 4 denial was emptying the EID update list without refreshing an unread KV snapshot, silently disabling orphan-EC recovery on non-GET publisher navigations. Force a snapshot read in that narrow case only, leaving the Failed/Present/Missing/no-data invariants untouched. Also narrows set_client_eids/client_eids to pub(crate) (no external callers), and makes the ts-eids cookie trim warning report sources that lost some UIDs but were retained, not just sources dropped outright. Signed-off-by: dhruv8sh --- crates/trusted-server-core/src/ec/finalize.rs | 124 +++++++++++++++++- crates/trusted-server-core/src/ec/mod.rs | 4 +- .../lib/src/integrations/prebid/index.ts | 24 +++- .../test/integrations/prebid/index.test.ts | 40 ++++++ 4 files changed, 179 insertions(+), 13 deletions(-) diff --git a/crates/trusted-server-core/src/ec/finalize.rs b/crates/trusted-server-core/src/ec/finalize.rs index f807eaef6..ef329731a 100644 --- a/crates/trusted-server-core/src/ec/finalize.rs +++ b/crates/trusted-server-core/src/ec/finalize.rs @@ -77,11 +77,36 @@ pub fn ec_finalize_response( ec_context, registry, ); - let snapshot = graph.upsert_partner_ids_from_snapshot( - &ec_id, - &updates, - ec_context.kv_snapshot().clone(), - ); + // `upsert_partner_ids_from_snapshot` early-returns the incoming + // snapshot unrefreshed when `updates` is empty, which is correct + // when there was never anything to write (an unconfigured + // registry, or no EID cookies/body this request — the case + // `finalize_not_read_snapshot_does_not_rotate` covers). But when + // 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 a Purpose-4-only denial + // must not also skip that one read on a recovery-eligible + // request. + let snapshot = if updates.is_empty() + && ec_context.recovery_eligible() + && matches!(ec_context.kv_snapshot(), EcKvSnapshot::NotRead) + && !collect_eid_updates( + eids_cookie, + sharedid_cookie, + ec_context.client_eids(), + registry, + ) + .is_empty() + { + graph.load_snapshot(&ec_id) + } else { + graph.upsert_partner_ids_from_snapshot( + &ec_id, + &updates, + ec_context.kv_snapshot().clone(), + ) + }; ec_context.set_kv_snapshot(snapshot); if matches!(ec_context.kv_snapshot(), EcKvSnapshot::Missing { .. }) && ec_context.recovery_eligible() @@ -990,6 +1015,95 @@ mod tests { ); } + #[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 = 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() + }; + 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_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 finalize_named_route_transient_miss_still_persists_eid_updates() { // `/auction` and `/_ts/page-bids` save their first lookup into the diff --git a/crates/trusted-server-core/src/ec/mod.rs b/crates/trusted-server-core/src/ec/mod.rs index 24cff7a3d..187b5ea19 100644 --- a/crates/trusted-server-core/src/ec/mod.rs +++ b/crates/trusted-server-core/src/ec/mod.rs @@ -511,13 +511,13 @@ impl EcContext { /// Records the current request's own EIDs (e.g. an `/auction` body) for /// use by response finalization's KV ingestion. - pub fn set_client_eids(&mut self, eids: Vec) { + 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 fn client_eids(&self) -> Option<&[Eid]> { + pub(crate) fn client_eids(&self) -> Option<&[Eid]> { self.client_eids.as_deref() } 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 2f2f2df69..87062ad94 100644 --- a/crates/trusted-server-js/lib/src/integrations/prebid/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/prebid/index.ts @@ -1966,17 +1966,19 @@ function clearPrebidEidsCookie(): void { 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) { - warnAboutDroppedEidSources(droppedSources); + warnAboutDroppedEidSources(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; } @@ -1986,17 +1988,27 @@ function fitAuctionEidsToCookie(eids: AuctionEid[]): AuctionEid[] | undefined { } } - warnAboutDroppedEidSources(droppedSources); + warnAboutDroppedEidSources(droppedSources, trimmedSources); return undefined; } -/** Logs which EID sources `fitAuctionEidsToCookie` had to drop, if any. */ -function warnAboutDroppedEidSources(droppedSources: Set): void { - if (droppedSources.size === 0) { +/** Logs which EID sources `fitAuctionEidsToCookie` had to drop or trim UIDs from, if any. */ +function warnAboutDroppedEidSources( + 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.warn( - `[tsjs-prebid] ts-eids cookie exceeded ${MAX_EID_COOKIE_BYTES} bytes; dropped sources: ${[...droppedSources].join(', ')}` + `[tsjs-prebid] ts-eids cookie exceeded ${MAX_EID_COOKIE_BYTES} bytes; ${parts.join('; ')}` ); } 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 33c0fc1c4..01d7084f7 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 @@ -1594,6 +1594,46 @@ describe('prebid/installPrebidNpm', () => { expect(warnedMessage).toContain('dropped.example'); }); + it('warns about a source whose UIDs were partially trimmed but retained', () => { + const warnSpy = vi.spyOn(console, 'warn').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 warnedMessage = warnSpy.mock.calls + .map((call) => call.at(-1)) + .find( + (arg): arg is string => typeof arg === 'string' && arg.includes('ts-eids cookie exceeded') + ); + expect(warnedMessage).toBeDefined(); + expect(warnedMessage).toContain('trimmed uids from sources: id5-sync.com'); + }); + 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 }) => { From 28addcbec3e8cc4f5f7edf6231264d56b3386400 Mon Sep 17 00:00:00 2001 From: dhruv8sh Date: Thu, 24 Sep 2026 13:59:41 +0530 Subject: [PATCH 5/8] Gate EID KV writes with a dedicated consent predicate Add consent::allows_eid_persistence, matching gate_eids_by_consent's decision (TCF Purpose 1 + 4, fail closed when GDPR applies without TCF) without its per-request info logs. EC finalization collects EID updates once, withholds them with a debug log when the predicate denies, and uses the pre-gating emptiness for the orphan-recovery snapshot read instead of re-collecting. Extract the Purpose-1-only test consent into a helper, update the client_eids doc to cover the ts-eids cookie fallback, and in TSJS report a trimmed-then-dropped EID source only as dropped and log cookie trimming at debug level. Signed-off-by: dhruv8sh --- crates/trusted-server-core/src/consent/mod.rs | 107 ++++++++++++- crates/trusted-server-core/src/ec/finalize.rs | 145 ++++++++---------- crates/trusted-server-core/src/ec/mod.rs | 9 +- .../lib/src/integrations/prebid/index.ts | 19 ++- .../test/integrations/prebid/index.test.ts | 60 ++++++-- 5 files changed, 235 insertions(+), 105 deletions(-) diff --git a/crates/trusted-server-core/src/consent/mod.rs b/crates/trusted-server-core/src/consent/mod.rs index f205a8363..0277588f2 100644 --- a/crates/trusted-server-core/src/consent/mod.rs +++ b/crates/trusted-server-core/src/consent/mod.rs @@ -518,6 +518,21 @@ 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. +#[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 // --------------------------------------------------------------------------- @@ -701,9 +716,9 @@ 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, has_explicit_ec_withdrawal, + consent_allows_server_side_auction, gate_eids_by_consent, has_explicit_ec_withdrawal, }; use crate::consent::jurisdiction::Jurisdiction; use crate::consent::types::{ @@ -804,6 +819,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/finalize.rs b/crates/trusted-server-core/src/ec/finalize.rs index ef329731a..bced6400f 100644 --- a/crates/trusted-server-core/src/ec/finalize.rs +++ b/crates/trusted-server-core/src/ec/finalize.rs @@ -10,7 +10,7 @@ use error_stack::Report; use http::Response; use super::consent::{ec_consent_granted, ec_consent_withdrawn}; -use crate::consent::gate_eids_by_consent; +use crate::consent::{ConsentContext, allows_eid_persistence}; use crate::error::TrustedServerError; use crate::settings::Settings; @@ -71,33 +71,29 @@ pub fn ec_finalize_response( // Returning user: consent is granted and EC came from request. 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 updates = collect_consent_gated_eid_updates( + let collected = collect_eid_updates( eids_cookie, sharedid_cookie, - ec_context, + ec_context.client_eids(), registry, ); + let had_eid_updates = !collected.is_empty(); + let updates = gate_eid_updates_by_consent(collected, ec_context.consent()); // `upsert_partner_ids_from_snapshot` early-returns the incoming // snapshot unrefreshed when `updates` is empty, which is correct // when there was never anything to write (an unconfigured // registry, or no EID cookies/body this request — the case // `finalize_not_read_snapshot_does_not_rotate` covers). But when - // 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 a Purpose-4-only denial - // must not also skip that one read on a recovery-eligible - // request. + // 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. let snapshot = if updates.is_empty() + && had_eid_updates && ec_context.recovery_eligible() && matches!(ec_context.kv_snapshot(), EcKvSnapshot::NotRead) - && !collect_eid_updates( - eids_cookie, - sharedid_cookie, - ec_context.client_eids(), - registry, - ) - .is_empty() { graph.load_snapshot(&ec_id) } else { @@ -131,8 +127,15 @@ pub fn ec_finalize_response( return; }; - let updates = - collect_consent_gated_eid_updates(eids_cookie, sharedid_cookie, ec_context, registry); + let updates = gate_eid_updates_by_consent( + collect_eid_updates( + eids_cookie, + sharedid_cookie, + ec_context.client_eids(), + registry, + ), + ec_context.consent(), + ); let snapshot = graph.upsert_partner_ids_from_snapshot( &ec_id, &updates, @@ -147,30 +150,30 @@ pub fn ec_finalize_response( } } -/// Collects EID-derived KV updates and applies TCF Purpose 4 (personalized -/// ads) consent gating on top of the Purpose 1 (EC) gate `ec_finalize_response` +/// 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 before they may be transmitted — the same -/// rule [`gate_eids_by_consent`](crate::consent::gate_eids_by_consent) applies -/// to the outbound `/auction` bid request. 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 collect_consent_gated_eid_updates( - eids_cookie: Option<&str>, - sharedid_cookie: Option<&str>, - ec_context: &EcContext, - registry: &PartnerRegistry, +/// 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 { - let updates = collect_eid_updates( - eids_cookie, - sharedid_cookie, - ec_context.client_eids(), - registry, + 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() ); - gate_eids_by_consent(Some(updates), Some(ec_context.consent())).unwrap_or_default() + Vec::new() } fn recover_orphaned_ec( @@ -1015,22 +1018,10 @@ mod tests { ); } - #[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 = ConsentContext { + /// 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 { @@ -1056,7 +1047,25 @@ mod tests { }), 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, @@ -1247,33 +1256,7 @@ mod tests { let settings = create_test_settings(); let ec_id = sample_ec_id("purp4x"); let graph = KvIdentityGraph::in_memory("test_store"); - let purpose1_only_consent = 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() - }; + let purpose1_only_consent = purpose_one_only_consent(); let live = KvEntry::new( &purpose1_only_consent, None, diff --git a/crates/trusted-server-core/src/ec/mod.rs b/crates/trusted-server-core/src/ec/mod.rs index 187b5ea19..2bad05dd7 100644 --- a/crates/trusted-server-core/src/ec/mod.rs +++ b/crates/trusted-server-core/src/ec/mod.rs @@ -234,10 +234,11 @@ pub struct EcContext { kv_snapshot: EcKvSnapshot, /// Whether this request may rotate an orphaned EC identity. recovery_eligible: bool, - /// EIDs parsed from the current request's own payload (e.g. the `/auction` - /// JSON body), when the route has one. Carried to response finalization so - /// KV ingestion can use the full set the client actually sent instead of - /// being limited to whatever fits in the size-capped `ts-eids` cookie. + /// 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>, } 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 87062ad94..3b155c425 100644 --- a/crates/trusted-server-js/lib/src/integrations/prebid/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/prebid/index.ts @@ -1971,7 +1971,7 @@ function fitAuctionEidsToCookie(eids: AuctionEid[]): AuctionEid[] | undefined { while (payload.length > 0) { const encoded = btoa(JSON.stringify(payload)); if (encoded.length <= MAX_EID_COOKIE_BYTES) { - warnAboutDroppedEidSources(droppedSources, trimmedSources); + logTrimmedEidSources(droppedSources, trimmedSources); return payload; } @@ -1985,18 +1985,21 @@ function fitAuctionEidsToCookie(eids: AuctionEid[]): AuctionEid[] | undefined { 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); } } - warnAboutDroppedEidSources(droppedSources, trimmedSources); + logTrimmedEidSources(droppedSources, trimmedSources); return undefined; } -/** Logs which EID sources `fitAuctionEidsToCookie` had to drop or trim UIDs from, if any. */ -function warnAboutDroppedEidSources( - droppedSources: Set, - trimmedSources: Set -): void { +/** + * 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; } @@ -2007,7 +2010,7 @@ function warnAboutDroppedEidSources( if (trimmedSources.size > 0) { parts.push(`trimmed uids from sources: ${[...trimmedSources].join(', ')}`); } - log.warn( + log.debug( `[tsjs-prebid] ts-eids cookie exceeded ${MAX_EID_COOKIE_BYTES} bytes; ${parts.join('; ')}` ); } 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 01d7084f7..a17132b08 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 @@ -1551,8 +1551,9 @@ describe('prebid/installPrebidNpm', () => { ]); }); - it('trims an oversized ts-eids cookie deterministically and warns which sources were dropped', () => { - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + 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?.(); }); @@ -1585,17 +1586,18 @@ describe('prebid/installPrebidNpm', () => { expect(persistedSources).not.toContain('dropped.example'); expect(persistedSources).toContain('id5-sync.com'); - const warnedMessage = warnSpy.mock.calls + 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(warnedMessage).toBeDefined(); - expect(warnedMessage).toContain('dropped.example'); + expect(loggedMessage).toBeDefined(); + expect(loggedMessage).toContain('dropped.example'); + expect(warnSpy).not.toHaveBeenCalledWith(expect.stringContaining('ts-eids cookie exceeded')); }); - it('warns about a source whose UIDs were partially trimmed but retained', () => { - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + 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?.(); }); @@ -1625,13 +1627,51 @@ describe('prebid/installPrebidNpm', () => { const persistedSource = persisted.find((eid) => eid.source === 'id5-sync.com'); expect(persistedSource!.uids.length).toBeLessThan(10); - const warnedMessage = warnSpy.mock.calls + 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(warnedMessage).toBeDefined(); - expect(warnedMessage).toContain('trimmed uids from sources: id5-sync.com'); + 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', () => { From 6338bcf1f96f08f048c74b01ed4eb35fb65d54b8 Mon Sep 17 00:00:00 2001 From: dhruv8sh Date: Thu, 24 Sep 2026 14:00:47 +0530 Subject: [PATCH 6/8] Document /auction body EID ingestion and Purpose 4 gating Update the Prebid, integration, and Edge Cookie guides: /auction writes request-body EIDs to the identity graph (applied after ts-eids and before sharedId), the ts-eids cookie remains the source for body-less routes such as GET /_ts/page-bids and navigations, and identity-graph EID writes require TCF Purpose 1 and Purpose 4 under GDPR. Signed-off-by: dhruv8sh --- docs/guide/edge-cookies.md | 25 +++++++++++++++++-------- docs/guide/integration-guide.md | 2 +- docs/guide/integrations/prebid.md | 6 ++++-- 3 files changed, 22 insertions(+), 11 deletions(-) diff --git a/docs/guide/edge-cookies.md b/docs/guide/edge-cookies.md index 2ba9f2638..92f90e4af 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 partner EIDs into the identity graph additionally requires TCF Purpose 4 (personalized ads); see [Partner Sync Channels](#partner-sync-channels). - **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 a later source wins for the same partner: + +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. + +Identity-graph 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 @@ -165,10 +173,10 @@ sequenceDiagram TSJS->>TSJS: Base64 encode full OpenRTB-style EID array
[{source, uids:[{id, atype, ext?}]}] TSJS->>B: document.cookie = "ts-eids=..." - Note over B,TS: Next page request + Note over B,TS: Next request without an EID body B->>TS: Request with ts-eids cookie TS->>TS: Base64 decode → parse OpenRTB-style EIDs
match source domains to partners - TS->>KV: upsert_partner_id() per match
(skips write when UID unchanged) + TS->>KV: Upsert matched partner UIDs
(requires TCF Purpose 1 + 4 under GDPR;
skips write when UID unchanged) ``` 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. @@ -195,7 +203,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 request with ts-eids TS->>KV: Decode cookie and upsert matched partner UIDs end @@ -211,6 +219,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: @@ -248,7 +257,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`. 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 `/auction` request body, `ts-eids` cookie, or `sharedId` cookie carries a different value for the same configured partner source. ## Configuration diff --git a/docs/guide/integration-guide.md b/docs/guide/integration-guide.md index cf7da71f6..fc4dddf6d 100644 --- a/docs/guide/integration-guide.md +++ b/docs/guide/integration-guide.md @@ -356,7 +356,7 @@ For Prebid-routed auctions, Trusted Server now forwards identity using a hybrid - The edge resolves additional EIDs from the EC/KV identity graph. - The auction handler merges and deduplicates both sets. - The Prebid provider forwards the merged result to Prebid Server as `user.ext.eids`. -- The `ts-eids` cookie is still ingested after the response so future requests can benefit from those IDs even without fresh browser-side resolution. +- After the response, the `/auction` request-body EIDs are written to the EC identity graph together with the `ts-eids` and `sharedId` cookies (precedence: `ts-eids`, then body, then `sharedId`), subject to TCF Purpose 1 and Purpose 4 consent under GDPR. The size-capped `ts-eids` cookie remains the ingestion and fallback source for requests without an EID body, such as `GET /_ts/page-bids` and page navigations. Reusing these patterns makes it straightforward to convert additional legacy flows (for example, Next.js rewrites) into first-class integrations. diff --git a/docs/guide/integrations/prebid.md b/docs/guide/integrations/prebid.md index dfc8ddb79..4adf4fb8c 100644 --- a/docs/guide/integrations/prebid.md +++ b/docs/guide/integrations/prebid.md @@ -514,7 +514,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 writes matched partner UIDs into the EC identity graph from the `ts-eids` cookie, then the `/auction` request-body EIDs, then the `sharedId` cookie; a later source wins when two carry the same partner. 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. @@ -536,7 +538,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 From ad518fb08d9a9e0e6411d61938af1c757546d54e Mon Sep 17 00:00:00 2001 From: Dhruvesh Surolia Date: Mon, 28 Sep 2026 13:05:36 +0530 Subject: [PATCH 7/8] Update docs/guide/edge-cookies.md Co-authored-by: AG <132480+aram356@users.noreply.github.com> --- docs/guide/edge-cookies.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/guide/edge-cookies.md b/docs/guide/edge-cookies.md index 92f90e4af..660b6a9db 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. Writing partner EIDs into the identity graph additionally requires TCF Purpose 4 (personalized ads); see [Partner Sync Channels](#partner-sync-channels). +- **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). Pull sync does not apply the Purpose 4 check yet. - **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. From 3b92170ab88d26bba971f62fe3c8da6f6eec744a Mon Sep 17 00:00:00 2001 From: dhruv8sh Date: Sun, 4 Oct 2026 23:58:51 +0530 Subject: [PATCH 8/8] Skip pull sync when EID persistence consent is denied Without TCF Purpose 1 + 4, finalization withholds browser-supplied partner UIDs, which left partner slots empty and made pull sync disclose the EC ID to partners for users who opted out. Also document proxy-mode fail-closed behavior, mark the admin EIDs preview as cookie-only, and align RampID docs with body ingestion and the consent gate. Signed-off-by: dhruv8sh --- crates/trusted-server-core/src/consent/mod.rs | 8 +++ crates/trusted-server-core/src/ec/admin.rs | 6 +- .../trusted-server-core/src/ec/pull_sync.rs | 61 ++++++++++++++++++- docs/guide/api-reference.md | 2 +- docs/guide/configuration.md | 8 ++- docs/guide/integrations/prebid.md | 15 +++-- 6 files changed, 90 insertions(+), 10 deletions(-) diff --git a/crates/trusted-server-core/src/consent/mod.rs b/crates/trusted-server-core/src/consent/mod.rs index f9ee4b59a..76aa6ad85 100644 --- a/crates/trusted-server-core/src/consent/mod.rs +++ b/crates/trusted-server-core/src/consent/mod.rs @@ -483,6 +483,14 @@ pub fn gate_eids_by_consent( /// 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) { 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/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/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/integrations/prebid.md b/docs/guide/integrations/prebid.md index c99e6ae24..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,7 @@ 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. After the response, Trusted Server writes matched partner UIDs into the EC identity graph from the `ts-eids` cookie, then the `/auction` request-body EIDs, then the `sharedId` cookie; a later source wins when two carry the same partner. Because the body carries the untrimmed EID set, `/auction` ingestion is not limited by the size-capped `ts-eids` cookie. +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.