diff --git a/crates/trusted-server-core/src/auction/README.md b/crates/trusted-server-core/src/auction/README.md index d6f4483a..bac2f3c3 100644 --- a/crates/trusted-server-core/src/auction/README.md +++ b/crates/trusted-server-core/src/auction/README.md @@ -257,18 +257,18 @@ The trusted-server handles several types of routes defined in `crates/trusted-se | Route | Method | Handler | Purpose | Line | |---------------------------|--------|--------------------------------|--------------------------------------------------|------| -| `/auction` | POST | `handle_auction()` | Main auction endpoint (Prebid.js/tsjs format) | 84 | -| `/first-party/proxy` | GET | `handle_first_party_proxy()` | Proxy creatives through first-party domain | 84 | -| `/first-party/click` | GET | `handle_first_party_click()` | Track clicks on ads | 85 | -| `/first-party/sign` | GET/POST | `handle_first_party_proxy_sign()` | Generate signed URLs for creatives | 86 | -| `/first-party/proxy-rebuild` | POST | `handle_first_party_proxy_rebuild()` | Rebuild creative HTML with new settings | 89 | -| `/static/tsjs=*` | GET | `handle_tsjs_dynamic()` | Serve tsjs library (Prebid.js alternative) | 66 | -| `/.well-known/ts.jwks.json` | GET | `handle_jwks_endpoint()` | Public key distribution for request signing | 71 | -| `/verify-signature` | POST | `handle_verify_signature()` | Verify signed requests | 74 | -| `/admin/keys/rotate` | POST | `handle_rotate_key()` | Rotate signing keys (admin only) | 77 | -| `/admin/keys/deactivate` | POST | `handle_deactivate_key()` | Deactivate signing keys (admin only) | 78 | -| `/integrations/*` | * | Integration Registry | Provider-specific endpoints (Prebid, etc.) | 92 | -| `*` (fallback) | * | `handle_publisher_request()` | Proxy to publisher origin | 108 | +| `/auction` | POST | `handle_auction()` | Main auction endpoint (Prebid.js/tsjs format) | 162 | +| `/first-party/proxy` | GET | `handle_first_party_proxy()` | Proxy creatives through first-party domain | 167 | +| `/first-party/click` | GET | `handle_first_party_click()` | Track clicks on ads | 170 | +| `/first-party/sign` | GET/POST | `handle_first_party_proxy_sign()` | Generate signed URLs for creatives | 173 | +| `/first-party/proxy-rebuild` | POST | `handle_first_party_proxy_rebuild()` | Rebuild creative HTML with new settings | 176 | +| `/static/tsjs=*` | GET | `handle_tsjs_dynamic()` | Serve tsjs library (Prebid.js alternative) | 145 | +| `/.well-known/trusted-server.json` | GET | `handle_trusted_server_discovery()` | Public key distribution for request signing | 149 | +| `/verify-signature` | POST | `handle_verify_signature()` | Verify signed requests | 154 | +| `/admin/keys/rotate` | POST | `handle_rotate_key()` | Rotate signing keys (admin only) | 158 | +| `/admin/keys/deactivate` | POST | `handle_deactivate_key()` | Deactivate signing keys (admin only) | 159 | +| `/integrations/*` | * | Integration Registry | Provider-specific endpoints (Prebid, etc.) | 179 | +| `*` (fallback) | * | `handle_publisher_request()` | Proxy to publisher origin | 195 | ### How Routing Works @@ -277,22 +277,50 @@ The Fastly Compute entrypoint uses pattern matching on `(Method, path)` tuples: ```rust let result = match (method, path.as_str()) { - // Auction endpoint + (Method::GET, path) if path.starts_with("/static/tsjs=") => { + handle_tsjs_dynamic(&req, integration_registry) + } + (Method::GET, "/.well-known/trusted-server.json") => { + handle_trusted_server_discovery(settings, runtime_services, req) + } + (Method::POST, "/verify-signature") => handle_verify_signature(settings, req), + (Method::POST, "/admin/keys/rotate") => handle_rotate_key(settings, req), + (Method::POST, "/admin/keys/deactivate") => handle_deactivate_key(settings, req), (Method::POST, "/auction") => { - handle_auction(&settings, &orchestrator, &runtime_services, req).await - }, - - // First-party endpoints - (Method::GET, "/first-party/proxy") => handle_first_party_proxy(&settings, req).await, - - // Integration registry (dynamic routes) - (m, path) if integration_registry.has_route(&m, path) => { - integration_registry.handle_proxy(&m, path, &settings, req).await + match runtime_services_for_consent_route(settings, runtime_services) { + Ok(auction_services) => { + handle_auction(settings, orchestrator, &auction_services, req).await + } + Err(e) => Err(e), + } + } + (Method::GET, "/first-party/proxy") => { + handle_first_party_proxy(settings, runtime_services, req).await + } + (Method::GET, "/first-party/click") => { + handle_first_party_click(settings, runtime_services, req).await + } + (Method::GET, "/first-party/sign") | (Method::POST, "/first-party/sign") => { + handle_first_party_proxy_sign(settings, runtime_services, req).await + } + (Method::POST, "/first-party/proxy-rebuild") => { + handle_first_party_proxy_rebuild(settings, runtime_services, req).await + } + (m, path) if integration_registry.has_route(&m, path) => integration_registry + .handle_proxy(&m, path, settings, runtime_services, req) + .await + .unwrap_or_else(|| { + Err(Report::new(TrustedServerError::BadRequest { + message: format!("Unknown integration route: {path}"), + })) + }), + _ => match runtime_services_for_consent_route(settings, runtime_services) { + Ok(publisher_services) => { + handle_publisher_request(settings, integration_registry, &publisher_services, req) + } + Err(e) => Err(e), }, - - // Fallback to publisher origin - _ => handle_publisher_request(&settings, &integration_registry, &runtime_services, req), -} +}; ``` #### 2. Integration Registry (Dynamic Routes) diff --git a/crates/trusted-server-core/src/auction/endpoints.rs b/crates/trusted-server-core/src/auction/endpoints.rs index e272d761..4ce4440e 100644 --- a/crates/trusted-server-core/src/auction/endpoints.rs +++ b/crates/trusted-server-core/src/auction/endpoints.rs @@ -8,7 +8,6 @@ use crate::consent; use crate::cookies::handle_request_cookies; use crate::edge_cookie::get_or_generate_ec_id; use crate::error::TrustedServerError; -use crate::geo::GeoInfo; use crate::platform::RuntimeServices; use crate::settings::Settings; @@ -32,7 +31,7 @@ use super::AuctionOrchestrator; pub async fn handle_auction( settings: &Settings, orchestrator: &AuctionOrchestrator, - runtime_services: &RuntimeServices, + services: &RuntimeServices, mut req: Request, ) -> Result> { // Parse request body @@ -49,15 +48,21 @@ pub async fn handle_auction( // Generate EC ID early so the consent pipeline can use it for // KV Store fallback/write operations. - let ec_id = - get_or_generate_ec_id(settings, &req).change_context(TrustedServerError::Auction { + let ec_id = get_or_generate_ec_id(settings, services, &req).change_context( + TrustedServerError::Auction { message: "Failed to generate EC ID".to_string(), - })?; + }, + )?; // Extract consent from request cookies, headers, and geo. let cookie_jar = handle_request_cookies(&req)?; - #[allow(deprecated)] - let geo = GeoInfo::from_request(&req); + let geo = services + .geo() + .lookup(services.client_info.client_ip) + .unwrap_or_else(|e| { + log::warn!("geo lookup failed: {e}"); + None + }); let consent_context = consent::build_consent_context(&consent::ConsentPipelineInput { jar: cookie_jar.as_ref(), req: &req, @@ -68,24 +73,32 @@ pub async fn handle_auction( .consent .consent_store .as_deref() - .map(|_| runtime_services.kv_store()), + .map(|_| services.kv_store()), }); // Convert tsjs request format to auction request - let auction_request = - convert_tsjs_to_auction_request(&body, settings, &req, consent_context, &ec_id)?; + let auction_request = convert_tsjs_to_auction_request( + &body, + settings, + services, + &req, + consent_context, + &ec_id, + geo, + )?; // Create auction context let context = AuctionContext { settings, request: &req, + client_info: &services.client_info, timeout_ms: settings.auction.timeout_ms, provider_responses: None, }; // Run the auction let result = orchestrator - .run_auction(&auction_request, &context, runtime_services) + .run_auction(&auction_request, &context, services) .await .change_context(TrustedServerError::Auction { message: "Auction orchestration failed".to_string(), diff --git a/crates/trusted-server-core/src/auction/formats.rs b/crates/trusted-server-core/src/auction/formats.rs index 1f557a17..5237921a 100644 --- a/crates/trusted-server-core/src/auction/formats.rs +++ b/crates/trusted-server-core/src/auction/formats.rs @@ -18,8 +18,8 @@ use crate::constants::{HEADER_X_TS_EC, HEADER_X_TS_EC_FRESH}; use crate::creative; use crate::edge_cookie::generate_ec_id; use crate::error::TrustedServerError; -use crate::geo::GeoInfo; use crate::openrtb::{to_openrtb_i32, OpenRtbBid, OpenRtbResponse, ResponseExt, SeatBid, ToExt}; +use crate::platform::{GeoInfo, RuntimeServices}; use crate::settings::Settings; use super::orchestrator::OrchestrationResult; @@ -83,14 +83,17 @@ pub struct BannerUnit { pub fn convert_tsjs_to_auction_request( body: &AdRequest, settings: &Settings, + services: &RuntimeServices, req: &Request, consent: ConsentContext, ec_id: &str, + geo: Option, ) -> Result> { let ec_id = ec_id.to_owned(); - let fresh_id = generate_ec_id(settings, req).change_context(TrustedServerError::Auction { - message: "Failed to generate fresh EC ID".to_string(), - })?; + let fresh_id = + generate_ec_id(settings, services).change_context(TrustedServerError::Auction { + message: "Failed to generate fresh EC ID".to_string(), + })?; // Convert ad units to slots let mut slots = Vec::new(); @@ -137,9 +140,8 @@ pub fn convert_tsjs_to_auction_request( user_agent: req .get_header_str("user-agent") .map(std::string::ToString::to_string), - ip: req.get_client_ip_addr().map(|ip| ip.to_string()), - #[allow(deprecated)] - geo: GeoInfo::from_request(req), + ip: services.client_info.client_ip.map(|ip| ip.to_string()), + geo, }); // Forward allowed config entries from the JS request into the context map. diff --git a/crates/trusted-server-core/src/auction/orchestrator.rs b/crates/trusted-server-core/src/auction/orchestrator.rs index 8ee6e13a..0b650e07 100644 --- a/crates/trusted-server-core/src/auction/orchestrator.rs +++ b/crates/trusted-server-core/src/auction/orchestrator.rs @@ -145,6 +145,7 @@ impl AuctionOrchestrator { let mediator_context = AuctionContext { settings: context.settings, request: context.request, + client_info: context.client_info, timeout_ms: remaining_ms, provider_responses: Some(&provider_responses), }; @@ -329,6 +330,7 @@ impl AuctionOrchestrator { let provider_context = AuctionContext { settings: context.settings, request: context.request, + client_info: context.client_info, timeout_ms: effective_timeout, provider_responses: context.provider_responses, }; @@ -695,10 +697,12 @@ mod tests { fn create_test_context<'a>( settings: &'a crate::settings::Settings, req: &'a Request, + client_info: &'a crate::platform::ClientInfo, ) -> AuctionContext<'a> { AuctionContext { settings, request: req, + client_info, timeout_ms: 2000, provider_responses: None, } @@ -791,7 +795,15 @@ mod tests { let request = create_test_auction_request(); let settings = create_test_settings(); let req = Request::get("https://test.com/test"); - let context = create_test_context(&settings, &req); + let context = create_test_context( + &settings, + &req, + &crate::platform::ClientInfo { + client_ip: None, + tls_protocol: None, + tls_cipher: None, + }, + ); let result = orchestrator .run_auction(&request, &context, &noop_services()) diff --git a/crates/trusted-server-core/src/auction/types.rs b/crates/trusted-server-core/src/auction/types.rs index aa863d61..82538206 100644 --- a/crates/trusted-server-core/src/auction/types.rs +++ b/crates/trusted-server-core/src/auction/types.rs @@ -6,6 +6,7 @@ use std::collections::HashMap; use crate::auction::context::ContextValue; use crate::geo::GeoInfo; +use crate::platform::ClientInfo; use crate::settings::Settings; /// Represents a unified auction request across all providers. @@ -102,6 +103,7 @@ pub struct SiteInfo { pub struct AuctionContext<'a> { pub settings: &'a Settings, pub request: &'a Request, + pub client_info: &'a ClientInfo, pub timeout_ms: u32, /// Provider responses from the bidding phase, used by mediators. /// This is `None` for regular bidders and `Some` when calling a mediator. diff --git a/crates/trusted-server-core/src/edge_cookie.rs b/crates/trusted-server-core/src/edge_cookie.rs index 063c3fdd..7d2094e3 100644 --- a/crates/trusted-server-core/src/edge_cookie.rs +++ b/crates/trusted-server-core/src/edge_cookie.rs @@ -14,6 +14,7 @@ use sha2::Sha256; use crate::constants::{COOKIE_TS_EC, HEADER_X_TS_EC}; use crate::cookies::{ec_id_has_only_allowed_chars, handle_request_cookies}; use crate::error::TrustedServerError; +use crate::platform::RuntimeServices; use crate::settings::Settings; type HmacSha256 = Hmac; @@ -67,12 +68,13 @@ fn generate_random_suffix(length: usize) -> String { /// - [`TrustedServerError::Ec`] if HMAC generation fails pub fn generate_ec_id( settings: &Settings, - req: &Request, + services: &RuntimeServices, ) -> Result> { // Fallback to "unknown" when client IP is unavailable (e.g., local testing). // All such requests share the same HMAC base; the random suffix provides uniqueness. - let client_ip = req - .get_client_ip_addr() + let client_ip = services + .client_info + .client_ip .map(normalize_ip) .unwrap_or_else(|| "unknown".to_string()); @@ -146,6 +148,7 @@ pub fn get_ec_id(req: &Request) -> Result, Report Result> { if let Some(id) = get_ec_id(req)? { @@ -153,7 +156,7 @@ pub fn get_or_generate_ec_id( } // If no existing EC ID found, generate a fresh one - let ec_id = generate_ec_id(settings, req)?; + let ec_id = generate_ec_id(settings, services)?; log::trace!("No existing EC ID, generated: {}", ec_id); Ok(ec_id) } @@ -164,6 +167,7 @@ mod tests { use fastly::http::{HeaderName, HeaderValue}; use std::net::{Ipv4Addr, Ipv6Addr}; + use crate::platform::test_support::{noop_services, noop_services_with_client_ip}; use crate::test_support::tests::create_test_settings; #[test] @@ -236,9 +240,8 @@ mod tests { #[test] fn test_generate_ec_id() { let settings: Settings = create_test_settings(); - let req = create_test_request(vec![]); - let ec_id = generate_ec_id(&settings, &req).expect("should generate EC ID"); + let ec_id = generate_ec_id(&settings, &noop_services()).expect("should generate EC ID"); log::debug!("Generated EC ID: {}", ec_id); assert!( is_ec_id_format(&ec_id), @@ -246,6 +249,25 @@ mod tests { ); } + #[test] + fn test_generate_ec_id_uses_client_ip() { + let settings = create_test_settings(); + let ip = IpAddr::V4(Ipv4Addr::new(203, 0, 113, 1)); + + let id_with_ip = generate_ec_id(&settings, &noop_services_with_client_ip(ip)) + .expect("should generate EC ID with client IP"); + let id_without_ip = generate_ec_id(&settings, &noop_services()) + .expect("should generate EC ID without client IP"); + + let hmac_with_ip = id_with_ip.split_once('.').expect("should contain dot").0; + let hmac_without_ip = id_without_ip.split_once('.').expect("should contain dot").0; + + assert_ne!( + hmac_with_ip, hmac_without_ip, + "should produce different HMAC when client IP differs" + ); + } + #[test] fn test_is_ec_id_format_accepts_valid_value() { let value = format!("{}.{}", "a".repeat(64), "Ab12z9"); @@ -290,7 +312,8 @@ mod tests { let ec_id = get_ec_id(&req).expect("should get EC ID"); assert_eq!(ec_id, Some("existing_ec_id".to_string())); - let ec_id = get_or_generate_ec_id(&settings, &req).expect("should reuse header EC ID"); + let ec_id = get_or_generate_ec_id(&settings, &noop_services(), &req) + .expect("should reuse header EC ID"); assert_eq!(ec_id, "existing_ec_id"); } @@ -305,7 +328,8 @@ mod tests { let ec_id = get_ec_id(&req).expect("should get EC ID"); assert_eq!(ec_id, Some("existing_cookie_id".to_string())); - let ec_id = get_or_generate_ec_id(&settings, &req).expect("should reuse cookie EC ID"); + let ec_id = get_or_generate_ec_id(&settings, &noop_services(), &req) + .expect("should reuse cookie EC ID"); assert_eq!(ec_id, "existing_cookie_id"); } @@ -321,7 +345,8 @@ mod tests { let settings = create_test_settings(); let req = create_test_request(vec![]); - let ec_id = get_or_generate_ec_id(&settings, &req).expect("should get or generate EC ID"); + let ec_id = get_or_generate_ec_id(&settings, &noop_services(), &req) + .expect("should get or generate EC ID"); assert!(!ec_id.is_empty()); } @@ -348,7 +373,7 @@ mod tests { let settings = create_test_settings(); let req = create_test_request(vec![(HEADER_X_TS_EC, "evil;injected")]); - let ec_id = get_or_generate_ec_id(&settings, &req) + let ec_id = get_or_generate_ec_id(&settings, &noop_services(), &req) .expect("should generate fresh ID on invalid header"); assert_ne!( ec_id, "evil;injected", diff --git a/crates/trusted-server-core/src/http_util.rs b/crates/trusted-server-core/src/http_util.rs index 72d01e4b..6944fb1c 100644 --- a/crates/trusted-server-core/src/http_util.rs +++ b/crates/trusted-server-core/src/http_util.rs @@ -6,6 +6,7 @@ use sha2::{Digest, Sha256}; use subtle::ConstantTimeEq as _; use crate::constants::INTERNAL_HEADERS; +use crate::platform::ClientInfo; use crate::settings::Settings; /// Copy `X-*` custom headers from one request to another, skipping TS-internal headers. @@ -29,9 +30,9 @@ pub fn copy_custom_headers(from: &Request, to: &mut Request) { /// Headers that clients can spoof to hijack URL rewriting. /// -/// On Fastly Compute the service is the edge — there is no upstream proxy that +/// On Fastly Compute the service is the edge - there is no upstream proxy that /// legitimately sets these. Stripping them forces [`RequestInfo::from_request`] -/// to fall back to the trustworthy `Host` header and Fastly SDK TLS detection. +/// to fall back to the trustworthy `Host` header and [`ClientInfo`] TLS detection. const SPOOFABLE_FORWARDED_HEADERS: &[&str] = &[ "forwarded", "x-forwarded-host", @@ -59,18 +60,18 @@ pub fn sanitize_forwarded_headers(req: &mut Request) { /// The parser checks forwarded headers (`Forwarded`, `X-Forwarded-Host`, /// `X-Forwarded-Proto`) as fallbacks, but on the Fastly edge /// [`sanitize_forwarded_headers`] strips those headers before this method is -/// called, so the `Host` header and Fastly SDK TLS detection are the effective -/// sources in production. +/// called, so the `Host` header and [`ClientInfo`] TLS detection are the +/// effective sources in production. #[derive(Debug, Clone)] pub struct RequestInfo { /// The effective host for URL rewriting (typically the `Host` header after edge sanitization). pub host: String, - /// The effective scheme (typically from Fastly SDK TLS detection after edge sanitization). + /// The effective scheme (typically from [`ClientInfo`] TLS detection after edge sanitization). pub scheme: String, } impl RequestInfo { - /// Extract request info from a Fastly request. + /// Extract request info from an incoming request. /// /// Host fallback order (first present wins): /// 1. `Forwarded` header (`host=...`) @@ -78,18 +79,22 @@ impl RequestInfo { /// 3. `Host` header /// /// Scheme fallback order: - /// 1. Fastly SDK TLS detection + /// 1. [`ClientInfo`] TLS fields populated at the adapter entry point /// 2. `Forwarded` header (`proto=...`) /// 3. `X-Forwarded-Proto` /// 4. `Fastly-SSL` /// 5. Default `http` /// /// In production the forwarded headers are stripped by - /// [`sanitize_forwarded_headers`] at the edge, so `Host` and SDK TLS - /// detection are the only sources that fire. - pub fn from_request(req: &Request) -> Self { + /// [`sanitize_forwarded_headers`] at the edge, so `Host` and + /// [`ClientInfo`] TLS detection are the only sources that fire. + pub fn from_request(req: &Request, client_info: &ClientInfo) -> Self { let host = extract_request_host(req); - let scheme = detect_request_scheme(req); + let scheme = detect_request_scheme( + req, + client_info.tls_protocol.as_deref(), + client_info.tls_cipher.as_deref(), + ); Self { host, scheme } } @@ -156,23 +161,27 @@ fn normalize_scheme(value: &str) -> Option { } } -/// Detects the request scheme (HTTP or HTTPS) using Fastly SDK methods and headers. +/// Detects the request scheme (HTTP or HTTPS) from `ClientInfo` TLS fields and headers. /// -/// Tries multiple methods in order of reliability: -/// 1. Fastly SDK TLS detection methods (most reliable) +/// Tries multiple sources in order of reliability: +/// 1. `ClientInfo` TLS fields populated at the adapter entry point (most reliable) /// 2. Forwarded header (RFC 7239) /// 3. X-Forwarded-Proto header /// 4. Fastly-SSL header (least reliable, can be spoofed) /// 5. Default to HTTP -fn detect_request_scheme(req: &Request) -> String { - // 1. First try Fastly SDK's built-in TLS detection methods - if let Some(tls_protocol) = req.get_tls_protocol() { +fn detect_request_scheme( + req: &Request, + tls_protocol: Option<&str>, + tls_cipher: Option<&str>, +) -> String { + // 1. First try ClientInfo TLS fields populated at the adapter entry point. + if let Some(tls_protocol) = tls_protocol { log::debug!("TLS protocol detected: {}", tls_protocol); return "https".to_string(); } - // Also check TLS cipher - if present, connection is HTTPS - if req.get_tls_cipher_openssl_name().is_some() { + // Also check TLS cipher - if present, connection is HTTPS. + if tls_cipher.is_some() { log::debug!("TLS cipher detected, using HTTPS"); return "https".to_string(); } @@ -376,6 +385,7 @@ pub fn compute_encrypted_sha256_token(settings: &Settings, full_url: &str) -> St #[cfg(test)] mod tests { use super::*; + use crate::platform::ClientInfo; #[test] fn encode_decode_roundtrip() { @@ -447,7 +457,14 @@ mod tests { let mut req = Request::new(fastly::http::Method::GET, "https://test.example.com/page"); req.set_header("host", "test.example.com"); - let info = RequestInfo::from_request(&req); + let info = RequestInfo::from_request( + &req, + &ClientInfo { + client_ip: None, + tls_protocol: None, + tls_cipher: None, + }, + ); assert_eq!( info.host, "test.example.com", "Host should use Host header when forwarded headers are missing" @@ -465,7 +482,14 @@ mod tests { req.set_header("host", "internal-proxy.local"); req.set_header("x-forwarded-host", "public.example.com, proxy.local"); - let info = RequestInfo::from_request(&req); + let info = RequestInfo::from_request( + &req, + &ClientInfo { + client_ip: None, + tls_protocol: None, + tls_cipher: None, + }, + ); assert_eq!( info.host, "public.example.com", "Host should prefer X-Forwarded-Host over Host" @@ -478,7 +502,14 @@ mod tests { req.set_header("host", "test.example.com"); req.set_header("x-forwarded-proto", "https, http"); - let info = RequestInfo::from_request(&req); + let info = RequestInfo::from_request( + &req, + &ClientInfo { + client_ip: None, + tls_protocol: None, + tls_cipher: None, + }, + ); assert_eq!( info.scheme, "https", "Scheme should prefer the first X-Forwarded-Proto value" @@ -489,7 +520,14 @@ mod tests { req.set_header("host", "test.example.com"); req.set_header("x-forwarded-proto", "http"); - let info = RequestInfo::from_request(&req); + let info = RequestInfo::from_request( + &req, + &ClientInfo { + client_ip: None, + tls_protocol: None, + tls_cipher: None, + }, + ); assert_eq!( info.scheme, "http", "Scheme should use the X-Forwarded-Proto value when present" @@ -508,7 +546,14 @@ mod tests { req.set_header("x-forwarded-host", "proxy.local"); req.set_header("x-forwarded-proto", "http"); - let info = RequestInfo::from_request(&req); + let info = RequestInfo::from_request( + &req, + &ClientInfo { + client_ip: None, + tls_protocol: None, + tls_cipher: None, + }, + ); assert_eq!( info.host, "public.example.com:443", "Host should prefer Forwarded host over X-Forwarded-Host" @@ -524,7 +569,14 @@ mod tests { let mut req = Request::new(fastly::http::Method::GET, "https://test.example.com/page"); req.set_header("fastly-ssl", "1"); - let info = RequestInfo::from_request(&req); + let info = RequestInfo::from_request( + &req, + &ClientInfo { + client_ip: None, + tls_protocol: None, + tls_cipher: None, + }, + ); assert_eq!( info.scheme, "https", "Scheme should fall back to Fastly-SSL when other signals are missing" @@ -543,7 +595,14 @@ mod tests { req.set_header("x-forwarded-host", "public.example.com"); req.set_header("x-forwarded-proto", "https"); - let info = RequestInfo::from_request(&req); + let info = RequestInfo::from_request( + &req, + &ClientInfo { + client_ip: None, + tls_protocol: None, + tls_cipher: None, + }, + ); assert_eq!( info.host, "public.example.com", "Host should use X-Forwarded-Host in chained proxy scenarios" @@ -601,7 +660,14 @@ mod tests { req.set_header("x-forwarded-proto", "http"); sanitize_forwarded_headers(&mut req); - let info = RequestInfo::from_request(&req); + let info = RequestInfo::from_request( + &req, + &ClientInfo { + client_ip: None, + tls_protocol: None, + tls_cipher: None, + }, + ); assert_eq!( info.host, "legit.example.com", @@ -662,4 +728,38 @@ mod tests { "Should filter x-geo-country" ); } + + #[test] + fn request_info_https_from_client_info_tls_protocol() { + let req = Request::new(fastly::http::Method::GET, "https://test.example.com/page"); + let client_info = ClientInfo { + client_ip: None, + tls_protocol: Some("TLSv1.3".to_string()), + tls_cipher: None, + }; + + let info = RequestInfo::from_request(&req, &client_info); + + assert_eq!( + info.scheme, "https", + "should detect https from ClientInfo tls_protocol" + ); + } + + #[test] + fn request_info_https_from_client_info_tls_cipher() { + let req = Request::new(fastly::http::Method::GET, "https://test.example.com/page"); + let client_info = ClientInfo { + client_ip: None, + tls_protocol: None, + tls_cipher: Some("TLS_AES_128_GCM_SHA256".to_string()), + }; + + let info = RequestInfo::from_request(&req, &client_info); + + assert_eq!( + info.scheme, "https", + "should detect https from ClientInfo tls_cipher" + ); + } } diff --git a/crates/trusted-server-core/src/integrations/didomi.rs b/crates/trusted-server-core/src/integrations/didomi.rs index 2cada38f..2dcb5e91 100644 --- a/crates/trusted-server-core/src/integrations/didomi.rs +++ b/crates/trusted-server-core/src/integrations/didomi.rs @@ -101,11 +101,12 @@ impl DidomiIntegration { fn copy_headers( &self, backend: &DidomiBackend, + client_ip: Option, original_req: &Request, proxy_req: &mut Request, ) { - if let Some(client_ip) = original_req.get_client_ip_addr() { - proxy_req.set_header("X-Forwarded-For", client_ip.to_string()); + if let Some(ip) = client_ip { + proxy_req.set_header("X-Forwarded-For", ip.to_string()); } for header_name in [ @@ -199,7 +200,7 @@ impl IntegrationProxy for DidomiIntegration { async fn handle( &self, _settings: &Settings, - _services: &RuntimeServices, + services: &RuntimeServices, req: Request, ) -> Result> { let path = req.get_path(); @@ -217,7 +218,12 @@ impl IntegrationProxy for DidomiIntegration { .change_context(Self::error("Failed to configure Didomi backend"))?; let mut proxy_req = Request::new(req.get_method().clone(), &target_url); - self.copy_headers(&backend, &req, &mut proxy_req); + self.copy_headers( + &backend, + services.client_info.client_ip, + &req, + &mut proxy_req, + ); if matches!(req.get_method(), &Method::POST | &Method::PUT) { if let Some(content_type) = req.get_header(header::CONTENT_TYPE) { @@ -244,6 +250,7 @@ mod tests { use crate::integrations::IntegrationRegistry; use crate::test_support::tests::create_test_settings; use fastly::http::Method; + use std::net::{IpAddr, Ipv4Addr}; fn config(enabled: bool) -> DidomiIntegrationConfig { DidomiIntegrationConfig { @@ -288,4 +295,38 @@ mod tests { assert!(registry.has_route(&Method::POST, "/integrations/didomi/consent/api/events")); assert!(!registry.has_route(&Method::GET, "/other")); } + + #[test] + fn copy_headers_sets_x_forwarded_for_from_client_ip() { + let integration = DidomiIntegration::new(Arc::new(config(true))); + let backend = DidomiBackend::Sdk; + let original_req = Request::new(Method::GET, "https://example.com/test"); + let mut proxy_req = Request::new(Method::GET, "https://sdk.privacy-center.org/test"); + let client_ip = Some(IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4))); + + integration.copy_headers(&backend, client_ip, &original_req, &mut proxy_req); + + assert_eq!( + proxy_req + .get_header("X-Forwarded-For") + .and_then(|v| v.to_str().ok()), + Some("1.2.3.4"), + "should set X-Forwarded-For from client_ip" + ); + } + + #[test] + fn copy_headers_omits_x_forwarded_for_when_no_client_ip() { + let integration = DidomiIntegration::new(Arc::new(config(true))); + let backend = DidomiBackend::Sdk; + let original_req = Request::new(Method::GET, "https://example.com/test"); + let mut proxy_req = Request::new(Method::GET, "https://sdk.privacy-center.org/test"); + + integration.copy_headers(&backend, None, &original_req, &mut proxy_req); + + assert!( + proxy_req.get_header("X-Forwarded-For").is_none(), + "should omit X-Forwarded-For when client_ip is None" + ); + } } diff --git a/crates/trusted-server-core/src/integrations/prebid.rs b/crates/trusted-server-core/src/integrations/prebid.rs index 3793ba53..ec5cfa8d 100644 --- a/crates/trusted-server-core/src/integrations/prebid.rs +++ b/crates/trusted-server-core/src/integrations/prebid.rs @@ -710,7 +710,7 @@ impl PrebidAuctionProvider { let regs = Self::build_regs(consent_ctx); // Build ext object - let request_info = RequestInfo::from_request(context.request); + let request_info = RequestInfo::from_request(context.request, context.client_info); let (version, signature, kid, ts) = signer .map(|(s, sig, params)| { ( @@ -1008,7 +1008,7 @@ impl AuctionProvider for PrebidAuctionProvider { &context.settings.request_signing { if request_signing_config.enabled { - let request_info = RequestInfo::from_request(context.request); + let request_info = RequestInfo::from_request(context.request, context.client_info); let signer = RequestSigner::from_config()?; let params = SigningParams::new(request.id.clone(), request_info.host, request_info.scheme); @@ -1283,10 +1283,12 @@ mod tests { fn create_test_auction_context<'a>( settings: &'a Settings, request: &'a Request, + client_info: &'a crate::platform::ClientInfo, ) -> AuctionContext<'a> { AuctionContext { settings, request, + client_info, timeout_ms: 1000, provider_responses: None, } @@ -1699,7 +1701,15 @@ server_url = "https://prebid.example" let auction_request = create_test_auction_request(); let settings = make_settings(); let request = Request::get("https://pub.example/auction"); - let context = create_test_auction_context(&settings, &request); + let context = create_test_auction_context( + &settings, + &request, + &crate::platform::ClientInfo { + client_ip: None, + tls_protocol: None, + tls_cipher: None, + }, + ); let openrtb = provider.to_openrtb(&auction_request, &context, None); @@ -1746,7 +1756,15 @@ server_url = "https://prebid.example" let auction_request = create_test_auction_request(); let settings = make_settings(); let request = Request::get("https://pub.example/auction"); - let context = create_test_auction_context(&settings, &request); + let context = create_test_auction_context( + &settings, + &request, + &crate::platform::ClientInfo { + client_ip: None, + tls_protocol: None, + tls_cipher: None, + }, + ); let openrtb = provider.to_openrtb(&auction_request, &context, None); @@ -1775,7 +1793,15 @@ server_url = "https://prebid.example" }); let settings = make_settings(); let request = Request::get("https://pub.example/auction"); - let context = create_test_auction_context(&settings, &request); + let context = create_test_auction_context( + &settings, + &request, + &crate::platform::ClientInfo { + client_ip: None, + tls_protocol: None, + tls_cipher: None, + }, + ); let openrtb = provider.to_openrtb(&auction_request, &context, None); @@ -1802,7 +1828,15 @@ server_url = "https://prebid.example" let auction_request = create_test_auction_request(); let settings = make_settings(); let request = Request::get("https://pub.example/auction"); - let context = create_test_auction_context(&settings, &request); + let context = create_test_auction_context( + &settings, + &request, + &crate::platform::ClientInfo { + client_ip: None, + tls_protocol: None, + tls_cipher: None, + }, + ); let openrtb = provider.to_openrtb(&auction_request, &context, None); @@ -1852,7 +1886,15 @@ server_url = "https://prebid.example" let settings = make_settings(); let request = Request::get("https://pub.example/auction"); - let context = create_test_auction_context(&settings, &request); + let context = create_test_auction_context( + &settings, + &request, + &crate::platform::ClientInfo { + client_ip: None, + tls_protocol: None, + tls_cipher: None, + }, + ); let openrtb = provider.to_openrtb(&auction_request, &context, None); let imp = &openrtb.imp[0]; @@ -1872,7 +1914,15 @@ server_url = "https://prebid.example" let settings = make_settings(); let request = Request::get("https://pub.example/auction"); - let context = create_test_auction_context(&settings, &request); + let context = create_test_auction_context( + &settings, + &request, + &crate::platform::ClientInfo { + client_ip: None, + tls_protocol: None, + tls_cipher: None, + }, + ); let openrtb = provider.to_openrtb(&auction_request, &context, None); let imp = &openrtb.imp[0]; @@ -1891,7 +1941,15 @@ server_url = "https://prebid.example" let settings = make_settings(); let request = Request::get("https://pub.example/auction"); - let context = create_test_auction_context(&settings, &request); + let context = create_test_auction_context( + &settings, + &request, + &crate::platform::ClientInfo { + client_ip: None, + tls_protocol: None, + tls_cipher: None, + }, + ); let openrtb = provider.to_openrtb(&auction_request, &context, None); let imp = &openrtb.imp[0]; @@ -1930,7 +1988,15 @@ server_url = "https://prebid.example" let settings = make_settings(); let request = Request::get("https://pub.example/auction"); - let context = create_test_auction_context(&settings, &request); + let context = create_test_auction_context( + &settings, + &request, + &crate::platform::ClientInfo { + client_ip: None, + tls_protocol: None, + tls_cipher: None, + }, + ); let openrtb = provider.to_openrtb(&auction_request, &context, None); @@ -1975,7 +2041,15 @@ server_url = "https://prebid.example" let settings = make_settings(); let request = Request::get("https://pub.example/auction"); - let context = create_test_auction_context(&settings, &request); + let context = create_test_auction_context( + &settings, + &request, + &crate::platform::ClientInfo { + client_ip: None, + tls_protocol: None, + tls_cipher: None, + }, + ); let openrtb = provider.to_openrtb(&auction_request, &context, None); @@ -2008,7 +2082,15 @@ server_url = "https://prebid.example" let settings = make_settings(); let request = Request::get("https://pub.example/auction"); - let context = create_test_auction_context(&settings, &request); + let context = create_test_auction_context( + &settings, + &request, + &crate::platform::ClientInfo { + client_ip: None, + tls_protocol: None, + tls_cipher: None, + }, + ); let openrtb = provider.to_openrtb(&auction_request, &context, None); @@ -2031,7 +2113,15 @@ server_url = "https://prebid.example" let settings = make_settings(); let request = Request::get("https://pub.example/auction"); - let context = create_test_auction_context(&settings, &request); + let context = create_test_auction_context( + &settings, + &request, + &crate::platform::ClientInfo { + client_ip: None, + tls_protocol: None, + tls_cipher: None, + }, + ); let openrtb = provider.to_openrtb(&auction_request, &context, None); @@ -2049,7 +2139,15 @@ server_url = "https://prebid.example" let settings = make_settings(); let request = Request::get("https://pub.example/auction"); - let context = create_test_auction_context(&settings, &request); + let context = create_test_auction_context( + &settings, + &request, + &crate::platform::ClientInfo { + client_ip: None, + tls_protocol: None, + tls_cipher: None, + }, + ); let openrtb = provider.to_openrtb(&auction_request, &context, None); @@ -2070,7 +2168,15 @@ server_url = "https://prebid.example" let settings = make_settings(); let request = Request::get("https://pub.example/auction"); - let context = create_test_auction_context(&settings, &request); + let context = create_test_auction_context( + &settings, + &request, + &crate::platform::ClientInfo { + client_ip: None, + tls_protocol: None, + tls_cipher: None, + }, + ); let openrtb = provider.to_openrtb(&auction_request, &context, None); let regs = openrtb.regs.as_ref().expect("should have regs"); @@ -2286,7 +2392,15 @@ server_url = "https://prebid.example" let settings = make_settings(); let mut request = Request::get("https://pub.example/auction"); request.set_header("DNT", "1"); - let context = create_test_auction_context(&settings, &request); + let context = create_test_auction_context( + &settings, + &request, + &crate::platform::ClientInfo { + client_ip: None, + tls_protocol: None, + tls_cipher: None, + }, + ); let openrtb = provider.to_openrtb(&auction_request, &context, None); let device = openrtb.device.as_ref().expect("should have device"); @@ -2307,7 +2421,15 @@ server_url = "https://prebid.example" let settings = make_settings(); let mut request = Request::get("https://pub.example/auction"); request.set_header("Accept-Language", "en-US,en;q=0.9,fr;q=0.8"); - let context = create_test_auction_context(&settings, &request); + let context = create_test_auction_context( + &settings, + &request, + &crate::platform::ClientInfo { + client_ip: None, + tls_protocol: None, + tls_cipher: None, + }, + ); let openrtb = provider.to_openrtb(&auction_request, &context, None); let device = openrtb.device.as_ref().expect("should have device"); @@ -2332,7 +2454,15 @@ server_url = "https://prebid.example" let settings = make_settings(); let mut request = Request::get("https://pub.example/auction"); request.set_header("Accept-Language", ""); - let context = create_test_auction_context(&settings, &request); + let context = create_test_auction_context( + &settings, + &request, + &crate::platform::ClientInfo { + client_ip: None, + tls_protocol: None, + tls_cipher: None, + }, + ); let openrtb = provider.to_openrtb(&auction_request, &context, None); let device = openrtb.device.as_ref().expect("should have device"); @@ -2363,7 +2493,15 @@ server_url = "https://prebid.example" let settings = make_settings(); let request = Request::get("https://pub.example/auction"); - let context = create_test_auction_context(&settings, &request); + let context = create_test_auction_context( + &settings, + &request, + &crate::platform::ClientInfo { + client_ip: None, + tls_protocol: None, + tls_cipher: None, + }, + ); let openrtb = provider.to_openrtb(&auction_request, &context, None); @@ -2393,7 +2531,15 @@ server_url = "https://prebid.example" let settings = make_settings(); let request = Request::get("https://pub.example/auction"); - let context = create_test_auction_context(&settings, &request); + let context = create_test_auction_context( + &settings, + &request, + &crate::platform::ClientInfo { + client_ip: None, + tls_protocol: None, + tls_cipher: None, + }, + ); let openrtb = provider.to_openrtb(&auction_request, &context, None); let geo = openrtb @@ -2421,7 +2567,15 @@ server_url = "https://prebid.example" let settings = make_settings(); let request = Request::get("https://pub.example/auction"); - let context = create_test_auction_context(&settings, &request); + let context = create_test_auction_context( + &settings, + &request, + &crate::platform::ClientInfo { + client_ip: None, + tls_protocol: None, + tls_cipher: None, + }, + ); let openrtb = provider.to_openrtb(&auction_request, &context, None); @@ -2446,7 +2600,15 @@ server_url = "https://prebid.example" let settings = make_settings(); let request = Request::get("https://pub.example/auction"); - let context = create_test_auction_context(&settings, &request); + let context = create_test_auction_context( + &settings, + &request, + &crate::platform::ClientInfo { + client_ip: None, + tls_protocol: None, + tls_cipher: None, + }, + ); let openrtb = provider.to_openrtb(&auction_request, &context, None); @@ -2468,7 +2630,15 @@ server_url = "https://prebid.example" let settings = make_settings(); let request = Request::get("https://pub.example/auction"); - let context = create_test_auction_context(&settings, &request); + let context = create_test_auction_context( + &settings, + &request, + &crate::platform::ClientInfo { + client_ip: None, + tls_protocol: None, + tls_cipher: None, + }, + ); let openrtb = provider.to_openrtb(&auction_request, &context, None); let formats = &openrtb.imp[0] @@ -2494,7 +2664,15 @@ server_url = "https://prebid.example" let settings = make_settings(); let mut request = Request::get("https://pub.example/auction"); request.set_header("Referer", "https://google.com/search?q=test"); - let context = create_test_auction_context(&settings, &request); + let context = create_test_auction_context( + &settings, + &request, + &crate::platform::ClientInfo { + client_ip: None, + tls_protocol: None, + tls_cipher: None, + }, + ); let openrtb = provider.to_openrtb(&auction_request, &context, None); let site = openrtb.site.as_ref().expect("should have site"); @@ -2513,7 +2691,15 @@ server_url = "https://prebid.example" let settings = make_settings(); let request = Request::get("https://pub.example/auction"); - let context = create_test_auction_context(&settings, &request); + let context = create_test_auction_context( + &settings, + &request, + &crate::platform::ClientInfo { + client_ip: None, + tls_protocol: None, + tls_cipher: None, + }, + ); let openrtb = provider.to_openrtb(&auction_request, &context, None); let publisher = openrtb @@ -2665,9 +2851,15 @@ server_url = "https://prebid.example" let provider = PrebidAuctionProvider::new(config); let settings = make_settings(); let fastly_req = Request::new(Method::POST, "https://example.com/auction"); + let client_info = crate::platform::ClientInfo { + client_ip: None, + tls_protocol: None, + tls_cipher: None, + }; let context = AuctionContext { settings: &settings, request: &fastly_req, + client_info: &client_info, timeout_ms: 1000, provider_responses: None, }; diff --git a/crates/trusted-server-core/src/integrations/registry.rs b/crates/trusted-server-core/src/integrations/registry.rs index cb34179d..21df01cf 100644 --- a/crates/trusted-server-core/src/integrations/registry.rs +++ b/crates/trusted-server-core/src/integrations/registry.rs @@ -659,7 +659,7 @@ impl IntegrationRegistry { ) -> Option>> { if let Some((proxy, _)) = self.find_route(method, path) { // Generate EC ID before consuming request - let ec_id_result = get_or_generate_ec_id(settings, &req); + let ec_id_result = get_or_generate_ec_id(settings, services, &req); // Set EC ID header on the request so integrations can read it. // Header injection: Fastly's HeaderValue API rejects values containing \r, \n, or \0, diff --git a/crates/trusted-server-core/src/platform/mod.rs b/crates/trusted-server-core/src/platform/mod.rs index bab3e0cc..5c834441 100644 --- a/crates/trusted-server-core/src/platform/mod.rs +++ b/crates/trusted-server-core/src/platform/mod.rs @@ -36,6 +36,7 @@ pub use types::{ #[cfg(test)] mod tests { + use std::net::{IpAddr, Ipv4Addr}; use std::sync::Arc; use std::time::Duration; @@ -43,7 +44,7 @@ mod tests { use bytes::Bytes; use edgezero_core::key_value_store::KvPage; - use super::test_support::noop_services; + use super::test_support::{noop_services, noop_services_with_client_ip}; use super::*; struct MarkerKvStore(&'static str); @@ -127,7 +128,7 @@ mod tests { #[test] fn runtime_services_with_kv_store_replaces_only_the_new_clone() { - let services = noop_services(); + let services = noop_services_with_client_ip(IpAddr::V4(Ipv4Addr::new(198, 51, 100, 7))); let replaced = services .clone() .with_kv_store(Arc::new(MarkerKvStore("replaced"))); @@ -146,6 +147,11 @@ mod tests { Some(Bytes::from_static(b"replaced")), "should expose the replacement KV store through kv_store()" ); + assert_eq!( + replaced.client_info().client_ip, + services.client_info().client_ip, + "should preserve client_info through with_kv_store" + ); } #[test] diff --git a/crates/trusted-server-core/src/platform/test_support.rs b/crates/trusted-server-core/src/platform/test_support.rs index 6189386e..68d428a9 100644 --- a/crates/trusted-server-core/src/platform/test_support.rs +++ b/crates/trusted-server-core/src/platform/test_support.rs @@ -279,6 +279,22 @@ pub(crate) fn noop_services() -> RuntimeServices { build_services_with_config(NoopConfigStore) } +pub(crate) fn noop_services_with_client_ip(ip: IpAddr) -> RuntimeServices { + RuntimeServices::builder() + .config_store(Arc::new(NoopConfigStore)) + .secret_store(Arc::new(NoopSecretStore)) + .kv_store(Arc::new(edgezero_core::key_value_store::NoopKvStore)) + .backend(Arc::new(NoopBackend)) + .http_client(Arc::new(NoopHttpClient)) + .geo(Arc::new(NoopGeo)) + .client_info(ClientInfo { + client_ip: Some(ip), + tls_protocol: None, + tls_cipher: None, + }) + .build() +} + /// Build a [`RuntimeServices`] with a [`StubBackend`] and the given HTTP client. /// /// Useful for tests that need to verify `services.http_client()` call sites. diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index a642c60f..efb96c8c 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -299,7 +299,7 @@ pub fn handle_publisher_request( // publisher-supplied Prebid scripts; the unified TSJS bundle includes Prebid.js when enabled. // Extract request host and scheme (uses Host header and TLS detection after edge sanitization) - let request_info = RequestInfo::from_request(&req); + let request_info = RequestInfo::from_request(&req, &services.client_info); let request_host = &request_info.host; let request_scheme = &request_info.scheme; @@ -326,15 +326,20 @@ pub fn handle_publisher_request( // Generate EC identifiers before the request body is consumed. // Always generated for internal use (KV lookups, logging) even when // consent is absent — the cookie is only *set* when consent allows it. - let ec_id = get_or_generate_ec_id(settings, &req)?; + let ec_id = get_or_generate_ec_id(settings, services, &req)?; // Extract, decode, and log consent signals (TCF, GPP, US Privacy, GPC) // from the incoming request. The ConsentContext carries both raw strings // (for OpenRTB forwarding) and decoded data (for enforcement). // When a consent_store is configured, this also persists consent to KV // and falls back to stored consent when cookies are absent. - #[allow(deprecated)] - let geo = crate::geo::GeoInfo::from_request(&req); + let geo = services + .geo() + .lookup(services.client_info.client_ip) + .unwrap_or_else(|e| { + log::warn!("geo lookup failed: {e}"); + None + }); let consent_context = build_consent_context(&ConsentPipelineInput { jar: cookie_jar.as_ref(), req: &req, @@ -481,6 +486,7 @@ pub fn handle_publisher_request( mod tests { use super::*; use crate::integrations::IntegrationRegistry; + use crate::platform::test_support::noop_services; use crate::test_support::tests::create_test_settings; use fastly::http::{header, Method, StatusCode}; @@ -679,7 +685,8 @@ mod tests { .and_then(|jar| jar.get(COOKIE_TS_EC)) .map(|cookie| cookie.value().to_owned()); - let resolved_ec_id = get_or_generate_ec_id(&settings, &req).expect("should resolve EC ID"); + let resolved_ec_id = + get_or_generate_ec_id(&settings, &noop_services(), &req).expect("should resolve EC ID"); assert_eq!( existing_ec_cookie.as_deref(), diff --git a/docs/superpowers/plans/2026-03-30-pr7-geo-client-info.md b/docs/superpowers/plans/2026-03-30-pr7-geo-client-info.md new file mode 100644 index 00000000..f116286b --- /dev/null +++ b/docs/superpowers/plans/2026-03-30-pr7-geo-client-info.md @@ -0,0 +1,1106 @@ +# PR 7 — Geo Lookup + Client Info (Extract-Once) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Eliminate all `req.get_client_ip_addr()`, `req.get_tls_protocol()`, and `req.get_tls_cipher_openssl_name()` calls from active (non-deprecated) code in `trusted-server-core` by threading the already-populated `RuntimeServices.client_info` to every call site. + +**Architecture:** Cascade from the struct that most files depend on (`AuctionContext`) outward to callers. Each task ends with `cargo test --workspace` passing. Function signature changes always update all callers in the same task to keep the codebase compilable. No new abstractions — pure threading of existing types. + +**Tech Stack:** Rust 1.91.1, `error-stack`, Fastly SDK, `viceroy` test runner. All tests run via `cargo test --workspace`. + +--- + +## File Map + +| File | Change | +| --------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `crates/trusted-server-core/src/auction/types.rs` | Add `client_info: &'a ClientInfo` to `AuctionContext<'a>` | +| `crates/trusted-server-core/src/auction/endpoints.rs` | Fix `AuctionContext` construction; thread `services` to `generate_synthetic_id`; replace `GeoInfo::from_request` with `services.geo().lookup()`; update `convert_tsjs_to_auction_request` call | +| `crates/trusted-server-core/src/auction/orchestrator.rs` | Fix 2 production `AuctionContext` constructions + 1 test helper | +| `crates/trusted-server-core/src/integrations/prebid.rs` | Update 2 `RequestInfo::from_request` call sites; update 2 test helpers | +| `crates/trusted-server-core/src/http_util.rs` | Change `from_request` to `(req: &Request, client_info: &ClientInfo)`; update `detect_request_scheme`; update 8 test call sites; add 1 new TLS test | +| `crates/trusted-server-core/src/publisher.rs` | Add `services: &RuntimeServices` param; update `from_request`, `get_or_generate_synthetic_id`, and geo call sites | +| `crates/trusted-server-core/src/synthetic.rs` | Add `services: &RuntimeServices` to `generate_synthetic_id` and `get_or_generate_synthetic_id`; update tests | +| `crates/trusted-server-core/src/auction/formats.rs` | Add `services: &RuntimeServices, geo: Option` params; thread services to `generate_synthetic_id`; replace `DeviceInfo.ip` and `DeviceInfo.geo` Fastly calls | +| `crates/trusted-server-core/src/integrations/registry.rs` | Thread `services` to `get_or_generate_synthetic_id` | +| `crates/trusted-server-core/src/integrations/didomi.rs` | Rename `_services` → `services`; add `client_ip: Option` to `copy_headers` | +| `crates/trusted-server-adapter-fastly/src/main.rs` | Pass `&runtime_services` to `handle_publisher_request` | + +--- + +## Task 1: Add `client_info` to `AuctionContext` and fix all construction sites + +**Files:** + +- Modify: `crates/trusted-server-core/src/auction/types.rs:102-109` +- Modify: `crates/trusted-server-core/src/auction/endpoints.rs:74-80` +- Modify: `crates/trusted-server-core/src/auction/orchestrator.rs:145-150` +- Modify: `crates/trusted-server-core/src/auction/orchestrator.rs:321-326` +- Modify: `crates/trusted-server-core/src/auction/orchestrator.rs:673-683` (test helper) +- Modify: `crates/trusted-server-core/src/integrations/prebid.rs:1283-1293` (test helper) +- Modify: `crates/trusted-server-core/src/integrations/prebid.rs:2664-2678` (test helper) + +- [ ] **Step 1: Add `client_info` to `AuctionContext` in `types.rs`** + + In `crates/trusted-server-core/src/auction/types.rs`, add the `ClientInfo` import and new field: + + ```rust + // Add to existing imports at the top of the file: + use crate::platform::ClientInfo; + ``` + + Change the `AuctionContext` struct (around line 102): + + ```rust + // Before: + pub struct AuctionContext<'a> { + pub settings: &'a Settings, + pub request: &'a Request, + pub timeout_ms: u32, + pub provider_responses: Option<&'a [AuctionResponse]>, + } + + // After: + pub struct AuctionContext<'a> { + pub settings: &'a Settings, + pub request: &'a Request, + pub timeout_ms: u32, + pub provider_responses: Option<&'a [AuctionResponse]>, + pub client_info: &'a ClientInfo, + } + ``` + + At this point `cargo build` fails because 6 construction sites are missing the new field. + +- [ ] **Step 2: Fix `endpoints.rs` construction site (line ~75)** + + In `crates/trusted-server-core/src/auction/endpoints.rs`, update the `AuctionContext` struct literal: + + ```rust + // Before: + let context = AuctionContext { + settings, + request: &req, + timeout_ms: settings.auction.timeout_ms, + provider_responses: None, + }; + + // After: + let context = AuctionContext { + settings, + request: &req, + timeout_ms: settings.auction.timeout_ms, + provider_responses: None, + client_info: &services.client_info, + }; + ``` + +- [ ] **Step 3: Fix `orchestrator.rs` production construction sites** + + In `crates/trusted-server-core/src/auction/orchestrator.rs`: + + Line ~145 (mediator context): + + ```rust + // Before: + let mediator_context = AuctionContext { + settings: context.settings, + request: context.request, + timeout_ms: remaining_ms, + provider_responses: Some(&provider_responses), + }; + + // After: + let mediator_context = AuctionContext { + settings: context.settings, + request: context.request, + timeout_ms: remaining_ms, + provider_responses: Some(&provider_responses), + client_info: context.client_info, + }; + ``` + + Line ~321 (provider context): + + ```rust + // Before: + let provider_context = AuctionContext { + settings: context.settings, + request: context.request, + timeout_ms: effective_timeout, + provider_responses: context.provider_responses, + }; + + // After: + let provider_context = AuctionContext { + settings: context.settings, + request: context.request, + timeout_ms: effective_timeout, + provider_responses: context.provider_responses, + client_info: context.client_info, + }; + ``` + +- [ ] **Step 4: Fix `orchestrator.rs` test helper `create_test_context` (line ~673)** + + In the `#[cfg(test)]` module of `crates/trusted-server-core/src/auction/orchestrator.rs`: + + ```rust + // Before: + fn create_test_context<'a>( + settings: &'a crate::settings::Settings, + req: &'a Request, + ) -> AuctionContext<'a> { + AuctionContext { + settings, + request: req, + timeout_ms: 2000, + provider_responses: None, + } + } + + // After: + fn create_test_context<'a>( + settings: &'a crate::settings::Settings, + req: &'a Request, + client_info: &'a crate::platform::ClientInfo, + ) -> AuctionContext<'a> { + AuctionContext { + settings, + request: req, + timeout_ms: 2000, + provider_responses: None, + client_info, + } + } + ``` + + Then update every call site of `create_test_context` in the same file to pass: + + ```rust + &crate::platform::ClientInfo { client_ip: None, tls_protocol: None, tls_cipher: None } + ``` + +- [ ] **Step 5: Fix `prebid.rs` test helper `create_test_auction_context` (line ~1283)** + + In the `#[cfg(test)]` module of `crates/trusted-server-core/src/integrations/prebid.rs`: + + ```rust + // Before: + fn create_test_auction_context<'a>( + settings: &'a Settings, + request: &'a Request, + ) -> AuctionContext<'a> { + AuctionContext { + settings, + request, + timeout_ms: 1000, + provider_responses: None, + } + } + + // After: + fn create_test_auction_context<'a>( + settings: &'a Settings, + request: &'a Request, + client_info: &'a crate::platform::ClientInfo, + ) -> AuctionContext<'a> { + AuctionContext { + settings, + request, + timeout_ms: 1000, + provider_responses: None, + client_info, + } + } + ``` + + Update every `create_test_auction_context(settings, req)` call in `prebid.rs` to pass: + + ```rust + create_test_auction_context(settings, req, &crate::platform::ClientInfo { client_ip: None, tls_protocol: None, tls_cipher: None }) + ``` + +- [ ] **Step 6: Fix `prebid.rs` test helper `call_to_openrtb` (line ~2664)** + + ```rust + // Before: + fn call_to_openrtb( + config: PrebidIntegrationConfig, + request: &AuctionRequest, + ) -> OpenRtbRequest { + let provider = PrebidAuctionProvider::new(config); + let settings = make_settings(); + let fastly_req = Request::new(Method::POST, "https://example.com/auction"); + let context = AuctionContext { + settings: &settings, + request: &fastly_req, + timeout_ms: 1000, + provider_responses: None, + }; + provider.to_openrtb(request, &context, None) + } + + // After: + fn call_to_openrtb( + config: PrebidIntegrationConfig, + request: &AuctionRequest, + ) -> OpenRtbRequest { + let provider = PrebidAuctionProvider::new(config); + let settings = make_settings(); + let fastly_req = Request::new(Method::POST, "https://example.com/auction"); + let client_info = crate::platform::ClientInfo { + client_ip: None, + tls_protocol: None, + tls_cipher: None, + }; + let context = AuctionContext { + settings: &settings, + request: &fastly_req, + timeout_ms: 1000, + provider_responses: None, + client_info: &client_info, + }; + provider.to_openrtb(request, &context, None) + } + ``` + +- [ ] **Step 7: Run tests to verify Task 1 compiles and passes** + + ```bash + cargo test --workspace + ``` + + Expected: all tests pass. + +- [ ] **Step 8: Commit Task 1** + + ```bash + git add crates/trusted-server-core/src/auction/types.rs \ + crates/trusted-server-core/src/auction/endpoints.rs \ + crates/trusted-server-core/src/auction/orchestrator.rs \ + crates/trusted-server-core/src/integrations/prebid.rs + git commit -m "Add client_info field to AuctionContext and fix all construction sites" + ``` + +--- + +## Task 2: Change `RequestInfo::from_request` to take `&ClientInfo`, add `services` to `handle_publisher_request`, update `main.rs` + +These four changes must happen together because: + +- `publisher.rs` needs `services` to supply `&services.client_info` to `from_request` +- `main.rs` must be updated when `publisher.rs` signature changes +- `prebid.rs` can now use `context.client_info` (available since Task 1) + +**Files:** + +- Modify: `crates/trusted-server-core/src/http_util.rs:89-94, 166-212, 393-562` (tests) +- Modify: `crates/trusted-server-core/src/publisher.rs:290-294, 301` +- Modify: `crates/trusted-server-core/src/integrations/prebid.rs:713, 1011` +- Modify: `crates/trusted-server-adapter-fastly/src/main.rs:195` + +- [ ] **Step 1: Update `http_util.rs` — change `from_request` signature and `detect_request_scheme`** + + Add `ClientInfo` to the imports in `crates/trusted-server-core/src/http_util.rs`. Look at the existing `use crate::` lines at the top and add: + + ```rust + use crate::platform::ClientInfo; + ``` + + Change `RequestInfo::from_request` (line ~89): + + ```rust + // Before: + pub fn from_request(req: &Request) -> Self { + let host = extract_request_host(req); + let scheme = detect_request_scheme(req); + Self { host, scheme } + } + + // After: + pub fn from_request(req: &Request, client_info: &ClientInfo) -> Self { + let host = extract_request_host(req); + let scheme = detect_request_scheme(req, client_info.tls_protocol.as_deref(), client_info.tls_cipher.as_deref()); + Self { host, scheme } + } + ``` + + Change `detect_request_scheme` (line ~166): + + ```rust + // Before: + fn detect_request_scheme(req: &Request) -> String { + // 1. First try Fastly SDK's built-in TLS detection methods + if let Some(tls_protocol) = req.get_tls_protocol() { + log::debug!("TLS protocol detected: {}", tls_protocol); + return "https".to_string(); + } + + // Also check TLS cipher - if present, connection is HTTPS + if req.get_tls_cipher_openssl_name().is_some() { + log::debug!("TLS cipher detected, using HTTPS"); + return "https".to_string(); + } + // ... rest unchanged + + // After: + fn detect_request_scheme(req: &Request, tls_protocol: Option<&str>, tls_cipher: Option<&str>) -> String { + // 1. Check ClientInfo TLS fields (extracted once at entry point) + if let Some(protocol) = tls_protocol { + log::debug!("TLS protocol detected: {}", protocol); + return "https".to_string(); + } + if tls_cipher.is_some() { + log::debug!("TLS cipher detected, using HTTPS"); + return "https".to_string(); + } + // ... rest unchanged (Forwarded, X-Forwarded-Proto, Fastly-SSL, default http) + ``` + +- [ ] **Step 2: Update `http_util.rs` tests — replace 8 `from_request` call sites** + + Add `ClientInfo` import to the `#[cfg(test)]` module. Look for the existing `use super::*;` line and add below it: + + ```rust + use crate::platform::ClientInfo; + ``` + + Replace every `RequestInfo::from_request(&req)` in the test module with: + + ```rust + RequestInfo::from_request(&req, &ClientInfo { client_ip: None, tls_protocol: None, tls_cipher: None }) + ``` + + There are 8 call sites: the test functions at lines ~398, ~416, ~429, ~440, ~459, ~475, ~494, ~552. + +- [ ] **Step 3: Add a new test for TLS-detected HTTPS via `ClientInfo`** + + In the `#[cfg(test)]` module of `http_util.rs`, after the existing `RequestInfo` tests: + + ```rust + #[test] + fn request_info_https_from_client_info_tls_protocol() { + let req = Request::new(fastly::http::Method::GET, "https://test.example.com/page"); + let client_info = ClientInfo { + client_ip: None, + tls_protocol: Some("TLSv1.3".to_string()), + tls_cipher: None, + }; + + let info = RequestInfo::from_request(&req, &client_info); + + assert_eq!( + info.scheme, "https", + "should detect https from ClientInfo tls_protocol" + ); + } + ``` + +- [ ] **Step 4: Add `services: &RuntimeServices` to `handle_publisher_request` in `publisher.rs`** + + In `crates/trusted-server-core/src/publisher.rs`, add the import (check if already imported): + + ```rust + use crate::platform::RuntimeServices; + ``` + + Change the function signature (line ~290): + + ```rust + // Before: + pub fn handle_publisher_request( + settings: &Settings, + integration_registry: &IntegrationRegistry, + mut req: Request, + ) -> Result> + + // After: + pub fn handle_publisher_request( + settings: &Settings, + integration_registry: &IntegrationRegistry, + services: &RuntimeServices, + mut req: Request, + ) -> Result> + ``` + + Update the `RequestInfo::from_request` call (line ~301): + + ```rust + // Before: + let request_info = RequestInfo::from_request(&req); + + // After: + let request_info = RequestInfo::from_request(&req, &services.client_info); + ``` + +- [ ] **Step 5: Update `main.rs` to pass `runtime_services` to `handle_publisher_request`** + + `runtime_services` is already `&RuntimeServices` in `route_request` (line ~102), so no extra borrow is needed. + + In `crates/trusted-server-adapter-fastly/src/main.rs`, around line 195: + + ```rust + // Before: + match handle_publisher_request(settings, integration_registry, req) { + + // After: + match handle_publisher_request(settings, integration_registry, runtime_services, req) { + ``` + +- [ ] **Step 6: Update `prebid.rs` two `RequestInfo::from_request` call sites** + + In `crates/trusted-server-core/src/integrations/prebid.rs`: + + Line ~713: + + ```rust + // Before: + let request_info = RequestInfo::from_request(context.request); + + // After: + let request_info = RequestInfo::from_request(context.request, context.client_info); + ``` + + Line ~1011: + + ```rust + // Before: + let request_info = RequestInfo::from_request(context.request); + + // After: + let request_info = RequestInfo::from_request(context.request, context.client_info); + ``` + +- [ ] **Step 7: Update all stale Fastly-SDK-specific wording in `http_util.rs` comments** + + There are five locations that describe TLS as coming from "Fastly SDK" rather than `ClientInfo`. Update all of them: + + **Location 1 — `SPOOFABLE_FORWARDED_HEADERS` doc (line ~29-33):** + + ``` + // Before: "to fall back to the trustworthy `Host` header and Fastly SDK TLS detection." + // After: "to fall back to the trustworthy `Host` header and [`ClientInfo`] TLS detection." + ``` + + **Location 2 — `RequestInfo` `scheme` field doc (line ~67):** + + ``` + // Before: "The effective scheme (typically from Fastly SDK TLS detection after edge sanitization)." + // After: "The effective scheme (typically from [`ClientInfo`] TLS detection after edge sanitization)." + ``` + + **Location 3 — `RequestInfo` struct doc (line ~55-62):** + The doc mentions "on the Fastly edge [`sanitize_forwarded_headers`] strips those headers before this method is called, so the `Host` header and Fastly SDK TLS detection are the effective sources in production." Update to: + + ``` + // Before: "so the `Host` header and Fastly SDK TLS detection are the effective + // sources in production." + // After: "so the `Host` header and [`ClientInfo`] TLS detection are the effective + // sources in production." + ``` + + **Location 4 — `from_request` doc (lines ~72-88):** + + ``` + // Before first line: "Extract request info from a Fastly request." + // After: "Extract request info from an incoming request." + + // Before scheme list item 1: "1. Fastly SDK TLS detection" + // After: "1. [`ClientInfo`] TLS fields populated at the adapter entry point" + + // Before last sentence: "so `Host` and SDK TLS detection are the only sources that fire." + // After: "so `Host` and [`ClientInfo`] TLS detection are the only sources that fire." + ``` + + **Location 5 — `detect_request_scheme` doc (line ~158-161):** + + ``` + // Before: "/// Detects the request scheme (HTTP or HTTPS) using Fastly SDK methods and headers. + // /// + // /// Tries multiple methods in order of reliability: + // /// 1. Fastly SDK TLS detection methods (most reliable)" + // After: "/// Detects the request scheme (HTTP or HTTPS) from ClientInfo TLS fields and headers. + // /// + // /// Tries multiple sources in order of reliability: + // /// 1. [`ClientInfo`] TLS fields populated at the adapter entry point (most reliable)" + ``` + +- [ ] **Step 8: Update the stale routing snippet in `crates/trusted-server-core/src/auction/README.md`** + + The entire routing snippet at lines ~277-292 is conceptually stale — `handle_auction`, `integration_registry.handle_proxy`, and `handle_publisher_request` all have different signatures than shown. Replace the whole block to match the current `main.rs` structure: + + ``` + // Before (lines ~277-292): + let result = match (method, path.as_str()) { + (Method::POST, "/auction") => handle_auction(&settings, req).await, + (Method::GET, "/first-party/proxy") => handle_first_party_proxy(&settings, req).await, + (m, path) if integration_registry.has_route(&m, path) => { + integration_registry.handle_proxy(&m, path, &settings, req).await + }, + _ => handle_publisher_request(&settings, &integration_registry, req), + } + + // After: + let result = match (method, path.as_str()) { + (Method::POST, "/auction") => { + handle_auction(settings, orchestrator, runtime_services, req).await + } + (m, path) if integration_registry.has_route(&m, path) => { + integration_registry.handle_proxy(&m, path, settings, runtime_services, req).await + // ... + } + _ => { + handle_publisher_request(settings, integration_registry, runtime_services, req) + } + } + ``` + +- [ ] **Step 9: Run tests to verify Task 2 compiles and passes** + + ```bash + cargo test --workspace + ``` + + Expected: all tests pass including the new TLS test. + +- [ ] **Step 10: Commit Task 2** + + ```bash + git add crates/trusted-server-core/src/http_util.rs \ + crates/trusted-server-core/src/publisher.rs \ + crates/trusted-server-core/src/integrations/prebid.rs \ + crates/trusted-server-adapter-fastly/src/main.rs \ + crates/trusted-server-core/src/auction/README.md + git commit -m "Change RequestInfo::from_request to take &ClientInfo, thread services into handle_publisher_request" + ``` + +--- + +## Task 3: Add `services` to `generate_synthetic_id` and fix all callers including `formats.rs` geo + +This task changes `synthetic.rs` and simultaneously fixes all 4 callers. `formats.rs` also gets the `geo` parameter so DeviceInfo.geo no longer uses the deprecated call. + +**Files:** + +- Modify: `crates/trusted-server-core/src/synthetic.rs:96-99, 216-218` +- Modify: `crates/trusted-server-core/src/auction/formats.rs:82-88, 91, 136-143` +- Modify: `crates/trusted-server-core/src/auction/endpoints.rs:10-13, 52, 61-68, 71-72` +- Modify: `crates/trusted-server-core/src/integrations/registry.rs:662` +- Modify: `crates/trusted-server-core/src/publisher.rs:328` + +- [ ] **Step 1: Update `synthetic.rs` — add `services: &RuntimeServices` to both functions** + + In `crates/trusted-server-core/src/synthetic.rs`, add `RuntimeServices` to imports: + + ```rust + use crate::platform::RuntimeServices; + ``` + + Change `generate_synthetic_id` (line ~96): + + ```rust + // Before: + pub fn generate_synthetic_id( + settings: &Settings, + req: &Request, + ) -> Result> + + // After: + pub fn generate_synthetic_id( + settings: &Settings, + services: &RuntimeServices, + req: &Request, + ) -> Result> + ``` + + Inside the function, replace line ~100: + + ```rust + // Before: + let client_ip = req.get_client_ip_addr().map(normalize_ip); + + // After: + let client_ip = services.client_info.client_ip.map(normalize_ip); + ``` + + Change `get_or_generate_synthetic_id` (line ~216): + + ```rust + // Before: + pub fn get_or_generate_synthetic_id( + settings: &Settings, + req: &Request, + ) -> Result> + + // After: + pub fn get_or_generate_synthetic_id( + settings: &Settings, + services: &RuntimeServices, + req: &Request, + ) -> Result> + ``` + + Inside `get_or_generate_synthetic_id`, update the `generate_synthetic_id` call: + + ```rust + // Before: + let synthetic_id = generate_synthetic_id(settings, req)?; + + // After: + let synthetic_id = generate_synthetic_id(settings, services, req)?; + ``` + +- [ ] **Step 2: Update `synthetic.rs` tests — add `noop_services` import and thread to test calls** + + In the `#[cfg(test)]` module of `synthetic.rs`, add: + + ```rust + use crate::platform::test_support::noop_services; + ``` + + Update every `generate_synthetic_id(&settings, &req)` call in the test module: + + ```rust + // Before: + generate_synthetic_id(&settings, &req) + + // After: + generate_synthetic_id(&settings, &noop_services(), &req) + ``` + + Update every `get_or_generate_synthetic_id(&settings, &req)` call: + + ```rust + // Before: + get_or_generate_synthetic_id(&settings, &req) + + // After: + get_or_generate_synthetic_id(&settings, &noop_services(), &req) + ``` + +- [ ] **Step 3: Update `formats.rs` — add `services` and `geo` params, fix IP and geo extraction** + + In `crates/trusted-server-core/src/auction/formats.rs`, add imports: + + ```rust + use crate::platform::{GeoInfo, RuntimeServices}; + ``` + + (Remove the existing `use crate::geo::GeoInfo;` if present — `GeoInfo` is re-exported from `platform`.) + + Actually check: `formats.rs` currently imports `use crate::geo::GeoInfo;` at line 19. Change to: + + ```rust + use crate::platform::{GeoInfo, RuntimeServices}; + ``` + + Change `convert_tsjs_to_auction_request` signature: + + ```rust + // Before: + pub fn convert_tsjs_to_auction_request( + body: &AdRequest, + settings: &Settings, + req: &Request, + consent: ConsentContext, + synthetic_id: &str, + ) -> Result> + + // After: + pub fn convert_tsjs_to_auction_request( + body: &AdRequest, + settings: &Settings, + req: &Request, + services: &RuntimeServices, + geo: Option, + consent: ConsentContext, + synthetic_id: &str, + ) -> Result> + ``` + + Update the `generate_synthetic_id` call (line ~91): + + ```rust + // Before: + let fresh_id = + generate_synthetic_id(settings, req).change_context(TrustedServerError::Auction { + message: "Failed to generate fresh ID".to_string(), + })?; + + // After: + let fresh_id = + generate_synthetic_id(settings, services, req).change_context(TrustedServerError::Auction { + message: "Failed to generate fresh ID".to_string(), + })?; + ``` + + Replace `DeviceInfo` construction (lines ~136-143): + + ```rust + // Before: + let device = Some(DeviceInfo { + user_agent: req + .get_header_str("user-agent") + .map(std::string::ToString::to_string), + ip: req.get_client_ip_addr().map(|ip| ip.to_string()), + #[allow(deprecated)] + geo: GeoInfo::from_request(req), + }); + + // After: + let device = Some(DeviceInfo { + user_agent: req + .get_header_str("user-agent") + .map(std::string::ToString::to_string), + ip: services.client_info.client_ip.map(|ip| ip.to_string()), + geo, + }); + ``` + +- [ ] **Step 4: Update `endpoints.rs` — thread `services` to synthetic, compute geo, update `convert_tsjs` call** + + In `crates/trusted-server-core/src/auction/endpoints.rs`: + + Remove the `use crate::geo::GeoInfo;` import at line 10. After the change `GeoInfo` is no longer referenced by name in this file (the `geo` local's type is inferred from `services.geo().lookup()`). Leaving the import causes a clippy unused-import error. + + Update `get_or_generate_synthetic_id` call (line ~52): + + ```rust + // Before: + let synthetic_id = get_or_generate_synthetic_id(settings, &req).change_context( + + // After: + let synthetic_id = get_or_generate_synthetic_id(settings, services, &req).change_context( + ``` + + Replace the deprecated `GeoInfo::from_request` call (lines ~60-61) with geo lookup: + + ```rust + // Before: + #[allow(deprecated)] + let geo = GeoInfo::from_request(&req); + + // After: + let geo = services + .geo() + .lookup(services.client_info.client_ip) + .unwrap_or_else(|e| { + log::warn!("geo lookup failed: {e:?}"); + None + }); + ``` + + Update `convert_tsjs_to_auction_request` call (line ~71): + + ```rust + // Before: + let auction_request = + convert_tsjs_to_auction_request(&body, settings, &req, consent_context, &synthetic_id)?; + + // After: + let auction_request = + convert_tsjs_to_auction_request(&body, settings, &req, services, geo, consent_context, &synthetic_id)?; + ``` + +- [ ] **Step 5: Update `registry.rs` — thread `services` to `get_or_generate_synthetic_id`** + + In `crates/trusted-server-core/src/integrations/registry.rs`, line ~662: + + ```rust + // Before: + let synthetic_id_result = get_or_generate_synthetic_id(settings, &req); + + // After: + let synthetic_id_result = get_or_generate_synthetic_id(settings, services, &req); + ``` + +- [ ] **Step 6: Update `publisher.rs` — thread `services` to `get_or_generate_synthetic_id`** + + In `crates/trusted-server-core/src/publisher.rs`, line ~328 (production call): + + ```rust + // Before: + let synthetic_id = get_or_generate_synthetic_id(settings, &req)?; + + // After: + let synthetic_id = get_or_generate_synthetic_id(settings, services, &req)?; + ``` + + Also update the test call site at line ~695. Add `noop_services` to the `#[cfg(test)]` module imports if not already present: + + ```rust + use crate::platform::test_support::noop_services; + ``` + + Then fix the test call: + + ```rust + // Before: + let resolved_synthetic_id = + get_or_generate_synthetic_id(&settings, &req).expect("should resolve synthetic id"); + + // After: + let resolved_synthetic_id = + get_or_generate_synthetic_id(&settings, &noop_services(), &req).expect("should resolve synthetic id"); + ``` + +- [ ] **Step 7: Run tests to verify Task 3 compiles and passes** + + ```bash + cargo test --workspace + ``` + + Expected: all tests pass. + +- [ ] **Step 8: Commit Task 3** + + ```bash + git add crates/trusted-server-core/src/synthetic.rs \ + crates/trusted-server-core/src/auction/formats.rs \ + crates/trusted-server-core/src/auction/endpoints.rs \ + crates/trusted-server-core/src/integrations/registry.rs \ + crates/trusted-server-core/src/publisher.rs + git commit -m "Add services param to generate_synthetic_id, remove Fastly IP/geo calls in formats and endpoints" + ``` + +--- + +## Task 4: Fix `publisher.rs` geo — replace deprecated `GeoInfo::from_request` + +`publisher.rs` now has `services` (from Task 2) so this is a straightforward swap. + +**Files:** + +- Modify: `crates/trusted-server-core/src/publisher.rs:335-336` + +- [ ] **Step 1: Replace `GeoInfo::from_request` in `publisher.rs`** + + In `crates/trusted-server-core/src/publisher.rs`, around line 335: + + ```rust + // Before: + #[allow(deprecated)] + let geo = crate::geo::GeoInfo::from_request(&req); + + // After: + let geo = services + .geo() + .lookup(services.client_info.client_ip) + .unwrap_or_else(|e| { + log::warn!("geo lookup failed: {e:?}"); + None + }); + ``` + + Verify the existing `use crate::platform::GeoInfo` is present or the type is not needed by name in `publisher.rs` (the `geo` variable is `Option` but GeoInfo may not be used by name). If `GeoInfo` is referenced by name, add the import: + + ```rust + use crate::platform::GeoInfo; + ``` + +- [ ] **Step 2: Check for any remaining `use crate::geo::GeoInfo` in `publisher.rs`** + + If `publisher.rs` still has `use crate::geo::GeoInfo` and it's no longer needed, remove that import. + +- [ ] **Step 3: Run tests** + + ```bash + cargo test --workspace + ``` + + Expected: all tests pass. + +- [ ] **Step 4: Commit Task 4** + + ```bash + git add crates/trusted-server-core/src/publisher.rs + git commit -m "Replace deprecated GeoInfo::from_request in publisher.rs with services.geo().lookup()" + ``` + +--- + +## Task 5: Fix `integrations/didomi.rs` — rename `_services`, update `copy_headers` + +**Files:** + +- Modify: `crates/trusted-server-core/src/integrations/didomi.rs:101-128, 199-220` + +- [ ] **Step 1: Update `copy_headers` signature** + + In `crates/trusted-server-core/src/integrations/didomi.rs`, change the `copy_headers` method: + + ```rust + // Before: + fn copy_headers( + &self, + backend: &DidomiBackend, + original_req: &Request, + proxy_req: &mut Request, + ) { + if let Some(client_ip) = original_req.get_client_ip_addr() { + proxy_req.set_header("X-Forwarded-For", client_ip.to_string()); + } + // ... + + // After: + fn copy_headers( + &self, + backend: &DidomiBackend, + client_ip: Option, + original_req: &Request, + proxy_req: &mut Request, + ) { + if let Some(ip) = client_ip { + proxy_req.set_header("X-Forwarded-For", ip.to_string()); + } + // ... rest of the method unchanged + ``` + +- [ ] **Step 2: Rename `_services` to `services` and update the `copy_headers` call in `handle`** + + In the `handle` method (around line 199), rename `_services` to `services`: + + ```rust + // Before: + async fn handle( + &self, + _settings: &Settings, + _services: &RuntimeServices, + req: Request, + ) -> Result> + + // After: + async fn handle( + &self, + _settings: &Settings, + services: &RuntimeServices, + req: Request, + ) -> Result> + ``` + + Update the `copy_headers` call inside `handle` (around line 220): + + ```rust + // Before: + self.copy_headers(&backend, &req, &mut proxy_req); + + // After: + self.copy_headers(&backend, services.client_info.client_ip, &req, &mut proxy_req); + ``` + +- [ ] **Step 3: Add a focused unit test for `copy_headers`** + + In the `#[cfg(test)]` module of `didomi.rs`, add: + + ```rust + #[test] + fn copy_headers_sets_x_forwarded_for_from_client_ip() { + use std::net::{IpAddr, Ipv4Addr}; + let integration = DidomiIntegration::new(Arc::new(config(true))); + let backend = DidomiBackend::Sdk; + let original_req = Request::new(Method::GET, "https://example.com/test"); + let mut proxy_req = Request::new(Method::GET, "https://sdk.privacy-center.org/test"); + let client_ip = Some(IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4))); + + integration.copy_headers(&backend, client_ip, &original_req, &mut proxy_req); + + assert_eq!( + proxy_req + .get_header("X-Forwarded-For") + .and_then(|v| v.to_str().ok()), + Some("1.2.3.4"), + "should set X-Forwarded-For from client_ip" + ); + } + + #[test] + fn copy_headers_omits_x_forwarded_for_when_no_client_ip() { + let integration = DidomiIntegration::new(Arc::new(config(true))); + let backend = DidomiBackend::Sdk; + let original_req = Request::new(Method::GET, "https://example.com/test"); + let mut proxy_req = Request::new(Method::GET, "https://sdk.privacy-center.org/test"); + + integration.copy_headers(&backend, None, &original_req, &mut proxy_req); + + assert!( + proxy_req.get_header("X-Forwarded-For").is_none(), + "should omit X-Forwarded-For when client_ip is None" + ); + } + ``` + +- [ ] **Step 4: Run tests** + + ```bash + cargo test --workspace + ``` + + Expected: all tests pass including the two new `copy_headers` tests. + +- [ ] **Step 5: Commit Task 5** + + ```bash + git add crates/trusted-server-core/src/integrations/didomi.rs + git commit -m "Remove Fastly IP extraction from Didomi copy_headers, use ClientInfo instead" + ``` + +--- + +## Task 6: Verify acceptance criteria + +- [ ] **Step 1: Verify zero active-code Fastly SDK calls remain** + + ```bash + grep -rn "get_client_ip_addr\|get_tls_protocol\|get_tls_cipher_openssl_name" \ + crates/trusted-server-core/src/ \ + --include="*.rs" + ``` + + Expected output: only the deprecated function body in `crates/trusted-server-core/src/geo.rs` — no other matches. + +- [ ] **Step 2: Verify zero `#[allow(deprecated)]` on `GeoInfo::from_request` call sites** + + ```bash + grep -rn "allow(deprecated)" crates/trusted-server-core/src/ --include="*.rs" + ``` + + Expected: no results for lines adjacent to `GeoInfo::from_request` calls. Any remaining `#[allow(deprecated)]` should be for unrelated items (e.g., in `nextjs/html_post_process.rs`). + +- [ ] **Step 3: Run full CI checks** + + ```bash + cargo fmt --all -- --check + ``` + + Expected: no formatting issues. + + ```bash + cargo clippy --workspace --all-targets --all-features -- -D warnings + ``` + + Expected: no warnings. + + ```bash + cargo test --workspace + ``` + + Expected: all tests pass. + + ```bash + cargo build --package trusted-server-adapter-fastly --release --target wasm32-wasip1 + ``` + + Expected: wasm32 build succeeds. + +- [ ] **Step 4: Commit final verification result** + + No code changes expected in this step. If clippy or fmt flagged specific files, fix them and commit only those files: + + ```bash + # Example — replace with the actual files that needed changes: + git add crates/trusted-server-core/src/some_file.rs + git commit -m "Fix clippy and fmt issues from PR7 threading changes" + ``` + + If no changes are needed, no commit is required. diff --git a/docs/superpowers/specs/2026-03-30-pr7-geo-client-info-design.md b/docs/superpowers/specs/2026-03-30-pr7-geo-client-info-design.md new file mode 100644 index 00000000..821ad101 --- /dev/null +++ b/docs/superpowers/specs/2026-03-30-pr7-geo-client-info-design.md @@ -0,0 +1,481 @@ +# PR 7 Design — Geo Lookup + Client Info (Extract-Once) + +> Phase 1, PR 7 of the EdgeZero migration. +> Implements [#488](https://github.com/IABTechLab/trusted-server/issues/488), +> part of [#480](https://github.com/IABTechLab/trusted-server/issues/480). +> Blocked by PR 2. + +--- + +## Goal + +Eliminate redundant per-call-site extraction of client IP and TLS metadata +from the Fastly request object inside `trusted-server-core`. After this PR, +every function that previously called `req.get_client_ip_addr()`, +`req.get_tls_protocol()`, or `req.get_tls_cipher_openssl_name()` reads from +`services.client_info` instead. The Fastly SDK extraction happens exactly once +— in `build_runtime_services()` at the adapter entry point. + +--- + +## Baseline (already done in PR 6) + +These are in place and not touched by PR 7: + +- `ClientInfo` struct in `platform/types.rs` with `client_ip: Option`, + `tls_protocol: Option`, `tls_cipher: Option` +- `RuntimeServices.client_info: ClientInfo` field + builder support +- `FastlyPlatformGeo` implementation in + `trusted-server-adapter-fastly/src/platform.rs` +- `build_runtime_services()` already extracts all three `ClientInfo` fields + from the Fastly request +- `main.rs` geo lookup already uses + `services.geo().lookup(services.client_info.client_ip)` — that call site + is complete + +--- + +## Architecture + +### Core principle + +`ClientInfo` is populated exactly once in `build_runtime_services()` at the +adapter entry point. Every downstream function that currently extracts client +metadata from the Fastly request instead reads from `services.client_info`. +The platform-specific extraction never leaves the adapter crate. + +### Injection pattern + +Follows the Phase 1 doc pattern: internal utility functions that currently +call Fastly SDK methods gain a `services: &RuntimeServices` parameter where +possible. Two exceptions: + +- `RequestInfo::from_request` takes `&ClientInfo` (not `&RuntimeServices`) so + it remains callable from both `publisher.rs` (which has `services`) and + `prebid.rs` (which only has `AuctionContext.client_info`). +- `didomi.rs` `copy_headers` takes `Option` directly — a private + helper only needs the scalar value. + +Callers already hold `RuntimeServices` and thread it through — no new +construction or allocation. + +**One exception — `AuctionContext`:** This struct sits between handlers and +auction providers (prebid, APS). PR 12.5 ("Thread RuntimeServices into +integrations") is the designated PR for adding full `&RuntimeServices` to that +layer. PR 7 adds only `client_info: &'a ClientInfo` to `AuctionContext` — +the minimum to fix the two prebid `RequestInfo::from_request` call sites +without stepping on PR 12.5's scope. + +### No new traits or structs + +This PR is purely plumbing. `ClientInfo`, `PlatformGeo`, `RuntimeServices`, +and all traits are already defined. PR 7 only removes Fastly SDK calls from +core by threading existing abstractions to the remaining call sites. + +--- + +## File-by-File Changes + +### `crates/trusted-server-core/src/synthetic.rs` + +**Current:** `generate_synthetic_id(settings, req: &Request)` calls +`req.get_client_ip_addr()` on line 100 of `synthetic.rs`. +`get_or_generate_synthetic_id` calls `generate_synthetic_id`. + +**Change:** Add `services: &RuntimeServices` parameter to both functions. +Replace `req.get_client_ip_addr()` with `services.client_info.client_ip`. +The `req: &Request` parameter stays — still needed for `User-Agent`, +`Accept-Language`, `Accept-Encoding` headers and cookie reading. + +```rust +// Before +pub fn generate_synthetic_id( + settings: &Settings, + req: &Request, +) -> Result> + +// After +pub fn generate_synthetic_id( + settings: &Settings, + services: &RuntimeServices, + req: &Request, +) -> Result> +// Inside: let client_ip = services.client_info.client_ip.map(normalize_ip); +``` + +**Callers that update:** `publisher.rs`, `auction/endpoints.rs`, +`auction/formats.rs`, `integrations/registry.rs`. + +--- + +### `crates/trusted-server-core/src/http_util.rs` + +**Current:** `RequestInfo::from_request(req)` calls private +`detect_request_scheme(req)`, which calls `req.get_tls_protocol()` (line 168) +and `req.get_tls_cipher_openssl_name()` (line 174) to determine HTTPS. + +**Change:** `RequestInfo::from_request(req, client_info)` — `detect_request_scheme` +gains `tls_protocol: Option<&str>` and `tls_cipher: Option<&str>` parameters +instead of calling the SDK. The `req: &Request` parameter stays for host +extraction and the forwarded/`X-Forwarded-Proto`/`Fastly-SSL` header fallbacks. + +Taking `&ClientInfo` (not `&RuntimeServices`) keeps the signature usable from +both `publisher.rs` (which has `&services.client_info`) and `prebid.rs` +(which only has `context.client_info: &ClientInfo`). + +```rust +// Before +pub fn from_request(req: &Request) -> Self + +// After +pub fn from_request(req: &Request, client_info: &ClientInfo) -> Self +// passes client_info.tls_protocol.as_deref() +// client_info.tls_cipher.as_deref() +// into detect_request_scheme +``` + +`detect_request_scheme` remains private. Its signature becomes: + +```rust +fn detect_request_scheme( + req: &Request, + tls_protocol: Option<&str>, + tls_cipher: Option<&str>, +) -> String +``` + +**Test updates:** There are 8 `RequestInfo::from_request` call sites in the +`http_util.rs` test module (lines 398, 416, 429, 440, 459, 475, 494, 552). +All must pass a zero-filled `ClientInfo`. Add the following import to the +`#[cfg(test)]` module if `ClientInfo` is not already in scope: + +```rust +use crate::platform::ClientInfo; +``` + +Each call site becomes: + +```rust +RequestInfo::from_request(&req, &ClientInfo { client_ip: None, tls_protocol: None, tls_cipher: None }) +``` + +Add one new test: TLS-detected HTTPS using a `ClientInfo` with +`tls_protocol: Some("TLSv1.3".to_string())`, confirming that +`detect_request_scheme` returns `"https"` when the protocol is set in +`ClientInfo` rather than from the Fastly SDK call. + +--- + +### `crates/trusted-server-core/src/integrations/didomi.rs` + +**Current:** `copy_headers` calls `original_req.get_client_ip_addr()` (line 107) +to set `X-Forwarded-For`. The `handle` method already has `_services: +&RuntimeServices` (currently unused — note the underscore). + +**Change:** `copy_headers` gains `client_ip: Option` as a parameter. +In `handle`, rename the existing `_services` parameter to `services` (remove +the `_` prefix — do not add a new parameter). Pass `services.client_info.client_ip` +to `copy_headers`. + +`copy_headers` is a private method and only needs the IP value — passing +`Option` directly is cleaner than full `services` in an internal +helper. + +```rust +// Before +fn copy_headers( + &self, + backend: &DidomiBackend, + original_req: &Request, + proxy_req: &mut Request, +) +// inside: original_req.get_client_ip_addr() + +// After +fn copy_headers( + &self, + backend: &DidomiBackend, + client_ip: Option, + original_req: &Request, + proxy_req: &mut Request, +) +// inside: client_ip (no SDK call) +// caller in handle(): +// self.copy_headers(&backend, services.client_info.client_ip, &req, &mut proxy_req) +``` + +--- + +### `crates/trusted-server-core/src/auction/formats.rs` + +**Current:** `convert_tsjs_to_auction_request` calls: + +- `generate_synthetic_id(settings, req)` at line 91 — produces `fresh_id` + for `UserInfo`, uses Fastly IP extraction internally +- `req.get_client_ip_addr()` (line 140) for `DeviceInfo.ip` +- `GeoInfo::from_request(req)` (deprecated, line 142) for `DeviceInfo.geo` + +Note: `generate_synthetic_id` is called once in `formats.rs`, at line 91, to +produce `fresh_id` for `UserInfo`. The `DeviceInfo.ip` fix at line 140 is a +separate `req.get_client_ip_addr()` call that is addressed independently by +reading `services.client_info.client_ip`. + +**Change:** Add `services: &RuntimeServices` and `geo: Option` +parameters. Thread `services` into the `generate_synthetic_id` call at line 91 +to fix `fresh_id` generation. Replace the separate `req.get_client_ip_addr()` +call at line 140 with `services.client_info.client_ip` for `DeviceInfo.ip`. +Use the `geo` parameter for `DeviceInfo.geo`. Remove the `#[allow(deprecated)]` +annotation. + +```rust +// Before +pub fn convert_tsjs_to_auction_request( + body: &AdRequest, + settings: &Settings, + req: &Request, + consent: ConsentContext, + synthetic_id: &str, +) -> Result> + +// After +pub fn convert_tsjs_to_auction_request( + body: &AdRequest, + settings: &Settings, + req: &Request, + services: &RuntimeServices, + geo: Option, + consent: ConsentContext, + synthetic_id: &str, +) -> Result> +``` + +The `geo` parameter is computed by the caller (`auction/endpoints.rs`) via +`services.geo().lookup(services.client_info.client_ip)` and passed in. This +avoids a second geo lookup inside `formats.rs`. + +--- + +### `crates/trusted-server-core/src/auction/endpoints.rs` + +**Current:** Already has `services: &RuntimeServices`. Calls: + +- `get_or_generate_synthetic_id(settings, &req)` — Fastly IP extraction +- `GeoInfo::from_request(&req)` (deprecated, line 61) +- `convert_tsjs_to_auction_request(body, settings, &req, consent, &synthetic_id)` + +**Change:** + +1. Replace `get_or_generate_synthetic_id(settings, &req)` with + `get_or_generate_synthetic_id(settings, services, &req)`. + +2. Replace `GeoInfo::from_request(&req)` with + `services.geo().lookup(services.client_info.client_ip)`. Handle the + `Result` with `unwrap_or_else(|e| { log::warn!(...); None })` — same + pattern as `main.rs`. Remove `#[allow(deprecated)]`. + +3. Pass `services` and `geo` into `convert_tsjs_to_auction_request`. + +4. Set `AuctionContext.client_info = &services.client_info` (new field, + see below). + +The geo value is computed once and used for both `consent::build_consent_context` +and `convert_tsjs_to_auction_request` — no double lookup. + +--- + +### `crates/trusted-server-core/src/publisher.rs` + +**Current:** `handle_publisher_request(settings, integration_registry, req)` +calls: + +- `RequestInfo::from_request(&req)` — TLS SDK extraction +- `get_or_generate_synthetic_id(settings, &req)` — IP SDK extraction +- `GeoInfo::from_request(&req)` (deprecated, line 336) + +**Change:** Add `services: &RuntimeServices` parameter. Thread to all three +call sites: + +1. `RequestInfo::from_request(&req)` → `RequestInfo::from_request(&req, &services.client_info)` +2. `get_or_generate_synthetic_id(settings, &req)` → `get_or_generate_synthetic_id(settings, services, &req)` +3. Replace deprecated geo call with `services.geo().lookup(services.client_info.client_ip)`. + Handle the `Result` with warn-and-continue — same pattern as `main.rs`: + ```rust + let geo = services.geo().lookup(services.client_info.client_ip) + .unwrap_or_else(|e| { log::warn!("geo lookup failed: {e:?}"); None }); + ``` + Remove `#[allow(deprecated)]`. + +```rust +// Before +pub fn handle_publisher_request( + settings: &Settings, + integration_registry: &IntegrationRegistry, + req: Request, +) -> Result> + +// After +pub fn handle_publisher_request( + settings: &Settings, + integration_registry: &IntegrationRegistry, + services: &RuntimeServices, + req: Request, +) -> Result> +``` + +--- + +### `crates/trusted-server-core/src/auction/types.rs` — `AuctionContext` + +**Current:** + +```rust +pub struct AuctionContext<'a> { + pub settings: &'a Settings, + pub request: &'a Request, + pub timeout_ms: u32, + pub provider_responses: Option<&'a [AuctionResponse]>, +} +``` + +**Change:** Add `client_info: &'a ClientInfo`. + +```rust +pub struct AuctionContext<'a> { + pub settings: &'a Settings, + pub request: &'a Request, + pub timeout_ms: u32, + pub provider_responses: Option<&'a [AuctionResponse]>, + pub client_info: &'a ClientInfo, // new in PR 7 +} +``` + +**All `AuctionContext` construction sites** — every site must add +`client_info: &services.client_info` (production) or propagate an existing +`client_info` reference (derived contexts): + +| File | Line | Type | Change | +| ------------------------- | ----- | ----------------------------------------- | ----------------------------------------------------------------- | +| `auction/endpoints.rs` | ~75 | production | `client_info: &services.client_info` | +| `auction/orchestrator.rs` | ~145 | production | `client_info: context.client_info` (copy from incoming `context`) | +| `auction/orchestrator.rs` | ~321 | production | `client_info: context.client_info` (copy from incoming `context`) | +| `auction/orchestrator.rs` | ~677 | test helper `create_test_context` | add `client_info: &ClientInfo` param, thread through | +| `integrations/prebid.rs` | ~1287 | test helper `create_test_auction_context` | add `client_info: &ClientInfo` param, thread through | +| `integrations/prebid.rs` | ~2671 | test helper `call_to_openrtb` | add `client_info: &ClientInfo` param, thread through | + +The three test helpers need a `client_info: &ClientInfo` parameter added, and +all callers of those helpers must pass `&ClientInfo { client_ip: None, tls_protocol: None, tls_cipher: None }`. + +Example for `auction/endpoints.rs`: + +```rust +let context = AuctionContext { + settings, + request: &req, + timeout_ms: settings.auction.timeout_ms, + provider_responses: None, + client_info: &services.client_info, // new +}; +``` + +**Why `&ClientInfo`, not `&RuntimeServices`:** PR 12.5 ("Thread +RuntimeServices into integrations") is the designated PR for adding full +services access to the auction provider layer. Adding only `client_info` here +keeps PR 7 minimal and avoids overlap. + +--- + +### `crates/trusted-server-core/src/integrations/prebid.rs` + +**Current:** Two call sites: + +- Line 713: `RequestInfo::from_request(context.request)` +- Line 1011: `RequestInfo::from_request(context.request)` + +**Change:** Both become `RequestInfo::from_request(context.request, context.client_info)`. +No other prebid changes in this PR. + +--- + +### `crates/trusted-server-core/src/integrations/registry.rs` + +**Current:** `handle_proxy` calls `get_or_generate_synthetic_id(settings, &req)`. +Already has `services: &RuntimeServices`. + +**Change:** Pass `services` through: +`get_or_generate_synthetic_id(settings, services, &req)`. + +--- + +### `crates/trusted-server-adapter-fastly/src/main.rs` + +Two changes: + +1. Pass `&runtime_services` to `handle_publisher_request`. The call is inside + the `route_request` function's fallback `match` arm; `runtime_services` is + already in scope there. + +```rust +// Before (inside route_request fallback arm, line ~195) +match handle_publisher_request(settings, integration_registry, req) { + +// After +match handle_publisher_request(settings, integration_registry, &runtime_services, req) { +``` + +2. No geo lookup changes — already uses `services.geo().lookup(...)`. + +--- + +## What Does NOT Change + +- `geo.rs` — `GeoInfo::from_request` stays (deprecated marker stays), + `geo_from_fastly`, `set_response_headers`, GDPR helpers — all unchanged +- `trusted-server-adapter-fastly/src/platform.rs` — no changes +- `platform/` module — no new traits or structs +- All integrations except `didomi.rs` and `prebid.rs` — unchanged +- `proxy.rs`, `auth.rs`, `consent/`, `cookies.rs` — unchanged + +--- + +## Testing + +| File | Test change | +| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `synthetic.rs` | Pass `noop_services()` to existing tests; add `use crate::platform::test_support::noop_services;` to `#[cfg(test)]` module; tests still pass `req` | +| `http_util.rs` | Pass `&ClientInfo { client_ip: None, tls_protocol: None, tls_cipher: None }` to all `RequestInfo::from_request` calls (8 sites); add one new test for TLS-detected HTTPS via `ClientInfo { tls_protocol: Some("TLSv1.3".to_string()), .. }` | +| `auction/formats.rs` | **No test module exists** — no test updates needed in this file | +| `didomi.rs` | Pass `client_ip: None` to `copy_headers` in any existing tests | +| `auction/endpoints.rs` | **No test module exists** — no test updates needed in this file | +| `publisher.rs` | Pass `noop_services()` to existing publisher tests | +| `auction/orchestrator.rs` | Update `create_test_context` helper to accept and thread `client_info: &ClientInfo`; all callers pass `&ClientInfo { client_ip: None, tls_protocol: None, tls_cipher: None }` | +| `integrations/prebid.rs` | Update `create_test_auction_context` and `call_to_openrtb` test helpers to accept and thread `client_info: &ClientInfo`; all callers pass `&ClientInfo { client_ip: None, tls_protocol: None, tls_cipher: None }` | + +All existing tests must continue to pass. No behavior changes — only extraction +source changes (from Fastly SDK calls to `ClientInfo` fields that contain the +same values). + +--- + +## Acceptance Criteria + +- [ ] Zero `req.get_client_ip_addr()` calls in active (non-deprecated) code in `trusted-server-core` (the deprecated body of `GeoInfo::from_request` in `geo.rs` is excluded — that body stays and is covered by the `#[deprecated]` marker itself) +- [ ] Zero `req.get_tls_protocol()` calls in active (non-deprecated) code in `trusted-server-core` +- [ ] Zero `req.get_tls_cipher_openssl_name()` calls in active (non-deprecated) code in `trusted-server-core` +- [ ] Zero `#[allow(deprecated)]` on `GeoInfo::from_request` calls (the `#[deprecated]` attribute on `GeoInfo::from_request` itself is preserved — only the call-site suppressors are removed; unrelated `#[allow(deprecated)]` annotations in `nextjs/html_post_process.rs` are for a different deprecated function and are out of scope for this PR) +- [ ] `ClientInfo` populated at entry point (PR6 ✅, PR7 verifies no regressions) +- [ ] All production client metadata originates from `RuntimeServices.client_info` (provider-layer reads happen via `AuctionContext.client_info`, which is populated from `&services.client_info` at the endpoint layer) +- [ ] CI gates pass: `cargo build --workspace`, wasm32 build, `cargo test --workspace`, clippy `-D warnings`, `cargo fmt` + +--- + +## Migration Path Alignment + +After this PR, `trusted-server-core` no longer extracts client IP or TLS +metadata from `fastly::Request`. The Fastly-specific extraction is fully +contained in `build_runtime_services()` in the adapter crate. When Phase 2 +(PR 11-13) replaces `fastly::Request` with `http::Request` throughout core, +these utility functions (`generate_synthetic_id`, `RequestInfo::from_request`, +etc.) already read from `services.client_info` and require zero further +changes for client metadata. The EdgeZero adapter (PR 14-15) will populate +`ClientInfo` from whatever mechanism the EdgeZero framework provides (request +extensions, worker context, etc.) — no core changes needed.