Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion crates/trusted-server-adapter-fastly/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,16 @@ async fn route_request(

// Unified auction endpoint (returns creative HTML inline)
(Method::POST, "/auction") => {
handle_auction(settings, orchestrator, kv_graph.as_ref(), &ec_context, req).await
let partner_store = require_partner_store(settings).ok();
handle_auction(
settings,
orchestrator,
kv_graph.as_ref(),
partner_store.as_ref(),
&ec_context,
req,
)
.await
}

// tsjs endpoints
Expand Down
144 changes: 141 additions & 3 deletions crates/trusted-server-core/src/auction/endpoints.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,13 @@ use error_stack::{Report, ResultExt};
use fastly::{Request, Response};

use crate::auction::formats::AdRequest;
use crate::consent::gate_eids_by_consent;
use crate::ec::eids::{resolve_partner_ids, to_eids};
use crate::ec::kv::KvIdentityGraph;
use crate::ec::partner::PartnerStore;
use crate::ec::EcContext;
use crate::error::TrustedServerError;
use crate::openrtb::Eid;
use crate::settings::Settings;

use super::formats::{convert_to_openrtb_response, convert_tsjs_to_auction_request};
Expand All @@ -29,7 +33,8 @@ use super::AuctionOrchestrator;
pub async fn handle_auction(
settings: &Settings,
orchestrator: &AuctionOrchestrator,
_kv: Option<&KvIdentityGraph>,
kv: Option<&KvIdentityGraph>,
partner_store: Option<&PartnerStore>,
ec_context: &EcContext,
mut req: Request,
) -> Result<Response, Report<TrustedServerError>> {
Expand Down Expand Up @@ -58,10 +63,22 @@ pub async fn handle_auction(
};
let consent_context = ec_context.consent().clone();

// Resolve partner EIDs from the KV identity graph when the user has
// a valid EC and both KV and partner stores are available.
let eids = resolve_auction_eids(kv, partner_store, ec_context);

// Convert tsjs request format to auction request
let auction_request =
let mut auction_request =
convert_tsjs_to_auction_request(&body, settings, &req, consent_context, ec_id)?;

// Apply consent gating to the resolved EIDs before attaching them to the
// auction request. `gate_eids_by_consent` checks TCF Purpose 1 + 4.
let had_eids = eids.as_ref().is_some_and(|v| !v.is_empty());
auction_request.user.eids = gate_eids_by_consent(eids, auction_request.user.consent.as_ref());
if had_eids && auction_request.user.eids.is_none() {
log::debug!("Auction EIDs stripped by TCF consent gating");
}

// Create auction context
let context = AuctionContext {
settings,
Expand All @@ -86,5 +103,126 @@ pub async fn handle_auction(
);

// Convert to OpenRTB response format with inline creative HTML
convert_to_openrtb_response(&result, settings, &auction_request)
convert_to_openrtb_response(&result, settings, &auction_request, ec_context.ec_allowed())
}

/// Resolves partner EIDs from the KV identity graph for bidstream decoration.
///
/// Returns `None` when any prerequisite is missing (no KV store, no partner
/// store, no EC, consent denied). On KV or partner-resolution errors, logs a
/// warning and returns empty EIDs so the auction can proceed in degraded mode.
fn resolve_auction_eids(
kv: Option<&KvIdentityGraph>,
partner_store: Option<&PartnerStore>,
ec_context: &EcContext,
) -> Option<Vec<Eid>> {
let kv = kv?;
let partner_store = partner_store?;

if !ec_context.ec_allowed() {
return None;
}

let ec_hash = ec_context.ec_hash()?;

let entry = match kv.get(ec_hash) {
Ok(Some((entry, _generation))) => entry,
Ok(None) => return Some(Vec::new()),
Err(err) => {
log::warn!("Auction KV read failed for EC hash '{ec_hash}': {err:?}");
return Some(Vec::new());
}
};

match resolve_partner_ids(partner_store, &entry) {
Ok(resolved) => Some(to_eids(&resolved)),
Err(err) => {
log::warn!("Auction partner resolution failed: {err:?}");
Some(Vec::new())
}
}
}

#[cfg(test)]
mod tests {
use super::*;
use crate::consent::jurisdiction::Jurisdiction;
use crate::consent::types::ConsentContext;

fn make_ec_context(jurisdiction: Jurisdiction, ec_value: Option<&str>) -> EcContext {
EcContext::new_for_test(
ec_value.map(str::to_owned),
ConsentContext {
jurisdiction,
..ConsentContext::default()
},
)
}

#[test]
fn resolve_auction_eids_returns_none_without_kv() {
let partner_store = PartnerStore::new("test_store");
let ec_id = format!("{}.ABC123", "a".repeat(64));
let ec_context = make_ec_context(Jurisdiction::NonRegulated, Some(&ec_id));

let result = resolve_auction_eids(None, Some(&partner_store), &ec_context);
assert!(result.is_none(), "should return None when KV is missing");
}

#[test]
fn resolve_auction_eids_returns_none_without_partner_store() {
let kv = KvIdentityGraph::new("test_store");
let ec_id = format!("{}.ABC123", "a".repeat(64));
let ec_context = make_ec_context(Jurisdiction::NonRegulated, Some(&ec_id));

let result = resolve_auction_eids(Some(&kv), None, &ec_context);
assert!(
result.is_none(),
"should return None when partner store is missing"
);
}

#[test]
fn resolve_auction_eids_returns_none_when_consent_denied() {
let kv = KvIdentityGraph::new("test_store");
let partner_store = PartnerStore::new("test_store");
let ec_id = format!("{}.ABC123", "a".repeat(64));
let ec_context = make_ec_context(Jurisdiction::Unknown, Some(&ec_id));

let result = resolve_auction_eids(Some(&kv), Some(&partner_store), &ec_context);
assert!(
result.is_none(),
"should return None when consent is denied"
);
}

#[test]
fn resolve_auction_eids_returns_none_when_no_ec() {
let kv = KvIdentityGraph::new("test_store");
let partner_store = PartnerStore::new("test_store");
let ec_context = make_ec_context(Jurisdiction::NonRegulated, None);

let result = resolve_auction_eids(Some(&kv), Some(&partner_store), &ec_context);
assert!(
result.is_none(),
"should return None when no EC value is present"
);
}

#[test]
fn resolve_auction_eids_returns_empty_on_kv_miss() {
let kv = KvIdentityGraph::new("nonexistent_store");
let partner_store = PartnerStore::new("nonexistent_store");
let ec_id = format!("{}.ABC123", "a".repeat(64));
let ec_context = make_ec_context(Jurisdiction::NonRegulated, Some(&ec_id));

// KV store doesn't exist, so the get() call will error — should return
// empty Vec (degraded mode), not None.
let result = resolve_auction_eids(Some(&kv), Some(&partner_store), &ec_context);
let eids = result.expect("should return Some on KV error (degraded mode)");
assert!(
eids.is_empty(),
"should return empty vec on KV error (degraded mode)"
);
}
}
160 changes: 157 additions & 3 deletions crates/trusted-server-core/src/auction/formats.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,11 @@ use uuid::Uuid;

use crate::auction::context::ContextValue;
use crate::consent::ConsentContext;
use crate::constants::HEADER_X_TS_EC;
use crate::constants::{
HEADER_X_TS_EC, HEADER_X_TS_EC_CONSENT, HEADER_X_TS_EIDS, HEADER_X_TS_EIDS_TRUNCATED,
};
use crate::creative;
use crate::ec::eids::encode_eids_header;
use crate::error::TrustedServerError;
use crate::geo::GeoInfo;
use crate::openrtb::{to_openrtb_i32, OpenRtbBid, OpenRtbResponse, ResponseExt, SeatBid, ToExt};
Expand Down Expand Up @@ -181,6 +184,7 @@ pub fn convert_tsjs_to_auction_request(
user: UserInfo {
id: ec_id,
consent: Some(consent),
eids: None,
},
device,
site: Some(SiteInfo {
Expand All @@ -204,6 +208,7 @@ pub fn convert_to_openrtb_response(
result: &OrchestrationResult,
settings: &Settings,
auction_request: &AuctionRequest,
ec_allowed: bool,
) -> Result<Response, Report<TrustedServerError>> {
// Build OpenRTB-style seatbid array
let mut seatbids = Vec::with_capacity(result.winning_bids.len());
Expand Down Expand Up @@ -304,8 +309,157 @@ pub fn convert_to_openrtb_response(
message: "Failed to serialize auction response".to_string(),
})?;

Ok(Response::from_status(StatusCode::OK)
let mut response = Response::from_status(StatusCode::OK)
.with_header(header::CONTENT_TYPE, "application/json")
.with_header(HEADER_X_TS_EC, &auction_request.user.id)
.with_body(body_bytes))
.with_body(body_bytes);

// Signal consent status independently of whether EIDs were resolved.
// A user may have granted consent but have no partner syncs yet;
// downstream clients rely on this header to know consent was verified.
if ec_allowed {
response.set_header(HEADER_X_TS_EC_CONSENT, "ok");
}

// Attach EID response headers when consent-gated EIDs are available.
// `Some(empty)` means "we looked and found no synced partners" — the
// header is still set (with an encoded empty array) so clients can
// distinguish this from `None` (EIDs not checked / consent denied).
if let Some(ref eids) = auction_request.user.eids {
let (encoded, truncated) = encode_eids_header(eids)?;
response.set_header(HEADER_X_TS_EIDS, encoded);
if truncated {
response.set_header(HEADER_X_TS_EIDS_TRUNCATED, "true");
}
}

Ok(response)
}

#[cfg(test)]
mod tests {
use super::*;
use crate::auction::orchestrator::OrchestrationResult;
use crate::auction::types::{AdFormat, AdSlot, MediaType};
use crate::constants::{HEADER_X_TS_EC_CONSENT, HEADER_X_TS_EIDS, HEADER_X_TS_EIDS_TRUNCATED};
use crate::openrtb::{Eid, Uid};

fn make_minimal_auction_request() -> AuctionRequest {
AuctionRequest {
id: "test-auction".to_owned(),
slots: vec![AdSlot {
id: "slot-1".to_owned(),
formats: vec![AdFormat {
media_type: MediaType::Banner,
width: 300,
height: 250,
}],
floor_price: None,
targeting: HashMap::new(),
bidders: HashMap::new(),
}],
publisher: PublisherInfo {
domain: "test.com".to_owned(),
page_url: None,
},
user: UserInfo {
id: "test-ec-id".to_owned(),
consent: None,
eids: None,
},
device: None,
site: None,
context: HashMap::new(),
}
}

fn make_empty_result() -> OrchestrationResult {
OrchestrationResult {
winning_bids: HashMap::new(),
provider_responses: Vec::new(),
mediator_response: None,
total_time_ms: 10,
metadata: HashMap::new(),
}
}

fn make_settings() -> Settings {
crate::test_support::tests::create_test_settings()
}

#[test]
fn response_includes_eid_headers_when_eids_present() {
let mut request = make_minimal_auction_request();
request.user.eids = Some(vec![Eid {
source: "ssp.com".to_owned(),
uids: vec![Uid {
id: "uid-1".to_owned(),
atype: Some(3),
ext: None,
}],
}]);

let settings = make_settings();
let result = make_empty_result();

let response = convert_to_openrtb_response(&result, &settings, &request, true)
.expect("should build response");

assert!(
response.get_header(HEADER_X_TS_EIDS).is_some(),
"should include x-ts-eids header when EIDs are present"
);
assert_eq!(
response
.get_header(HEADER_X_TS_EC_CONSENT)
.and_then(|v| v.to_str().ok()),
Some("ok"),
"should include x-ts-ec-consent: ok when ec_allowed is true"
);
assert!(
response.get_header(HEADER_X_TS_EIDS_TRUNCATED).is_none(),
"should not include truncated header for small payload"
);
}

#[test]
fn response_sets_consent_header_even_without_eids() {
let request = make_minimal_auction_request();
let settings = make_settings();
let result = make_empty_result();

let response = convert_to_openrtb_response(&result, &settings, &request, true)
.expect("should build response");

assert_eq!(
response
.get_header(HEADER_X_TS_EC_CONSENT)
.and_then(|v| v.to_str().ok()),
Some("ok"),
"should set x-ts-ec-consent: ok based on consent, not EID presence"
);
assert!(
response.get_header(HEADER_X_TS_EIDS).is_none(),
"should omit x-ts-eids when no EIDs available"
);
}

#[test]
fn response_omits_consent_header_when_not_allowed() {
let request = make_minimal_auction_request();
let settings = make_settings();
let result = make_empty_result();

let response = convert_to_openrtb_response(&result, &settings, &request, false)
.expect("should build response");

assert!(
response.get_header(HEADER_X_TS_EC_CONSENT).is_none(),
"should omit x-ts-ec-consent when ec_allowed is false"
);
assert!(
response.get_header(HEADER_X_TS_EIDS).is_none(),
"should omit x-ts-eids when no EIDs available"
);
}
}
1 change: 1 addition & 0 deletions crates/trusted-server-core/src/auction/orchestrator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -627,6 +627,7 @@ mod tests {
user: UserInfo {
id: "user-123".to_string(),
consent: None,
eids: None,
},
device: None,
site: None,
Expand Down
Loading
Loading