diff --git a/crates/trusted-server-adapter-fastly/src/main.rs b/crates/trusted-server-adapter-fastly/src/main.rs index d7fba408..dee4105b 100644 --- a/crates/trusted-server-adapter-fastly/src/main.rs +++ b/crates/trusted-server-adapter-fastly/src/main.rs @@ -12,8 +12,10 @@ use trusted_server_core::constants::{ }; use trusted_server_core::ec::admin::handle_register_partner; use trusted_server_core::ec::finalize::ec_finalize_response; +use trusted_server_core::ec::identify::{cors_preflight_identify, handle_identify}; use trusted_server_core::ec::kv::KvIdentityGraph; use trusted_server_core::ec::partner::PartnerStore; +use trusted_server_core::ec::sync_pixel::handle_sync; use trusted_server_core::ec::EcContext; use trusted_server_core::error::TrustedServerError; use trusted_server_core::geo::GeoInfo; @@ -137,6 +139,18 @@ async fn route_request( require_partner_store(settings).and_then(|store| handle_register_partner(&store, req)) } + (Method::GET, "/sync") => require_identity_graph(settings).and_then(|kv| { + require_partner_store(settings).and_then(|partner_store| { + handle_sync(settings, &kv, &partner_store, &req, &mut ec_context) + }) + }), + (Method::GET, "/identify") => require_identity_graph(settings).and_then(|kv| { + require_partner_store(settings).and_then(|partner_store| { + handle_identify(settings, &kv, &partner_store, &req, &ec_context) + }) + }), + (Method::OPTIONS, "/identify") => cors_preflight_identify(settings, &req), + // Unified auction endpoint (returns creative HTML inline) (Method::POST, "/auction") => { handle_auction(settings, orchestrator, kv_graph.as_ref(), &ec_context, req).await @@ -268,3 +282,17 @@ fn require_partner_store(settings: &Settings) -> Result Result> { + let store_name = settings.ec.ec_store.as_deref().ok_or_else(|| { + Report::new(TrustedServerError::KvStore { + store_name: "ec.ec_store".to_owned(), + message: "ec.ec_store is not configured".to_owned(), + }) + })?; + Ok(KvIdentityGraph::new(store_name)) +} diff --git a/crates/trusted-server-core/src/constants.rs b/crates/trusted-server-core/src/constants.rs index 0294cd25..d7047a4c 100644 --- a/crates/trusted-server-core/src/constants.rs +++ b/crates/trusted-server-core/src/constants.rs @@ -4,6 +4,9 @@ pub const COOKIE_TS_EC: &str = "ts-ec"; pub const HEADER_X_PUB_USER_ID: HeaderName = HeaderName::from_static("x-pub-user-id"); pub const HEADER_X_TS_EC: HeaderName = HeaderName::from_static("x-ts-ec"); +pub const HEADER_X_TS_EIDS: HeaderName = HeaderName::from_static("x-ts-eids"); +pub const HEADER_X_TS_EC_CONSENT: HeaderName = HeaderName::from_static("x-ts-ec-consent"); +pub const HEADER_X_TS_EIDS_TRUNCATED: HeaderName = HeaderName::from_static("x-ts-eids-truncated"); pub const HEADER_X_CONSENT_ADVERTISING: HeaderName = HeaderName::from_static("x-consent-advertising"); pub const HEADER_X_FORWARDED_FOR: HeaderName = HeaderName::from_static("x-forwarded-for"); @@ -44,6 +47,9 @@ pub const HEADER_REFERER: HeaderName = HeaderName::from_static("referer"); /// in `const` context. pub const INTERNAL_HEADERS: &[&str] = &[ "x-ts-ec", + "x-ts-eids", + "x-ts-ec-consent", + "x-ts-eids-truncated", "x-pub-user-id", "x-subject-id", "x-consent-advertising", diff --git a/crates/trusted-server-core/src/ec/identify.rs b/crates/trusted-server-core/src/ec/identify.rs new file mode 100644 index 00000000..d25fdd1c --- /dev/null +++ b/crates/trusted-server-core/src/ec/identify.rs @@ -0,0 +1,512 @@ +//! Identity lookup endpoint (`GET /identify`). + +use std::collections::HashMap; + +use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _}; +use error_stack::{Report, ResultExt}; +use fastly::http::{header, StatusCode}; +use fastly::{Request, Response}; +use url::Url; + +use crate::consent::allows_ec_creation; +use crate::constants::{ + HEADER_X_TS_EC, HEADER_X_TS_EC_CONSENT, HEADER_X_TS_EIDS, HEADER_X_TS_EIDS_TRUNCATED, +}; +use crate::error::TrustedServerError; +use crate::openrtb::{Eid, Uid}; +use crate::settings::Settings; + +use super::kv::KvIdentityGraph; +use super::kv_types::KvEntry; +use super::partner::PartnerStore; +use super::EcContext; + +const MAX_EXPOSE_PARTNER_HEADERS: usize = 20; +const MAX_EIDS_HEADER_BYTES: usize = 4096; + +/// Handles `GET /identify`. +/// +/// # Errors +/// +/// Returns [`TrustedServerError`] for response serialization issues. +pub fn handle_identify( + settings: &Settings, + kv: &KvIdentityGraph, + partner_store: &PartnerStore, + req: &Request, + ec_context: &EcContext, +) -> Result> { + let cors = classify_origin(req, settings); + if matches!(cors, CorsDecision::Denied) { + return Ok(Response::from_status(StatusCode::FORBIDDEN)); + } + + if !allows_ec_creation(ec_context.consent()) { + let mut response = json_response( + StatusCode::FORBIDDEN, + &serde_json::json!({ "consent": "denied" }), + )?; + if let CorsDecision::Allowed(origin) = cors { + apply_cors_headers(&mut response, &origin); + } + return Ok(response); + } + + let Some(ec_id) = ec_context.ec_value() else { + let mut response = Response::from_status(StatusCode::NO_CONTENT); + if let CorsDecision::Allowed(origin) = cors { + apply_cors_headers(&mut response, &origin); + } + return Ok(response); + }; + + let mut degraded = false; + let mut resolved = Vec::new(); + + if let Some(ec_hash) = ec_context.ec_hash() { + match kv.get(ec_hash) { + Ok(Some((entry, _generation))) => match resolve_partner_ids(partner_store, &entry) { + Ok(values) => { + resolved = values; + } + Err(err) => { + log::warn!("Identify partner resolution failed: {err:?}"); + degraded = true; + } + }, + Ok(None) => {} + Err(err) => { + log::warn!("Identify KV read failed for hash '{ec_hash}': {err:?}"); + degraded = true; + } + } + } + + let mut uids = HashMap::new(); + for item in &resolved { + uids.insert(item.partner_id.clone(), item.uid.clone()); + } + + let eids = to_eids(&resolved); + let body = IdentifyResponse { + ec: ec_id.to_owned(), + consent: "ok".to_owned(), + degraded, + uids, + eids, + }; + + let mut response = json_response(StatusCode::OK, &body)?; + response.set_header(HEADER_X_TS_EC, ec_id); + response.set_header(HEADER_X_TS_EC_CONSENT, "ok"); + + let mut expose_headers = vec![ + "x-ts-ec".to_owned(), + "x-ts-eids".to_owned(), + "x-ts-ec-consent".to_owned(), + "x-ts-eids-truncated".to_owned(), + ]; + + for item in resolved.iter().take(MAX_EXPOSE_PARTNER_HEADERS) { + let header_name = format!("x-ts-{}", item.partner_id); + response.set_header(&header_name, &item.uid); + expose_headers.push(header_name); + } + + let (encoded_eids, truncated) = build_eids_header(&resolved)?; + response.set_header(HEADER_X_TS_EIDS, encoded_eids); + if truncated { + response.set_header(HEADER_X_TS_EIDS_TRUNCATED, "true"); + } + + if let CorsDecision::Allowed(origin) = cors { + apply_cors_headers(&mut response, &origin); + response.set_header( + header::ACCESS_CONTROL_EXPOSE_HEADERS, + expose_headers.join(", "), + ); + } + + Ok(response) +} + +/// Handles `OPTIONS /identify` CORS preflight. +/// +/// # Errors +/// +/// Returns [`TrustedServerError`] when response construction fails. +pub fn cors_preflight_identify( + settings: &Settings, + req: &Request, +) -> Result> { + let mut response = match classify_origin(req, settings) { + CorsDecision::Denied => Response::from_status(StatusCode::FORBIDDEN), + CorsDecision::NoOrigin => Response::from_status(StatusCode::OK), + CorsDecision::Allowed(origin) => { + let mut response = Response::from_status(StatusCode::OK); + apply_cors_headers(&mut response, &origin); + response + } + }; + + response.set_body(Vec::new()); + Ok(response) +} + +#[derive(serde::Serialize)] +struct IdentifyResponse { + ec: String, + consent: String, + degraded: bool, + uids: HashMap, + eids: Vec, +} + +struct ResolvedPartnerId { + partner_id: String, + uid: String, + synced: u64, + source_domain: String, + openrtb_atype: u8, +} + +fn resolve_partner_ids( + partner_store: &PartnerStore, + entry: &KvEntry, +) -> Result, Report> { + let mut resolved = Vec::new(); + + for (partner_id, partner_uid) in &entry.ids { + if partner_uid.uid.is_empty() { + continue; + } + + let Some(partner) = partner_store.get(partner_id)? else { + continue; + }; + if !partner.bidstream_enabled { + continue; + } + + resolved.push(ResolvedPartnerId { + partner_id: partner_id.clone(), + uid: partner_uid.uid.clone(), + synced: partner_uid.synced, + source_domain: partner.source_domain, + openrtb_atype: partner.openrtb_atype, + }); + } + + resolved.sort_by(|a, b| b.synced.cmp(&a.synced)); + Ok(resolved) +} + +fn to_eids(resolved: &[ResolvedPartnerId]) -> Vec { + resolved + .iter() + .map(|item| Eid { + source: item.source_domain.clone(), + uids: vec![Uid { + id: item.uid.clone(), + atype: Some(item.openrtb_atype), + ext: None, + }], + }) + .collect() +} + +fn build_eids_header( + resolved: &[ResolvedPartnerId], +) -> Result<(String, bool), Report> { + for size in (0..=resolved.len()).rev() { + let eids = to_eids(&resolved[..size]); + let json = serde_json::to_vec(&eids).change_context(TrustedServerError::Configuration { + message: "Failed to serialize identify eids header payload".to_owned(), + })?; + let encoded = BASE64.encode(json); + + if encoded.len() <= MAX_EIDS_HEADER_BYTES { + return Ok((encoded, size != resolved.len())); + } + } + + Ok((BASE64.encode("[]"), true)) +} + +fn json_response( + status: StatusCode, + body: &T, +) -> Result> { + let body = serde_json::to_string(body).change_context(TrustedServerError::Configuration { + message: "Failed to serialize identify response".to_owned(), + })?; + + Ok(Response::from_status(status) + .with_content_type(fastly::mime::APPLICATION_JSON) + .with_body(body)) +} + +enum CorsDecision { + NoOrigin, + Allowed(String), + Denied, +} + +fn classify_origin(req: &Request, settings: &Settings) -> CorsDecision { + let Some(origin) = req.get_header(header::ORIGIN).and_then(|v| v.to_str().ok()) else { + return CorsDecision::NoOrigin; + }; + + let Ok(origin_url) = Url::parse(origin) else { + return CorsDecision::Denied; + }; + + let Some(host) = origin_url.host_str() else { + return CorsDecision::Denied; + }; + + let host = host.to_ascii_lowercase(); + let publisher_host = settings + .publisher + .domain + .trim_end_matches('.') + .to_ascii_lowercase(); + + if host == publisher_host || host.ends_with(&format!(".{publisher_host}")) { + return CorsDecision::Allowed(origin.to_owned()); + } + + CorsDecision::Denied +} + +fn apply_cors_headers(response: &mut Response, origin: &str) { + response.set_header(header::ACCESS_CONTROL_ALLOW_ORIGIN, origin); + response.set_header(header::ACCESS_CONTROL_ALLOW_CREDENTIALS, "true"); + response.set_header(header::ACCESS_CONTROL_ALLOW_METHODS, "GET, OPTIONS"); + response.set_header( + header::ACCESS_CONTROL_ALLOW_HEADERS, + "Cookie, X-ts-ec, X-consent-advertising", + ); + response.set_header(header::VARY, "Origin"); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::consent::jurisdiction::Jurisdiction; + use crate::consent::types::{ConsentContext, ConsentSource}; + use crate::test_support::tests::create_test_settings; + + fn make_ec_context(jurisdiction: Jurisdiction, ec_value: Option<&str>) -> EcContext { + EcContext { + ec_value: ec_value.map(str::to_owned), + cookie_ec_value: ec_value.map(str::to_owned), + ec_was_present: ec_value.is_some(), + ec_generated: false, + consent: ConsentContext { + jurisdiction, + source: ConsentSource::Cookie, + ..ConsentContext::default() + }, + client_ip: None, + geo_info: None, + } + } + + #[test] + fn classify_origin_accepts_publisher_subdomain() { + let settings = create_test_settings(); + let mut req = Request::new("GET", "https://edge.test-publisher.com/identify"); + req.set_header("origin", "https://www.test-publisher.com"); + + let decision = classify_origin(&req, &settings); + assert!( + matches!(decision, CorsDecision::Allowed(_)), + "should allow publisher subdomain origin" + ); + } + + #[test] + fn classify_origin_rejects_mismatch() { + let settings = create_test_settings(); + let mut req = Request::new("GET", "https://edge.test-publisher.com/identify"); + req.set_header("origin", "https://evil.com"); + + let decision = classify_origin(&req, &settings); + assert!( + matches!(decision, CorsDecision::Denied), + "should deny mismatched origin" + ); + } + + #[test] + fn classify_origin_allows_absent_origin_header() { + let settings = create_test_settings(); + let req = Request::new("GET", "https://edge.test-publisher.com/identify"); + + let decision = classify_origin(&req, &settings); + assert!( + matches!(decision, CorsDecision::NoOrigin), + "should allow no-origin requests" + ); + } + + #[test] + fn eids_header_truncates_when_too_large() { + let mut resolved = Vec::new(); + for idx in 0..64 { + resolved.push(ResolvedPartnerId { + partner_id: format!("p{idx}"), + uid: "x".repeat(200), + synced: 1000 - idx, + source_domain: format!("s{idx}.example.com"), + openrtb_atype: 3, + }); + } + + let (header, truncated) = + build_eids_header(&resolved).expect("should build capped eids header"); + assert!(truncated, "should truncate oversized eids header payload"); + assert!( + header.len() <= MAX_EIDS_HEADER_BYTES, + "should cap encoded header bytes" + ); + } + + #[test] + fn handle_identify_denied_consent_returns_403_json() { + let settings = create_test_settings(); + let kv = KvIdentityGraph::new("missing_store"); + let partner_store = PartnerStore::new("missing_store"); + let mut req = Request::new("GET", "https://edge.test-publisher.com/identify"); + req.set_header("origin", "https://www.test-publisher.com"); + let ec_context = make_ec_context(Jurisdiction::Unknown, None); + + let mut response = handle_identify(&settings, &kv, &partner_store, &req, &ec_context) + .expect("should construct denied response"); + + assert_eq!( + response.get_status(), + StatusCode::FORBIDDEN, + "should return 403 when consent denies EC" + ); + assert_eq!( + response + .get_header(header::ACCESS_CONTROL_ALLOW_ORIGIN) + .and_then(|v| v.to_str().ok()), + Some("https://www.test-publisher.com"), + "should include CORS allow-origin for approved publisher origin" + ); + + let body = serde_json::from_slice::(&response.take_body_bytes()) + .expect("should decode denied JSON body"); + assert_eq!( + body, + serde_json::json!({ "consent": "denied" }), + "should return denied consent payload" + ); + } + + #[test] + fn handle_identify_without_ec_returns_204() { + let settings = create_test_settings(); + let kv = KvIdentityGraph::new("missing_store"); + let partner_store = PartnerStore::new("missing_store"); + let mut req = Request::new("GET", "https://edge.test-publisher.com/identify"); + req.set_header("origin", "https://www.test-publisher.com"); + let ec_context = make_ec_context(Jurisdiction::NonRegulated, None); + + let response = handle_identify(&settings, &kv, &partner_store, &req, &ec_context) + .expect("should construct no-content response"); + + assert_eq!( + response.get_status(), + StatusCode::NO_CONTENT, + "should return 204 when EC is unavailable" + ); + assert_eq!( + response + .get_header(header::ACCESS_CONTROL_ALLOW_ORIGIN) + .and_then(|v| v.to_str().ok()), + Some("https://www.test-publisher.com"), + "should include CORS allow-origin for approved publisher origin" + ); + } + + #[test] + fn handle_identify_kv_failure_sets_degraded_true() { + let settings = create_test_settings(); + let kv = KvIdentityGraph::new("missing_store"); + let partner_store = PartnerStore::new("missing_store"); + let req = Request::new("GET", "https://edge.test-publisher.com/identify"); + let ec_id = format!("{}.ABC123", "a".repeat(64)); + let ec_context = make_ec_context(Jurisdiction::NonRegulated, Some(&ec_id)); + + let mut response = handle_identify(&settings, &kv, &partner_store, &req, &ec_context) + .expect("should construct degraded identify response"); + + assert_eq!( + response.get_status(), + StatusCode::OK, + "should return 200 on degraded KV read" + ); + let body = serde_json::from_slice::(&response.take_body_bytes()) + .expect("should decode identify response JSON"); + + assert_eq!(body["ec"], ec_id, "should echo EC in body"); + assert_eq!( + body["degraded"], + serde_json::Value::Bool(true), + "should mark response as degraded when KV read fails" + ); + assert_eq!( + body["uids"], + serde_json::json!({}), + "should emit empty uids" + ); + assert_eq!( + body["eids"], + serde_json::json!([]), + "should emit empty eids" + ); + } + + #[test] + fn identify_preflight_denies_mismatched_origin() { + let settings = create_test_settings(); + let mut req = Request::new("OPTIONS", "https://edge.test-publisher.com/identify"); + req.set_header("origin", "https://evil.example"); + + let response = + cors_preflight_identify(&settings, &req).expect("should construct preflight response"); + + assert_eq!( + response.get_status(), + StatusCode::FORBIDDEN, + "should reject preflight from non-publisher origin" + ); + } + + #[test] + fn identify_preflight_allows_publisher_origin() { + let settings = create_test_settings(); + let mut req = Request::new("OPTIONS", "https://edge.test-publisher.com/identify"); + req.set_header("origin", "https://www.test-publisher.com"); + + let response = + cors_preflight_identify(&settings, &req).expect("should construct preflight response"); + + assert_eq!( + response.get_status(), + StatusCode::OK, + "should allow preflight from publisher origin" + ); + assert_eq!( + response + .get_header(header::ACCESS_CONTROL_ALLOW_ORIGIN) + .and_then(|v| v.to_str().ok()), + Some("https://www.test-publisher.com"), + "should include CORS allow-origin header" + ); + } +} diff --git a/crates/trusted-server-core/src/ec/mod.rs b/crates/trusted-server-core/src/ec/mod.rs index cb440537..d7dfe480 100644 --- a/crates/trusted-server-core/src/ec/mod.rs +++ b/crates/trusted-server-core/src/ec/mod.rs @@ -20,15 +20,19 @@ //! - [`kv_types`] — Schema types for KV identity graph entries //! - [`partner`] — Partner registry (`PartnerRecord`, `PartnerStore`) //! - [`admin`] — Admin endpoints for partner management +//! - [`sync_pixel`] — Pixel sync write endpoint (`GET /sync`) +//! - [`identify`] — Browser identity read endpoint (`GET /identify`) pub mod admin; pub mod consent; pub mod cookies; pub mod finalize; pub mod generation; +pub mod identify; pub mod kv; pub mod kv_types; pub mod partner; +pub mod sync_pixel; use cookie::CookieJar; use error_stack::Report; @@ -287,6 +291,15 @@ impl EcContext { &self.consent } + /// Returns a mutable reference to the consent context. + /// + /// Used by `/sync` to apply query-param fallback consent for the current + /// request only when pre-routing consent extraction produced an empty + /// context. + pub fn consent_mut(&mut self) -> &mut ConsentContext { + &mut self.consent + } + /// Returns the normalized client IP, if available. #[must_use] pub fn client_ip(&self) -> Option<&str> { @@ -324,6 +337,12 @@ impl EcContext { (Some(cookie), Some(active)) if cookie != active ) } + + /// Returns the stable EC hash prefix from the active EC value. + #[must_use] + pub fn ec_hash(&self) -> Option<&str> { + self.ec_value.as_deref().map(generation::ec_hash) + } } fn current_timestamp() -> u64 { diff --git a/crates/trusted-server-core/src/ec/sync_pixel.rs b/crates/trusted-server-core/src/ec/sync_pixel.rs new file mode 100644 index 00000000..a129a65b --- /dev/null +++ b/crates/trusted-server-core/src/ec/sync_pixel.rs @@ -0,0 +1,432 @@ +//! Pixel sync endpoint (`GET /sync`). + +use error_stack::{Report, ResultExt}; +use fastly::erl::{CounterDuration, RateCounter}; +use fastly::http::StatusCode; +use fastly::{Request, Response}; +use url::Url; + +use crate::consent::{allows_ec_creation, gpp, tcf, ConsentContext}; +use crate::error::TrustedServerError; +use crate::settings::Settings; + +use super::generation::{ec_hash, is_valid_ec_id}; +use super::kv::KvIdentityGraph; +use super::partner::{PartnerRecord, PartnerStore}; +use super::EcContext; + +const RATE_COUNTER_NAME: &str = "counter_store"; + +/// Handles `GET /sync` pixel sync requests. +/// +/// # Errors +/// +/// Returns [`TrustedServerError`] when request validation fails (`400`) or +/// required stores are unavailable (`503`). +pub fn handle_sync( + _settings: &Settings, + kv: &KvIdentityGraph, + partner_store: &PartnerStore, + req: &Request, + ec_context: &mut EcContext, +) -> Result> { + let query = SyncQuery::parse(req)?; + + let partner = partner_store.get(&query.partner)?.ok_or_else(|| { + Report::new(TrustedServerError::BadRequest { + message: format!("unknown partner '{}'", query.partner), + }) + })?; + + let return_url = validate_return_url(&query.return_url, &partner)?; + + let Some(cookie_ec_id) = ec_context + .existing_cookie_ec_id() + .filter(|v| is_valid_ec_id(v)) + .map(str::to_owned) + else { + return Ok(redirect_with_status(&return_url, "0", Some("no_ec"))); + }; + + if ec_context.consent().is_empty() { + if let Some(consent_query) = query.consent.as_deref() { + if let Some(fallback) = + decode_query_fallback_consent(ec_context.consent(), consent_query) + { + *ec_context.consent_mut() = fallback; + } + } + } + + if !allows_ec_creation(ec_context.consent()) { + return Ok(redirect_with_status(&return_url, "0", Some("no_consent"))); + } + + let hash = ec_hash(&cookie_ec_id); + let limiter = FastlyRateLimiter::new(RATE_COUNTER_NAME); + if limiter.exceeded(&format!("{}:{hash}", partner.id), partner.sync_rate_limit)? { + return Ok(Response::from_status(StatusCode::TOO_MANY_REQUESTS) + .with_body_text_plain("rate_limit_exceeded")); + } + + let now = current_timestamp(); + if let Err(err) = kv.upsert_partner_id(hash, &partner.id, &query.uid, now) { + log::warn!( + "Pixel sync write failed for partner '{}' and hash '{}': {err:?}", + partner.id, + hash, + ); + return Ok(redirect_with_status(&return_url, "0", Some("write_failed"))); + } + + Ok(redirect_with_status(&return_url, "1", None)) +} + +#[derive(Debug)] +struct SyncQuery { + partner: String, + uid: String, + return_url: String, + consent: Option, +} + +impl SyncQuery { + fn parse(req: &Request) -> Result> { + let mut partner = None; + let mut uid = None; + let mut return_url = None; + let mut consent = None; + + let raw_query = req.get_query_str().unwrap_or(""); + for (key, value) in url::form_urlencoded::parse(raw_query.as_bytes()) { + match key.as_ref() { + "partner" => partner = Some(value.into_owned()), + "uid" => uid = Some(value.into_owned()), + "return" => return_url = Some(value.into_owned()), + "consent" => consent = Some(value.into_owned()), + _ => {} + } + } + + Ok(Self { + partner: required_query_param(partner, "partner")?, + uid: required_query_param(uid, "uid")?, + return_url: required_query_param(return_url, "return")?, + consent, + }) + } +} + +fn required_query_param( + value: Option, + key: &str, +) -> Result> { + let Some(value) = value else { + return Err(Report::new(TrustedServerError::BadRequest { + message: format!("missing required query parameter '{key}'"), + })); + }; + + if value.trim().is_empty() { + return Err(Report::new(TrustedServerError::BadRequest { + message: format!("query parameter '{key}' must not be empty"), + })); + } + + Ok(value) +} + +fn validate_return_url( + return_url: &str, + partner: &PartnerRecord, +) -> Result> { + let parsed = Url::parse(return_url).change_context(TrustedServerError::BadRequest { + message: "return URL must be a valid absolute URL".to_owned(), + })?; + + let host = parsed + .host_str() + .ok_or_else(|| { + Report::new(TrustedServerError::BadRequest { + message: "return URL must include a hostname".to_owned(), + }) + })? + .trim_end_matches('.') + .to_ascii_lowercase(); + + let allowed = partner + .allowed_return_domains + .iter() + .map(|domain| domain.trim().trim_end_matches('.').to_ascii_lowercase()) + .any(|domain| domain == host); + + if !allowed { + return Err(Report::new(TrustedServerError::BadRequest { + message: format!( + "return URL host '{host}' is not allowed for partner '{}'", + partner.id + ), + })); + } + + Ok(parsed) +} + +fn redirect_with_status(return_url: &Url, synced: &str, reason: Option<&str>) -> Response { + let mut url = return_url.clone(); + { + let mut query = url.query_pairs_mut(); + query.append_pair("ts_synced", synced); + if let Some(reason) = reason { + query.append_pair("ts_reason", reason); + } + } + + Response::from_status(StatusCode::FOUND).with_header("location", url.as_str()) +} + +fn decode_query_fallback_consent( + base: &ConsentContext, + raw_consent: &str, +) -> Option { + if raw_consent.trim().is_empty() { + return None; + } + + let mut consent = ConsentContext { + jurisdiction: base.jurisdiction.clone(), + gpc: base.gpc, + ..ConsentContext::default() + }; + + if raw_consent.contains('~') || raw_consent.starts_with("DB") { + match gpp::decode_gpp_string(raw_consent) { + Ok(decoded) => { + consent.raw_gpp_string = Some(raw_consent.to_owned()); + consent.gpp_section_ids = Some(decoded.section_ids.clone()); + consent.tcf = decoded.eu_tcf.clone(); + consent.gpp = Some(decoded); + consent.gdpr_applies = consent + .gpp_section_ids + .as_ref() + .is_some_and(|sids| sids.contains(&2)); + return Some(consent); + } + Err(err) => { + log::warn!("Failed to decode GPP consent query fallback: {err:?}"); + return None; + } + } + } + + match tcf::decode_tc_string(raw_consent) { + Ok(decoded) => { + consent.raw_tc_string = Some(raw_consent.to_owned()); + consent.tcf = Some(decoded); + consent.gdpr_applies = true; + Some(consent) + } + Err(err) => { + log::warn!("Failed to decode TCF consent query fallback: {err:?}"); + None + } + } +} + +trait RateLimiter { + fn exceeded(&self, key: &str, hourly_limit: u32) -> Result>; +} + +struct FastlyRateLimiter { + counter: RateCounter, +} + +impl FastlyRateLimiter { + fn new(counter_name: &str) -> Self { + Self { + counter: RateCounter::open(counter_name), + } + } +} + +impl RateLimiter for FastlyRateLimiter { + fn exceeded(&self, key: &str, hourly_limit: u32) -> Result> { + // Fastly's public rate-counter API currently exposes windows up to 60s. + // Approximate the story's 1h limit by converting to a per-minute budget. + // + // Follow-up: move to exact 1-hour enforcement once platform counters + // expose longer windows or we add a dedicated KV-backed hour bucket. + let per_minute_limit = hourly_limit.saturating_add(59) / 60; + let per_minute_limit = per_minute_limit.max(1); + + let current = self + .counter + .lookup_count(key, CounterDuration::SixtySecs) + .map_err(|e| { + Report::new(TrustedServerError::KvStore { + store_name: RATE_COUNTER_NAME.to_owned(), + message: format!("Failed to read sync rate counter: {e}"), + }) + })?; + + if current >= per_minute_limit { + return Ok(true); + } + + self.counter.increment(key, 1).map_err(|e| { + Report::new(TrustedServerError::KvStore { + store_name: RATE_COUNTER_NAME.to_owned(), + message: format!("Failed to increment sync rate counter: {e}"), + }) + })?; + + Ok(false) + } +} + +fn current_timestamp() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample_partner() -> PartnerRecord { + PartnerRecord { + id: "ssp_x".to_owned(), + name: "SSP X".to_owned(), + allowed_return_domains: vec!["sync.example.com".to_owned()], + api_key_hash: "deadbeef".to_owned(), + bidstream_enabled: false, + source_domain: "ssp.example.com".to_owned(), + openrtb_atype: 3, + sync_rate_limit: 100, + batch_rate_limit: 60, + pull_sync_enabled: false, + pull_sync_url: None, + pull_sync_allowed_domains: vec![], + pull_sync_ttl_sec: 86400, + pull_sync_rate_limit: 10, + ts_pull_token: None, + } + } + + #[test] + fn redirect_appends_query_when_url_has_none() { + let url = Url::parse("https://sync.example.com/return").expect("should parse URL"); + let response = redirect_with_status(&url, "1", None); + let location = response + .get_header("location") + .expect("should set location header") + .to_str() + .expect("should convert location to UTF-8"); + + assert_eq!( + location, "https://sync.example.com/return?ts_synced=1", + "should append query with ? when missing" + ); + } + + #[test] + fn redirect_appends_query_when_url_already_has_query() { + let url = Url::parse("https://sync.example.com/return?foo=bar").expect("should parse URL"); + let response = redirect_with_status(&url, "0", Some("no_ec")); + let location = response + .get_header("location") + .expect("should set location header") + .to_str() + .expect("should convert location to UTF-8"); + + assert_eq!( + location, "https://sync.example.com/return?foo=bar&ts_synced=0&ts_reason=no_ec", + "should append sync status after existing query" + ); + } + + #[test] + fn fallback_decodes_tcf() { + let base = ConsentContext::default(); + let decoded = + decode_query_fallback_consent(&base, "CPXxGfAPXxGfAAfKABENB-CgAAAAAAAAAAYgAAAAAAAA") + .expect("should decode TCF fallback"); + + assert!( + decoded.raw_tc_string.is_some(), + "should store raw TC string" + ); + } + + #[test] + fn query_parse_rejects_missing_required_param() { + let req = Request::new("GET", "https://edge.example.com/sync?partner=ssp&uid=u1"); + let err = SyncQuery::parse(&req).expect_err("should fail when return param is missing"); + assert!( + err.to_string() + .contains("missing required query parameter 'return'"), + "should mention missing required return parameter" + ); + } + + #[test] + fn query_parse_rejects_empty_required_param() { + let req = Request::new( + "GET", + "https://edge.example.com/sync?partner=ssp&uid=u1&return= ", + ); + let err = SyncQuery::parse(&req).expect_err("should fail when return param is empty"); + assert!( + err.to_string() + .contains("query parameter 'return' must not be empty"), + "should reject empty required return parameter" + ); + } + + #[test] + fn return_url_validation_rejects_subdomain_spoofing() { + let partner = sample_partner(); + let err = validate_return_url("https://a.sync.example.com/callback", &partner) + .expect_err("should reject return host not exactly allowlisted"); + + assert!( + err.to_string().contains("is not allowed"), + "should reject non-exact allowlist host" + ); + } + + #[test] + fn return_url_validation_rejects_relative_url() { + let partner = sample_partner(); + let err = validate_return_url("/callback", &partner) + .expect_err("should reject non-absolute return URL"); + assert!( + err.to_string().contains("valid absolute URL"), + "should require absolute return URLs" + ); + } + + #[test] + fn fallback_decodes_gpp() { + let base = ConsentContext::default(); + let decoded = decode_query_fallback_consent(&base, "DBABTA~1YNN") + .expect("should decode valid GPP fallback"); + + assert!( + decoded.raw_gpp_string.is_some(), + "should store raw GPP string" + ); + } + + #[test] + fn fallback_returns_none_for_invalid_consent_string() { + let base = ConsentContext::default(); + let decoded = decode_query_fallback_consent(&base, "not-a-valid-consent"); + assert!( + decoded.is_none(), + "should ignore undecodable consent fallback" + ); + } +}