Skip to content

Commit f26c040

Browse files
Decorate auction bidstream with partner EIDs from KV identity graph
1 parent 403d17f commit f26c040

13 files changed

Lines changed: 615 additions & 92 deletions

File tree

crates/trusted-server-adapter-fastly/src/main.rs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,16 @@ async fn route_request(
153153

154154
// Unified auction endpoint (returns creative HTML inline)
155155
(Method::POST, "/auction") => {
156-
handle_auction(settings, orchestrator, kv_graph.as_ref(), &ec_context, req).await
156+
let partner_store = require_partner_store(settings).ok();
157+
handle_auction(
158+
settings,
159+
orchestrator,
160+
kv_graph.as_ref(),
161+
partner_store.as_ref(),
162+
&ec_context,
163+
req,
164+
)
165+
.await
157166
}
158167

159168
// tsjs endpoints

crates/trusted-server-core/src/auction/endpoints.rs

Lines changed: 141 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,13 @@ use error_stack::{Report, ResultExt};
44
use fastly::{Request, Response};
55

66
use crate::auction::formats::AdRequest;
7+
use crate::consent::gate_eids_by_consent;
8+
use crate::ec::eids::{resolve_partner_ids, to_eids};
79
use crate::ec::kv::KvIdentityGraph;
10+
use crate::ec::partner::PartnerStore;
811
use crate::ec::EcContext;
912
use crate::error::TrustedServerError;
13+
use crate::openrtb::Eid;
1014
use crate::settings::Settings;
1115

1216
use super::formats::{convert_to_openrtb_response, convert_tsjs_to_auction_request};
@@ -29,7 +33,8 @@ use super::AuctionOrchestrator;
2933
pub async fn handle_auction(
3034
settings: &Settings,
3135
orchestrator: &AuctionOrchestrator,
32-
_kv: Option<&KvIdentityGraph>,
36+
kv: Option<&KvIdentityGraph>,
37+
partner_store: Option<&PartnerStore>,
3338
ec_context: &EcContext,
3439
mut req: Request,
3540
) -> Result<Response, Report<TrustedServerError>> {
@@ -58,10 +63,22 @@ pub async fn handle_auction(
5863
};
5964
let consent_context = ec_context.consent().clone();
6065

66+
// Resolve partner EIDs from the KV identity graph when the user has
67+
// a valid EC and both KV and partner stores are available.
68+
let eids = resolve_auction_eids(kv, partner_store, ec_context);
69+
6170
// Convert tsjs request format to auction request
62-
let auction_request =
71+
let mut auction_request =
6372
convert_tsjs_to_auction_request(&body, settings, &req, consent_context, ec_id)?;
6473

74+
// Apply consent gating to the resolved EIDs before attaching them to the
75+
// auction request. `gate_eids_by_consent` checks TCF Purpose 1 + 4.
76+
let had_eids = eids.as_ref().is_some_and(|v| !v.is_empty());
77+
auction_request.user.eids = gate_eids_by_consent(eids, auction_request.user.consent.as_ref());
78+
if had_eids && auction_request.user.eids.is_none() {
79+
log::debug!("Auction EIDs stripped by TCF consent gating");
80+
}
81+
6582
// Create auction context
6683
let context = AuctionContext {
6784
settings,
@@ -86,5 +103,126 @@ pub async fn handle_auction(
86103
);
87104

88105
// Convert to OpenRTB response format with inline creative HTML
89-
convert_to_openrtb_response(&result, settings, &auction_request)
106+
convert_to_openrtb_response(&result, settings, &auction_request, ec_context.ec_allowed())
107+
}
108+
109+
/// Resolves partner EIDs from the KV identity graph for bidstream decoration.
110+
///
111+
/// Returns `None` when any prerequisite is missing (no KV store, no partner
112+
/// store, no EC, consent denied). On KV or partner-resolution errors, logs a
113+
/// warning and returns empty EIDs so the auction can proceed in degraded mode.
114+
fn resolve_auction_eids(
115+
kv: Option<&KvIdentityGraph>,
116+
partner_store: Option<&PartnerStore>,
117+
ec_context: &EcContext,
118+
) -> Option<Vec<Eid>> {
119+
let kv = kv?;
120+
let partner_store = partner_store?;
121+
122+
if !ec_context.ec_allowed() {
123+
return None;
124+
}
125+
126+
let ec_hash = ec_context.ec_hash()?;
127+
128+
let entry = match kv.get(ec_hash) {
129+
Ok(Some((entry, _generation))) => entry,
130+
Ok(None) => return Some(Vec::new()),
131+
Err(err) => {
132+
log::warn!("Auction KV read failed for EC hash '{ec_hash}': {err:?}");
133+
return Some(Vec::new());
134+
}
135+
};
136+
137+
match resolve_partner_ids(partner_store, &entry) {
138+
Ok(resolved) => Some(to_eids(&resolved)),
139+
Err(err) => {
140+
log::warn!("Auction partner resolution failed: {err:?}");
141+
Some(Vec::new())
142+
}
143+
}
144+
}
145+
146+
#[cfg(test)]
147+
mod tests {
148+
use super::*;
149+
use crate::consent::jurisdiction::Jurisdiction;
150+
use crate::consent::types::ConsentContext;
151+
152+
fn make_ec_context(jurisdiction: Jurisdiction, ec_value: Option<&str>) -> EcContext {
153+
EcContext::new_for_test(
154+
ec_value.map(str::to_owned),
155+
ConsentContext {
156+
jurisdiction,
157+
..ConsentContext::default()
158+
},
159+
)
160+
}
161+
162+
#[test]
163+
fn resolve_auction_eids_returns_none_without_kv() {
164+
let partner_store = PartnerStore::new("test_store");
165+
let ec_id = format!("{}.ABC123", "a".repeat(64));
166+
let ec_context = make_ec_context(Jurisdiction::NonRegulated, Some(&ec_id));
167+
168+
let result = resolve_auction_eids(None, Some(&partner_store), &ec_context);
169+
assert!(result.is_none(), "should return None when KV is missing");
170+
}
171+
172+
#[test]
173+
fn resolve_auction_eids_returns_none_without_partner_store() {
174+
let kv = KvIdentityGraph::new("test_store");
175+
let ec_id = format!("{}.ABC123", "a".repeat(64));
176+
let ec_context = make_ec_context(Jurisdiction::NonRegulated, Some(&ec_id));
177+
178+
let result = resolve_auction_eids(Some(&kv), None, &ec_context);
179+
assert!(
180+
result.is_none(),
181+
"should return None when partner store is missing"
182+
);
183+
}
184+
185+
#[test]
186+
fn resolve_auction_eids_returns_none_when_consent_denied() {
187+
let kv = KvIdentityGraph::new("test_store");
188+
let partner_store = PartnerStore::new("test_store");
189+
let ec_id = format!("{}.ABC123", "a".repeat(64));
190+
let ec_context = make_ec_context(Jurisdiction::Unknown, Some(&ec_id));
191+
192+
let result = resolve_auction_eids(Some(&kv), Some(&partner_store), &ec_context);
193+
assert!(
194+
result.is_none(),
195+
"should return None when consent is denied"
196+
);
197+
}
198+
199+
#[test]
200+
fn resolve_auction_eids_returns_none_when_no_ec() {
201+
let kv = KvIdentityGraph::new("test_store");
202+
let partner_store = PartnerStore::new("test_store");
203+
let ec_context = make_ec_context(Jurisdiction::NonRegulated, None);
204+
205+
let result = resolve_auction_eids(Some(&kv), Some(&partner_store), &ec_context);
206+
assert!(
207+
result.is_none(),
208+
"should return None when no EC value is present"
209+
);
210+
}
211+
212+
#[test]
213+
fn resolve_auction_eids_returns_empty_on_kv_miss() {
214+
let kv = KvIdentityGraph::new("nonexistent_store");
215+
let partner_store = PartnerStore::new("nonexistent_store");
216+
let ec_id = format!("{}.ABC123", "a".repeat(64));
217+
let ec_context = make_ec_context(Jurisdiction::NonRegulated, Some(&ec_id));
218+
219+
// KV store doesn't exist, so the get() call will error — should return
220+
// empty Vec (degraded mode), not None.
221+
let result = resolve_auction_eids(Some(&kv), Some(&partner_store), &ec_context);
222+
let eids = result.expect("should return Some on KV error (degraded mode)");
223+
assert!(
224+
eids.is_empty(),
225+
"should return empty vec on KV error (degraded mode)"
226+
);
227+
}
90228
}

crates/trusted-server-core/src/auction/formats.rs

Lines changed: 157 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,11 @@ use uuid::Uuid;
1414

1515
use crate::auction::context::ContextValue;
1616
use crate::consent::ConsentContext;
17-
use crate::constants::HEADER_X_TS_EC;
17+
use crate::constants::{
18+
HEADER_X_TS_EC, HEADER_X_TS_EC_CONSENT, HEADER_X_TS_EIDS, HEADER_X_TS_EIDS_TRUNCATED,
19+
};
1820
use crate::creative;
21+
use crate::ec::eids::encode_eids_header;
1922
use crate::error::TrustedServerError;
2023
use crate::geo::GeoInfo;
2124
use crate::openrtb::{to_openrtb_i32, OpenRtbBid, OpenRtbResponse, ResponseExt, SeatBid, ToExt};
@@ -181,6 +184,7 @@ pub fn convert_tsjs_to_auction_request(
181184
user: UserInfo {
182185
id: ec_id,
183186
consent: Some(consent),
187+
eids: None,
184188
},
185189
device,
186190
site: Some(SiteInfo {
@@ -204,6 +208,7 @@ pub fn convert_to_openrtb_response(
204208
result: &OrchestrationResult,
205209
settings: &Settings,
206210
auction_request: &AuctionRequest,
211+
ec_allowed: bool,
207212
) -> Result<Response, Report<TrustedServerError>> {
208213
// Build OpenRTB-style seatbid array
209214
let mut seatbids = Vec::with_capacity(result.winning_bids.len());
@@ -304,8 +309,157 @@ pub fn convert_to_openrtb_response(
304309
message: "Failed to serialize auction response".to_string(),
305310
})?;
306311

307-
Ok(Response::from_status(StatusCode::OK)
312+
let mut response = Response::from_status(StatusCode::OK)
308313
.with_header(header::CONTENT_TYPE, "application/json")
309314
.with_header(HEADER_X_TS_EC, &auction_request.user.id)
310-
.with_body(body_bytes))
315+
.with_body(body_bytes);
316+
317+
// Signal consent status independently of whether EIDs were resolved.
318+
// A user may have granted consent but have no partner syncs yet;
319+
// downstream clients rely on this header to know consent was verified.
320+
if ec_allowed {
321+
response.set_header(HEADER_X_TS_EC_CONSENT, "ok");
322+
}
323+
324+
// Attach EID response headers when consent-gated EIDs are available.
325+
// `Some(empty)` means "we looked and found no synced partners" — the
326+
// header is still set (with an encoded empty array) so clients can
327+
// distinguish this from `None` (EIDs not checked / consent denied).
328+
if let Some(ref eids) = auction_request.user.eids {
329+
let (encoded, truncated) = encode_eids_header(eids)?;
330+
response.set_header(HEADER_X_TS_EIDS, encoded);
331+
if truncated {
332+
response.set_header(HEADER_X_TS_EIDS_TRUNCATED, "true");
333+
}
334+
}
335+
336+
Ok(response)
337+
}
338+
339+
#[cfg(test)]
340+
mod tests {
341+
use super::*;
342+
use crate::auction::orchestrator::OrchestrationResult;
343+
use crate::auction::types::{AdFormat, AdSlot, MediaType};
344+
use crate::constants::{HEADER_X_TS_EC_CONSENT, HEADER_X_TS_EIDS, HEADER_X_TS_EIDS_TRUNCATED};
345+
use crate::openrtb::{Eid, Uid};
346+
347+
fn make_minimal_auction_request() -> AuctionRequest {
348+
AuctionRequest {
349+
id: "test-auction".to_owned(),
350+
slots: vec![AdSlot {
351+
id: "slot-1".to_owned(),
352+
formats: vec![AdFormat {
353+
media_type: MediaType::Banner,
354+
width: 300,
355+
height: 250,
356+
}],
357+
floor_price: None,
358+
targeting: HashMap::new(),
359+
bidders: HashMap::new(),
360+
}],
361+
publisher: PublisherInfo {
362+
domain: "test.com".to_owned(),
363+
page_url: None,
364+
},
365+
user: UserInfo {
366+
id: "test-ec-id".to_owned(),
367+
consent: None,
368+
eids: None,
369+
},
370+
device: None,
371+
site: None,
372+
context: HashMap::new(),
373+
}
374+
}
375+
376+
fn make_empty_result() -> OrchestrationResult {
377+
OrchestrationResult {
378+
winning_bids: HashMap::new(),
379+
provider_responses: Vec::new(),
380+
mediator_response: None,
381+
total_time_ms: 10,
382+
metadata: HashMap::new(),
383+
}
384+
}
385+
386+
fn make_settings() -> Settings {
387+
crate::test_support::tests::create_test_settings()
388+
}
389+
390+
#[test]
391+
fn response_includes_eid_headers_when_eids_present() {
392+
let mut request = make_minimal_auction_request();
393+
request.user.eids = Some(vec![Eid {
394+
source: "ssp.com".to_owned(),
395+
uids: vec![Uid {
396+
id: "uid-1".to_owned(),
397+
atype: Some(3),
398+
ext: None,
399+
}],
400+
}]);
401+
402+
let settings = make_settings();
403+
let result = make_empty_result();
404+
405+
let response = convert_to_openrtb_response(&result, &settings, &request, true)
406+
.expect("should build response");
407+
408+
assert!(
409+
response.get_header(HEADER_X_TS_EIDS).is_some(),
410+
"should include x-ts-eids header when EIDs are present"
411+
);
412+
assert_eq!(
413+
response
414+
.get_header(HEADER_X_TS_EC_CONSENT)
415+
.and_then(|v| v.to_str().ok()),
416+
Some("ok"),
417+
"should include x-ts-ec-consent: ok when ec_allowed is true"
418+
);
419+
assert!(
420+
response.get_header(HEADER_X_TS_EIDS_TRUNCATED).is_none(),
421+
"should not include truncated header for small payload"
422+
);
423+
}
424+
425+
#[test]
426+
fn response_sets_consent_header_even_without_eids() {
427+
let request = make_minimal_auction_request();
428+
let settings = make_settings();
429+
let result = make_empty_result();
430+
431+
let response = convert_to_openrtb_response(&result, &settings, &request, true)
432+
.expect("should build response");
433+
434+
assert_eq!(
435+
response
436+
.get_header(HEADER_X_TS_EC_CONSENT)
437+
.and_then(|v| v.to_str().ok()),
438+
Some("ok"),
439+
"should set x-ts-ec-consent: ok based on consent, not EID presence"
440+
);
441+
assert!(
442+
response.get_header(HEADER_X_TS_EIDS).is_none(),
443+
"should omit x-ts-eids when no EIDs available"
444+
);
445+
}
446+
447+
#[test]
448+
fn response_omits_consent_header_when_not_allowed() {
449+
let request = make_minimal_auction_request();
450+
let settings = make_settings();
451+
let result = make_empty_result();
452+
453+
let response = convert_to_openrtb_response(&result, &settings, &request, false)
454+
.expect("should build response");
455+
456+
assert!(
457+
response.get_header(HEADER_X_TS_EC_CONSENT).is_none(),
458+
"should omit x-ts-ec-consent when ec_allowed is false"
459+
);
460+
assert!(
461+
response.get_header(HEADER_X_TS_EIDS).is_none(),
462+
"should omit x-ts-eids when no EIDs available"
463+
);
464+
}
311465
}

crates/trusted-server-core/src/auction/orchestrator.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -627,6 +627,7 @@ mod tests {
627627
user: UserInfo {
628628
id: "user-123".to_string(),
629629
consent: None,
630+
eids: None,
630631
},
631632
device: None,
632633
site: None,

0 commit comments

Comments
 (0)