From 6e9b880d4cf167d64906bec4cf6b5153ee104a1b Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Mon, 7 Sep 2026 15:56:40 +0530 Subject: [PATCH 01/10] Add Didomi geo forwarding configuration --- .../trusted-server-core/src/config_payload.rs | 31 +++++++++++++ .../src/integrations/didomi.rs | 46 ++++++++++++++++++- 2 files changed, 76 insertions(+), 1 deletion(-) diff --git a/crates/trusted-server-core/src/config_payload.rs b/crates/trusted-server-core/src/config_payload.rs index 6ede36e9c..9aaf1f0ea 100644 --- a/crates/trusted-server-core/src/config_payload.rs +++ b/crates/trusted-server-core/src/config_payload.rs @@ -44,6 +44,7 @@ pub fn settings_from_config_blob( #[cfg(test)] mod tests { use super::*; + use crate::integrations::didomi::DidomiIntegrationConfig; use crate::redacted::Redacted; use crate::test_support::tests::crate_test_settings_str; use serde::Deserialize; @@ -99,6 +100,36 @@ mod tests { ); } + #[test] + fn didomi_geo_query_parameters_survive_blob_round_trip() { + let mut original = test_settings(); + original + .integrations + .insert_config( + "didomi", + &DidomiIntegrationConfig { + enabled: true, + geo_query_parameters: true, + proxy_path: None, + sdk_origin: "https://sdk.privacy-center.org".to_string(), + api_origin: "https://api.privacy-center.org".to_string(), + }, + ) + .expect("should insert Didomi configuration"); + + let reconstructed = settings_from_config_blob(&envelope_json(&original)) + .expect("should reconstruct settings"); + let config = reconstructed + .integration_config::("didomi") + .expect("should read Didomi configuration") + .expect("should enable Didomi"); + + assert!( + config.geo_query_parameters, + "should preserve Didomi geo opt-in" + ); + } + #[test] fn legacy_blob_without_rewrite_creatives_preserves_rewriting() { let data = diff --git a/crates/trusted-server-core/src/integrations/didomi.rs b/crates/trusted-server-core/src/integrations/didomi.rs index f8472b796..0d1cb7638 100644 --- a/crates/trusted-server-core/src/integrations/didomi.rs +++ b/crates/trusted-server-core/src/integrations/didomi.rs @@ -27,6 +27,9 @@ pub struct DidomiIntegrationConfig { /// Whether the integration is enabled. #[serde(default = "default_enabled")] pub enabled: bool, + /// Add trusted country and region parameters to notice-loader URLs. + #[serde(default)] + pub geo_query_parameters: bool, /// Custom proxy path prefix to avoid ad-blocker detection. /// Defaults to "integrations/didomi/consent" if not set. #[serde(default)] @@ -369,19 +372,58 @@ mod tests { use super::*; use crate::integrations::{IntegrationDocumentState, IntegrationRegistry}; use crate::platform::test_support::{StubHttpClient, build_services_with_http_client}; - use crate::test_support::tests::create_test_settings; + use crate::test_support::tests::{crate_test_settings_str, create_test_settings}; use http::Method; use std::net::{IpAddr, Ipv4Addr}; fn config(enabled: bool) -> DidomiIntegrationConfig { DidomiIntegrationConfig { enabled, + geo_query_parameters: false, proxy_path: None, sdk_origin: default_sdk_origin(), api_origin: default_api_origin(), } } + #[test] + fn geo_query_parameters_defaults_to_disabled() { + let settings = Settings::from_toml(&format!( + "{}\n[integrations.didomi]\nenabled = true\n", + crate_test_settings_str() + )) + .expect("should parse Didomi configuration"); + + let config = settings + .integration_config::(DIDOMI_INTEGRATION_ID) + .expect("should read Didomi configuration") + .expect("should enable Didomi"); + + assert!( + !config.geo_query_parameters, + "should disable geo query parameters when omitted" + ); + } + + #[test] + fn geo_query_parameters_parses_explicit_opt_in() { + let settings = Settings::from_toml(&format!( + "{}\n[integrations.didomi]\nenabled = true\ngeo_query_parameters = true\n", + crate_test_settings_str() + )) + .expect("should parse Didomi geo configuration"); + + let config = settings + .integration_config::(DIDOMI_INTEGRATION_ID) + .expect("should read Didomi configuration") + .expect("should enable Didomi"); + + assert!( + config.geo_query_parameters, + "should retain explicit geo query parameter opt-in" + ); + } + #[test] fn selects_api_backend_for_api_paths() { let integration = DidomiIntegration::new(Arc::new(config(true))); @@ -496,6 +538,7 @@ mod tests { let mut settings = create_test_settings(); let custom_config = DidomiIntegrationConfig { enabled: true, + geo_query_parameters: false, proxy_path: Some("my-custom-consent".to_string()), sdk_origin: default_sdk_origin(), api_origin: default_api_origin(), @@ -557,6 +600,7 @@ mod tests { fn head_injector_emits_proxy_path() { let custom_config = DidomiIntegrationConfig { enabled: true, + geo_query_parameters: false, proxy_path: Some("my-consent".to_string()), sdk_origin: default_sdk_origin(), api_origin: default_api_origin(), From 31aaf0a5ab0ab5c8594b1411a08b21c2f8de6c6a Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Mon, 7 Sep 2026 16:01:50 +0530 Subject: [PATCH 02/10] Canonicalize Didomi loader geo parameters --- .../src/integrations/didomi.rs | 228 +++++++++++++++++- 1 file changed, 227 insertions(+), 1 deletion(-) diff --git a/crates/trusted-server-core/src/integrations/didomi.rs b/crates/trusted-server-core/src/integrations/didomi.rs index 0d1cb7638..ec4f3c8de 100644 --- a/crates/trusted-server-core/src/integrations/didomi.rs +++ b/crates/trusted-server-core/src/integrations/didomi.rs @@ -15,7 +15,7 @@ use crate::integrations::{ IntegrationHtmlContext, IntegrationProxy, IntegrationRegistration, collect_body_bounded, ensure_integration_backend, }; -use crate::platform::{PlatformHttpRequest, RuntimeServices}; +use crate::platform::{GeoInfo, PlatformHttpRequest, RuntimeServices}; use crate::settings::{IntegrationConfig, Settings}; const DIDOMI_INTEGRATION_ID: &str = "didomi"; @@ -103,6 +103,101 @@ enum DidomiBackend { Api, } +#[derive(Debug, Clone, Eq, PartialEq)] +struct DidomiGeo { + country: String, + region: String, +} + +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +enum DidomiGeoError { + MissingCountry, + MissingRegion, + InvalidCountry, + InvalidRegion, +} + +#[derive(Debug, Clone, Eq, PartialEq)] +struct CanonicalLoaderUrl { + browser_target: String, + query: String, +} + +fn is_notice_loader(method: &Method, consent_path: &str) -> bool { + if *method != Method::GET { + return false; + } + + let Some(path) = consent_path.strip_prefix('/') else { + return false; + }; + let mut segments = path.split('/'); + let public_key = segments.next(); + let file_name = segments.next(); + + public_key.is_some_and(|segment| !segment.is_empty()) + && file_name == Some("loader.js") + && segments.next().is_none() +} + +fn trim_ascii(value: &str) -> &str { + value.trim_matches(|character: char| character.is_ascii_whitespace()) +} + +fn normalize_didomi_geo(geo: &GeoInfo) -> Result { + let country = trim_ascii(&geo.country).to_ascii_uppercase(); + if country.is_empty() { + return Err(DidomiGeoError::MissingCountry); + } + if country.len() != 2 + || !country.bytes().all(|byte| byte.is_ascii_alphabetic()) + || matches!(country.as_str(), "XX" | "ZZ") + { + return Err(DidomiGeoError::InvalidCountry); + } + + let Some(region) = geo.region.as_deref() else { + return Err(DidomiGeoError::MissingRegion); + }; + let region = trim_ascii(region).to_ascii_uppercase(); + if region.is_empty() { + return Err(DidomiGeoError::MissingRegion); + } + let region = match region.split_once('-') { + Some((prefix, subdivision)) if prefix == country => subdivision, + Some(_) => return Err(DidomiGeoError::InvalidRegion), + None => region.as_str(), + }; + if !(1..=3).contains(®ion.len()) || !region.bytes().all(|byte| byte.is_ascii_alphanumeric()) + { + return Err(DidomiGeoError::InvalidRegion); + } + + Ok(DidomiGeo { + country, + region: region.to_string(), + }) +} + +fn canonical_loader_url(path: &str, query: Option<&str>, geo: &DidomiGeo) -> CanonicalLoaderUrl { + let mut serializer = url::form_urlencoded::Serializer::new(String::new()); + if let Some(query) = query { + for (name, value) in url::form_urlencoded::parse(query.as_bytes()) { + if !name.eq_ignore_ascii_case("country") && !name.eq_ignore_ascii_case("region") { + serializer.append_pair(&name, &value); + } + } + } + serializer.append_pair("country", &geo.country); + serializer.append_pair("region", &geo.region); + let query = serializer.finish(); + + CanonicalLoaderUrl { + browser_target: format!("{path}?{query}"), + query, + } +} + struct DidomiIntegration { config: Arc, } @@ -371,6 +466,7 @@ mod tests { use super::*; use crate::integrations::{IntegrationDocumentState, IntegrationRegistry}; + use crate::platform::GeoInfo; use crate::platform::test_support::{StubHttpClient, build_services_with_http_client}; use crate::test_support::tests::{crate_test_settings_str, create_test_settings}; use http::Method; @@ -386,6 +482,19 @@ mod tests { } } + fn geo_info(country: &str, region: Option<&str>) -> GeoInfo { + GeoInfo { + city: String::new(), + country: country.to_string(), + continent: String::new(), + latitude: 0.0, + longitude: 0.0, + metro_code: 0, + region: region.map(str::to_string), + asn: None, + } + } + #[test] fn geo_query_parameters_defaults_to_disabled() { let settings = Settings::from_toml(&format!( @@ -424,6 +533,123 @@ mod tests { ); } + #[test] + fn matches_only_exact_get_notice_loader_paths() { + assert!(is_notice_loader(&Method::GET, "/public-key/loader.js")); + + for (method, path) in [ + (Method::POST, "/public-key/loader.js"), + (Method::GET, "/loader.js"), + (Method::GET, "/api/public-key/loader.js"), + (Method::GET, "/public-key/loader.js/"), + (Method::GET, "/public-key/loader.js.map"), + (Method::GET, "/nested/public-key/loader.js"), + (Method::GET, "/public-key/other.js"), + ] { + assert!( + !is_notice_loader(&method, path), + "should reject {method} {path}" + ); + } + } + + #[test] + fn normalizes_country_and_region() { + let geo = geo_info(" us \n", Some(" ca\t")); + + assert_eq!( + normalize_didomi_geo(&geo).expect("should normalize geo"), + DidomiGeo { + country: "US".to_string(), + region: "CA".to_string(), + }, + "should trim ASCII whitespace and uppercase both values" + ); + } + + #[test] + fn normalizes_matching_country_prefixed_region() { + let geo = geo_info("us", Some("us-ca")); + + assert_eq!( + normalize_didomi_geo(&geo).expect("should normalize prefixed region"), + DidomiGeo { + country: "US".to_string(), + region: "CA".to_string(), + }, + "should remove a matching country prefix" + ); + } + + #[test] + fn rejects_incomplete_or_invalid_geo() { + for (country, region) in [ + ("", None), + ("US", None), + ("XX", Some("CA")), + ("ZZ", Some("CA")), + ("U1", Some("CA")), + ("USA", Some("CA")), + ("ÜS", Some("CA")), + ("US", Some("")), + ("US", Some("C-A")), + ("US", Some("CAL1")), + ("US", Some("CA!")), + ("US", Some("GB-LND")), + ] { + let geo = geo_info(country, region); + + assert!( + normalize_didomi_geo(&geo).is_err(), + "should reject country {country:?} and region {region:?}" + ); + } + } + + #[test] + fn canonicalizes_loader_query_with_authoritative_geo() { + let geo = DidomiGeo { + country: "US".to_string(), + region: "CA".to_string(), + }; + let canonical = canonical_loader_url( + "/integrations/didomi/consent/key/loader.js", + Some("target_type=notice&x=1&Country=gb&%72egion=lnd&x=2&empty=&space=a+b&plus=%2B"), + &geo, + ); + + assert_eq!( + canonical.query, + "target_type=notice&x=1&x=2&empty=&space=a+b&plus=%2B&country=US®ion=CA", + "should preserve unrelated decoded pairs and replace all geo pairs" + ); + assert_eq!( + canonical.browser_target, + "/integrations/didomi/consent/key/loader.js?target_type=notice&x=1&x=2&empty=&space=a+b&plus=%2B&country=US®ion=CA", + "should build a relative same-origin target" + ); + } + + #[test] + fn canonical_loader_query_is_idempotent() { + let geo = DidomiGeo { + country: "US".to_string(), + region: "CA".to_string(), + }; + let first = canonical_loader_url( + "/integrations/didomi/consent/key/loader.js", + Some("quote=%27&country=US®ion=CA"), + &geo, + ); + let second = canonical_loader_url( + "/integrations/didomi/consent/key/loader.js", + Some(&first.query), + &geo, + ); + + assert_eq!(second, first, "should remain stable after canonicalization"); + } + #[test] fn selects_api_backend_for_api_paths() { let integration = DidomiIntegration::new(Arc::new(config(true))); From c82c270929ed52c87a5ee2ad583c1758af5b0494 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Mon, 7 Sep 2026 16:06:48 +0530 Subject: [PATCH 03/10] Forward trusted geo to Didomi loaders --- .../src/integrations/didomi.rs | 329 +++++++++++++++++- 1 file changed, 324 insertions(+), 5 deletions(-) diff --git a/crates/trusted-server-core/src/integrations/didomi.rs b/crates/trusted-server-core/src/integrations/didomi.rs index ec4f3c8de..cddf2cb45 100644 --- a/crates/trusted-server-core/src/integrations/didomi.rs +++ b/crates/trusted-server-core/src/integrations/didomi.rs @@ -117,6 +117,17 @@ enum DidomiGeoError { InvalidRegion, } +impl DidomiGeoError { + const fn reason(self) -> &'static str { + match self { + Self::MissingCountry => "missing_country", + Self::MissingRegion => "missing_region", + Self::InvalidCountry => "invalid_country", + Self::InvalidRegion => "invalid_region", + } + } +} + #[derive(Debug, Clone, Eq, PartialEq)] struct CanonicalLoaderUrl { browser_target: String, @@ -254,6 +265,7 @@ impl DidomiIntegration { client_ip: Option, original_headers: &HeaderMap, proxy_headers: &mut HeaderMap, + authoritative_geo: Option<&DidomiGeo>, ) { if let Some(ip) = client_ip { proxy_headers.insert( @@ -282,7 +294,24 @@ impl DidomiIntegration { } if matches!(backend, DidomiBackend::Sdk) { - Self::copy_geo_headers(original_headers, proxy_headers); + if let Some(geo) = authoritative_geo { + Self::set_geo_headers(geo, proxy_headers); + } else { + Self::copy_geo_headers(original_headers, proxy_headers); + } + } + } + + fn set_geo_headers(geo: &DidomiGeo, proxy_headers: &mut HeaderMap) { + for (name, value) in [ + ("X-Geo-Country", geo.country.as_str()), + ("X-Geo-Region", geo.region.as_str()), + ("CloudFront-Viewer-Country", geo.country.as_str()), + ] { + proxy_headers.insert( + name, + HeaderValue::from_str(value).expect("should format validated Didomi geo header"), + ); } } @@ -324,6 +353,29 @@ impl DidomiIntegration { ) -> Result> { ensure_integration_backend(services, origin, DIDOMI_INTEGRATION_ID, None) } + + fn geo_failure_response(reason: &str) -> http::Response { + log::warn!("Didomi loader geo unavailable: reason={reason}"); + let mut response = http::Response::builder() + .status(http::StatusCode::SERVICE_UNAVAILABLE) + .header(header::CONTENT_TYPE, "text/plain; charset=utf-8") + .body(EdgeBody::from("Didomi loader unavailable")) + .expect("should build static Didomi geo failure response"); + crate::response_privacy::enforce_terminal_private_cache_privacy(&mut response); + response + } + + fn redirect_response( + location: &str, + ) -> Result, Report> { + let mut response = http::Response::builder() + .status(http::StatusCode::TEMPORARY_REDIRECT) + .header(header::LOCATION, location) + .body(EdgeBody::empty()) + .change_context(Self::error("Failed to build Didomi geo redirect"))?; + crate::response_privacy::enforce_terminal_private_cache_privacy(&mut response); + Ok(response) + } } fn build( @@ -384,13 +436,41 @@ impl IntegrationProxy for DidomiIntegration { let prefix = self.resolved_prefix(); let consent_path = path.strip_prefix(&prefix).unwrap_or(&path); let backend = self.backend_for_path(consent_path); + let canonical_loader = if self.config.geo_query_parameters + && matches!(backend, DidomiBackend::Sdk) + && is_notice_loader(&parts.method, consent_path) + { + let geo = match services.geo().lookup(services.client_info().client_ip) { + Ok(Some(geo)) => match normalize_didomi_geo(&geo) { + Ok(geo) => geo, + Err(error) => return Ok(Self::geo_failure_response(error.reason())), + }, + Ok(None) => return Ok(Self::geo_failure_response("geo_unavailable")), + Err(_) => return Ok(Self::geo_failure_response("lookup_failed")), + }; + let canonical = canonical_loader_url(&path, parts.uri.query(), &geo); + let incoming_path_and_query = parts + .uri + .path_and_query() + .map_or(path.as_str(), http::uri::PathAndQuery::as_str); + if incoming_path_and_query != canonical.browser_target { + return Self::redirect_response(&canonical.browser_target); + } + Some((geo, canonical)) + } else { + None + }; let base_origin = match backend { DidomiBackend::Sdk => self.config.sdk_origin.as_str(), DidomiBackend::Api => self.config.api_origin.as_str(), }; + let query = canonical_loader.as_ref().map_or_else( + || parts.uri.query(), + |(_, canonical)| Some(&*canonical.query), + ); let target_url = self - .build_target_url(base_origin, consent_path, parts.uri.query()) + .build_target_url(base_origin, consent_path, query) .change_context(Self::error("Failed to build Didomi target URL"))?; let backend_name = Self::backend_name_for_origin(services, base_origin) .change_context(Self::error("Failed to configure Didomi backend"))?; @@ -414,6 +494,7 @@ impl IntegrationProxy for DidomiIntegration { services.client_info().client_ip, &parts.headers, proxy_req.headers_mut(), + canonical_loader.as_ref().map(|(geo, _)| geo), ); let mut response = services @@ -462,15 +543,37 @@ impl IntegrationHeadInjector for DidomiIntegration { #[cfg(test)] mod tests { + use std::net::{IpAddr, Ipv4Addr}; use std::sync::Arc; use super::*; use crate::integrations::{IntegrationDocumentState, IntegrationRegistry}; - use crate::platform::GeoInfo; - use crate::platform::test_support::{StubHttpClient, build_services_with_http_client}; + use crate::platform::test_support::{ + NoopConfigStore, NoopSecretStore, StubBackend, StubHttpClient, + build_services_with_http_client, + }; + use crate::platform::{ClientInfo, GeoInfo, PlatformError, PlatformGeo}; use crate::test_support::tests::{crate_test_settings_str, create_test_settings}; use http::Method; - use std::net::{IpAddr, Ipv4Addr}; + + enum GeoResult { + Value(Option), + Failure, + } + + struct StubGeo(GeoResult); + + impl PlatformGeo for StubGeo { + fn lookup( + &self, + _client_ip: Option, + ) -> Result, Report> { + match &self.0 { + GeoResult::Value(geo) => Ok(geo.clone()), + GeoResult::Failure => Err(Report::new(PlatformError::Geo)), + } + } + } fn config(enabled: bool) -> DidomiIntegrationConfig { DidomiIntegrationConfig { @@ -495,6 +598,31 @@ mod tests { } } + fn config_with_geo_query_parameters() -> DidomiIntegrationConfig { + DidomiIntegrationConfig { + geo_query_parameters: true, + ..config(true) + } + } + + fn services_with_geo( + http_client: Arc, + geo_result: GeoResult, + ) -> 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(StubBackend)) + .http_client(http_client) + .geo(Arc::new(StubGeo(geo_result))) + .client_info(ClientInfo { + client_ip: Some(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 7))), + ..ClientInfo::default() + }) + .build() + } + #[test] fn geo_query_parameters_defaults_to_disabled() { let settings = Settings::from_toml(&format!( @@ -650,6 +778,194 @@ mod tests { assert_eq!(second, first, "should remain stable after canonicalization"); } + #[test] + fn enabled_geo_redirects_noncanonical_loader_without_upstream_call() { + let stub = Arc::new(StubHttpClient::new()); + let services = services_with_geo( + Arc::clone(&stub), + GeoResult::Value(Some(geo_info("us", Some("ca")))), + ); + let settings = create_test_settings(); + let integration = DidomiIntegration::new(Arc::new(config_with_geo_query_parameters())); + let request = http::Request::builder() + .method(Method::GET) + .uri("https://publisher.example/integrations/didomi/consent/key/loader.js?target_type=notice&Country=GB®ion=LND") + .body(EdgeBody::empty()) + .expect("should build request"); + + let response = + futures::executor::block_on(integration.handle(&settings, &services, request)) + .expect("should return redirect"); + + assert_eq!( + response.status(), + http::StatusCode::TEMPORARY_REDIRECT, + "should redirect to the canonical loader URL" + ); + assert_eq!( + response + .headers() + .get(header::LOCATION) + .and_then(|v| v.to_str().ok()), + Some( + "/integrations/didomi/consent/key/loader.js?target_type=notice&country=US®ion=CA" + ), + "should use a relative target with authoritative geo" + ); + assert_eq!( + response + .headers() + .get(header::CACHE_CONTROL) + .and_then(|v| v.to_str().ok()), + Some("no-store, private"), + "should make the redirect private and non-storable" + ); + assert!( + stub.recorded_backend_names().is_empty(), + "should not contact Didomi before canonical redirect" + ); + } + + #[test] + fn enabled_geo_proxies_canonical_loader_with_authoritative_headers() { + let stub = Arc::new(StubHttpClient::new()); + stub.push_response(200, b"loader".to_vec()); + let services = services_with_geo( + Arc::clone(&stub), + GeoResult::Value(Some(geo_info("us", Some("us-ca")))), + ); + let settings = create_test_settings(); + let integration = DidomiIntegration::new(Arc::new(config_with_geo_query_parameters())); + let request = http::Request::builder() + .method(Method::GET) + .uri("https://publisher.example/integrations/didomi/consent/key/loader.js?target_type=notice&country=US®ion=CA") + .header("FastlyGeo-CountryCode", "GB") + .header("FastlyGeo-Region", "LND") + .body(EdgeBody::empty()) + .expect("should build request"); + + let response = + futures::executor::block_on(integration.handle(&settings, &services, request)) + .expect("should proxy canonical loader"); + + assert_eq!(response.status(), http::StatusCode::OK); + assert_eq!( + stub.recorded_request_uris(), + vec![ + "https://sdk.privacy-center.org/key/loader.js?target_type=notice&country=US®ion=CA" + ], + "should send the canonical query to Didomi" + ); + let headers = stub.recorded_request_headers(); + for (name, expected) in [ + ("x-geo-country", "US"), + ("x-geo-region", "CA"), + ("cloudfront-viewer-country", "US"), + ] { + assert!( + headers[0] + .iter() + .any(|(actual_name, value)| actual_name == name && value == expected), + "should set {name} from authoritative geo" + ); + } + } + + #[test] + fn disabled_geo_preserves_existing_loader_behavior() { + let stub = Arc::new(StubHttpClient::new()); + stub.push_response(200, b"loader".to_vec()); + let services = services_with_geo( + Arc::clone(&stub), + GeoResult::Value(Some(geo_info("US", Some("CA")))), + ); + let settings = create_test_settings(); + let integration = DidomiIntegration::new(Arc::new(config(true))); + let request = http::Request::builder() + .method(Method::GET) + .uri("https://publisher.example/integrations/didomi/consent/key/loader.js?target_type=notice") + .body(EdgeBody::empty()) + .expect("should build request"); + + let response = + futures::executor::block_on(integration.handle(&settings, &services, request)) + .expect("should proxy loader"); + + assert_eq!(response.status(), http::StatusCode::OK); + assert_eq!( + stub.recorded_request_uris(), + vec!["https://sdk.privacy-center.org/key/loader.js?target_type=notice"], + "should not add geo when the option is disabled" + ); + } + + #[test] + fn enabled_geo_leaves_unrelated_sdk_assets_unchanged() { + let stub = Arc::new(StubHttpClient::new()); + stub.push_response(200, b"sdk".to_vec()); + let services = services_with_geo(Arc::clone(&stub), GeoResult::Failure); + let settings = create_test_settings(); + let integration = DidomiIntegration::new(Arc::new(config_with_geo_query_parameters())); + let request = http::Request::builder() + .method(Method::GET) + .uri("https://publisher.example/integrations/didomi/consent/sdk/v1/core.js?v=1") + .body(EdgeBody::empty()) + .expect("should build request"); + + let response = + futures::executor::block_on(integration.handle(&settings, &services, request)) + .expect("should proxy unrelated SDK asset"); + + assert_eq!(response.status(), http::StatusCode::OK); + assert_eq!( + stub.recorded_request_uris(), + vec!["https://sdk.privacy-center.org/sdk/v1/core.js?v=1"], + "should not apply loader geo behavior to other SDK assets" + ); + } + + #[test] + fn enabled_geo_fails_closed_without_complete_geo() { + for geo_result in [ + GeoResult::Value(None), + GeoResult::Value(Some(geo_info("US", None))), + GeoResult::Value(Some(geo_info("XX", Some("CA")))), + GeoResult::Failure, + ] { + let stub = Arc::new(StubHttpClient::new()); + let services = services_with_geo(Arc::clone(&stub), geo_result); + let settings = create_test_settings(); + let integration = DidomiIntegration::new(Arc::new(config_with_geo_query_parameters())); + let request = http::Request::builder() + .method(Method::GET) + .uri("https://publisher.example/integrations/didomi/consent/key/loader.js") + .body(EdgeBody::empty()) + .expect("should build request"); + + let response = + futures::executor::block_on(integration.handle(&settings, &services, request)) + .expect("should return controlled geo failure"); + + assert_eq!( + response.status(), + http::StatusCode::SERVICE_UNAVAILABLE, + "should fail closed without complete trusted geo" + ); + assert_eq!( + response + .headers() + .get(header::CACHE_CONTROL) + .and_then(|v| v.to_str().ok()), + Some("no-store, private"), + "should make geo failures private and non-storable" + ); + assert!( + stub.recorded_backend_names().is_empty(), + "should not contact Didomi on geo failure" + ); + } + } + #[test] fn selects_api_backend_for_api_paths() { let integration = DidomiIntegration::new(Arc::new(config(true))); @@ -707,6 +1023,7 @@ mod tests { client_ip, original_req.headers(), proxy_req.headers_mut(), + None, ); assert_eq!( @@ -743,6 +1060,7 @@ mod tests { None, original_req.headers(), proxy_req.headers_mut(), + None, ); assert!( @@ -867,6 +1185,7 @@ mod tests { None, original_req.headers(), proxy_req.headers_mut(), + None, ); assert!( From 27f99ef766e5c8d4849ca57e9301b5736190a41d Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Mon, 7 Sep 2026 16:09:43 +0530 Subject: [PATCH 04/10] Enforce Didomi API cache privacy --- .../src/integrations/didomi.rs | 122 +++++++++++++++++- 1 file changed, 121 insertions(+), 1 deletion(-) diff --git a/crates/trusted-server-core/src/integrations/didomi.rs b/crates/trusted-server-core/src/integrations/didomi.rs index cddf2cb45..c6f47798e 100644 --- a/crates/trusted-server-core/src/integrations/didomi.rs +++ b/crates/trusted-server-core/src/integrations/didomi.rs @@ -497,14 +497,22 @@ impl IntegrationProxy for DidomiIntegration { canonical_loader.as_ref().map(|(geo, _)| geo), ); + let platform_request = PlatformHttpRequest::new(proxy_req, backend_name); + let platform_request = if matches!(backend, DidomiBackend::Api) { + platform_request.with_cache_bypass() + } else { + platform_request + }; let mut response = services .http_client() - .send(PlatformHttpRequest::new(proxy_req, backend_name)) + .send(platform_request) .await .change_context(Self::error("Didomi upstream request failed"))?; if matches!(backend, DidomiBackend::Sdk) { Self::add_cors_headers(&mut response.response); + } else { + crate::response_privacy::enforce_terminal_private_cache_privacy(&mut response.response); } Ok(response.response) @@ -966,6 +974,118 @@ mod tests { } } + #[test] + fn api_requests_bypass_cache_and_strip_response_cache_metadata() { + let stub = Arc::new(StubHttpClient::new()); + stub.push_response_with_headers( + 200, + b"event".to_vec(), + vec![ + ("Cache-Control", "public, max-age=3600"), + ("Expires", "Wed, 21 Oct 2037 07:28:00 GMT"), + ("ETag", "\"api-response\""), + ("Last-Modified", "Wed, 21 Oct 2015 07:28:00 GMT"), + ("Age", "120"), + ("Surrogate-Control", "max-age=3600"), + ("CDN-Cache-Control", "max-age=3600"), + ], + ); + let services = services_with_geo(Arc::clone(&stub), GeoResult::Value(None)); + let settings = create_test_settings(); + let integration = DidomiIntegration::new(Arc::new(config(true))); + let request = http::Request::builder() + .method(Method::GET) + .uri("https://publisher.example/integrations/didomi/consent/api/events?x=1") + .body(EdgeBody::empty()) + .expect("should build request"); + + let response = + futures::executor::block_on(integration.handle(&settings, &services, request)) + .expect("should proxy API request"); + + assert_eq!( + stub.recorded_cache_bypass_flags(), + vec![true], + "should bypass the platform cache for API requests" + ); + assert_eq!( + response + .headers() + .get(header::CACHE_CONTROL) + .and_then(|value| value.to_str().ok()), + Some("no-store, private"), + "should make API responses private and non-storable" + ); + for name in [ + header::EXPIRES.as_str(), + header::ETAG.as_str(), + header::LAST_MODIFIED.as_str(), + header::AGE.as_str(), + "surrogate-control", + "cdn-cache-control", + ] { + assert!( + response.headers().get(name).is_none(), + "should remove API cache metadata {name}" + ); + } + } + + #[test] + fn sdk_responses_preserve_origin_cache_metadata() { + let stub = Arc::new(StubHttpClient::new()); + stub.push_response_with_headers( + 200, + b"sdk".to_vec(), + vec![ + ("Cache-Control", "public, max-age=3600"), + ("Expires", "Wed, 21 Oct 2037 07:28:00 GMT"), + ("ETag", "\"sdk-response\""), + ("Last-Modified", "Wed, 21 Oct 2015 07:28:00 GMT"), + ("Age", "120"), + ("Surrogate-Control", "max-age=3600"), + ], + ); + let services = services_with_geo(Arc::clone(&stub), GeoResult::Value(None)); + let settings = create_test_settings(); + let integration = DidomiIntegration::new(Arc::new(config(true))); + let request = http::Request::builder() + .method(Method::GET) + .uri("https://publisher.example/integrations/didomi/consent/sdk/v1/core.js") + .body(EdgeBody::empty()) + .expect("should build request"); + + let response = + futures::executor::block_on(integration.handle(&settings, &services, request)) + .expect("should proxy SDK request"); + + assert_eq!( + stub.recorded_cache_bypass_flags(), + vec![false], + "should retain normal platform caching for SDK requests" + ); + for (name, expected) in [ + (header::CACHE_CONTROL.as_str(), "public, max-age=3600"), + (header::EXPIRES.as_str(), "Wed, 21 Oct 2037 07:28:00 GMT"), + (header::ETAG.as_str(), "\"sdk-response\""), + ( + header::LAST_MODIFIED.as_str(), + "Wed, 21 Oct 2015 07:28:00 GMT", + ), + (header::AGE.as_str(), "120"), + ("surrogate-control", "max-age=3600"), + ] { + assert_eq!( + response + .headers() + .get(name) + .and_then(|value| value.to_str().ok()), + Some(expected), + "should preserve SDK cache metadata {name}" + ); + } + } + #[test] fn selects_api_backend_for_api_paths() { let integration = DidomiIntegration::new(Arc::new(config(true))); From cbe3d93df089fd13e3c332d4b2eb9da9d8b0326f Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Mon, 7 Sep 2026 16:11:31 +0530 Subject: [PATCH 05/10] Test Didomi query conversion on Fastly --- .../src/platform.rs | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/crates/trusted-server-adapter-fastly/src/platform.rs b/crates/trusted-server-adapter-fastly/src/platform.rs index 2b6189d2a..8828a19d1 100644 --- a/crates/trusted-server-adapter-fastly/src/platform.rs +++ b/crates/trusted-server-adapter-fastly/src/platform.rs @@ -1024,6 +1024,44 @@ mod tests { ); } + #[test] + fn edge_request_to_fastly_preserves_canonical_didomi_query() { + let expected_query = "space=a+b&plus=%2B"e=%27&empty=&x=1&x=2&country=US®ion=CA"; + let request = request_builder() + .method("GET") + .uri(format!( + "https://sdk.privacy-center.org/key/loader.js?{expected_query}" + )) + .body(Body::empty()) + .expect("should build canonical Didomi request"); + + let fastly_req = edge_request_to_fastly(request).expect("should convert request"); + + assert_eq!( + fastly_req.get_url().query(), + Some(expected_query), + "should preserve the canonical Didomi query across Fastly conversion" + ); + assert_eq!( + fastly_req + .get_url() + .query_pairs() + .filter(|(name, _)| name.eq_ignore_ascii_case("country")) + .count(), + 1, + "should retain exactly one country pair" + ); + assert_eq!( + fastly_req + .get_url() + .query_pairs() + .filter(|(name, _)| name.eq_ignore_ascii_case("region")) + .count(), + 1, + "should retain exactly one region pair" + ); + } + // --- FastlyPlatformBackend::predict_name -------------------------------- #[test] From e30f289f2b5c89d57674ad87fed584dda073cfd5 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Mon, 7 Sep 2026 16:16:49 +0530 Subject: [PATCH 06/10] Document Didomi geo forwarding --- .../configs/trusted-server.integration.toml | 1 + docs/guide/integrations/didomi.md | 463 ++++++------------ .../plans/2026-09-07-didomi-geo-forwarding.md | 348 +++++++++++++ .../specs/2026-09-07-didomi-geo-design.md | 344 +++++++++++++ trusted-server.example.toml | 1 + 5 files changed, 834 insertions(+), 323 deletions(-) create mode 100644 docs/superpowers/plans/2026-09-07-didomi-geo-forwarding.md create mode 100644 docs/superpowers/specs/2026-09-07-didomi-geo-design.md diff --git a/crates/trusted-server-integration-tests/fixtures/configs/trusted-server.integration.toml b/crates/trusted-server-integration-tests/fixtures/configs/trusted-server.integration.toml index d8e35d179..e9bbbf5f3 100644 --- a/crates/trusted-server-integration-tests/fixtures/configs/trusted-server.integration.toml +++ b/crates/trusted-server-integration-tests/fixtures/configs/trusted-server.integration.toml @@ -52,6 +52,7 @@ rewrite_scripts = true [integrations.didomi] enabled = false +geo_query_parameters = false sdk_origin = "https://sdk.example.com" api_origin = "https://api.example.com" diff --git a/docs/guide/integrations/didomi.md b/docs/guide/integrations/didomi.md index 8b27ec924..a3c2143bc 100644 --- a/docs/guide/integrations/didomi.md +++ b/docs/guide/integrations/didomi.md @@ -1,396 +1,213 @@ # Didomi Integration -**Category**: CMP (Consent Management Platform) -**Status**: Production -**Type**: Reverse Proxy for Consent Management - -## Overview - -The Didomi integration enables first-party serving of Didomi's consent management platform (CMP) through Trusted Server. By proxying Didomi's SDK and API through your domain, you maintain first-party context for Didomi's GDPR/TCF 2.2 consent flows. +**Category**: Consent Management Platform -## What is Didomi? - -Didomi is a Consent Management Platform that manages user consent for data collection and processing under GDPR, CCPA, and other regulations. - -**Key Capabilities**: +**Status**: Production -- TCF 2.2 (Transparency & Consent Framework) compliance -- Custom consent notices and preferences -- Vendor management -- Consent analytics and reporting -- Multi-regulation support (GDPR, CCPA, LGPD) +**Type**: First-party SDK and API reverse proxy -## How It Works +## Overview -``` -┌──────────────────────────────────────────────────┐ -│ Browser Request │ -│ GET /integrations/didomi/consent/loader.js │ -│ ↓ │ -│ Trusted Server (First-Party Domain) │ -│ ↓ │ -│ Proxy to Didomi SDK Origin │ -│ https://sdk.privacy-center.org/loader.js │ -│ ↓ │ -│ Return SDK (appears first-party to browser) │ -└──────────────────────────────────────────────────┘ -``` +The Didomi integration serves Didomi SDK assets and API calls through the +publisher's Trusted Server domain. It proxies SDK requests to +`sdk.privacy-center.org`, routes `/api/*` requests to `api.privacy-center.org`, +and injects the configured first-party SDK path into the browser integration. -**Benefits**: +Didomi generates notice loaders according to the visitor's country and region. +On Fastly, Trusted Server can put its trusted platform geo in the loader URL so +the browser, Fastly cache, and Didomi origin all identify the same geographic +variant. -- Didomi SDK loads from your domain (not `privacy-center.org`) -- First-party cookies for consent storage -- Improved tracking prevention compatibility -- Better page load performance +See [Didomi's reverse-proxy requirements](https://developers.didomi.io/api-and-platform/domains/reverse-proxy) +for the upstream contract. ## Configuration -Add Didomi configuration to `trusted-server.toml`: +Add the integration to the operator-owned `trusted-server.toml`: ```toml [integrations.didomi] enabled = true -sdk_origin = "https://sdk.privacy-center.org" -api_origin = "https://api.privacy-center.org" +geo_query_parameters = true +# proxy_path = "my-custom-consent" +# sdk_origin = "https://sdk.privacy-center.org" +# api_origin = "https://api.privacy-center.org" ``` -### Configuration Options - -| Field | Type | Required | Default | Description | -| ------------ | ------- | -------- | -------------------------------- | ---------------------------------------- | -| `enabled` | boolean | No | `false` | Enable/disable integration | -| `proxy_path` | string | No | `integrations/didomi/consent` | Custom proxy URL path prefix (see below) | -| `sdk_origin` | string | Yes | `https://sdk.privacy-center.org` | Didomi SDK backend URL | -| `api_origin` | string | Yes | `https://api.privacy-center.org` | Didomi API backend URL | - -### Environment Variables +Publish application configuration with: ```bash -TRUSTED_SERVER__INTEGRATIONS__DIDOMI__ENABLED=true -TRUSTED_SERVER__INTEGRATIONS__DIDOMI__PROXY_PATH=my-custom-consent -TRUSTED_SERVER__INTEGRATIONS__DIDOMI__SDK_ORIGIN=https://sdk.privacy-center.org -TRUSTED_SERVER__INTEGRATIONS__DIDOMI__API_ORIGIN=https://api.privacy-center.org +ts config push --adapter fastly ``` -### Custom Proxy Path +| Field | Type | Required | Default | Description | +| ---------------------- | ------- | -------- | ------------------------------------- | ------------------------------------------------------- | +| `enabled` | boolean | No | `true` in a present integration block | Enables the integration | +| `geo_query_parameters` | boolean | No | `false` | Enables trusted geo canonicalization for notice loaders | +| `proxy_path` | string | No | `integrations/didomi/consent` | Changes the first-party path prefix | +| `sdk_origin` | string | No | `https://sdk.privacy-center.org` | Changes the SDK origin, primarily for testing | +| `api_origin` | string | No | `https://api.privacy-center.org` | Changes the API origin, primarily for testing | + +`geo_query_parameters` is disabled by default for compatibility. It currently +supports Fastly only because Cloudflare does not expose a trusted region through +the pinned EdgeZero adapter, while Axum and Spin do not provide platform geo. -By default, Didomi requests are served at `/integrations/didomi/consent/*`. Since this path is predictable, ad blockers may add it to their block lists. Use `proxy_path` to set a customer-specific path that is harder to target: +The normal configuration source is TOML. `TRUSTED_SERVER__...` variables are +optional overlays applied by `ts config validate` and `ts config push`; they are +not read by a running deployment. An overlay can replace only a scalar leaf that +already exists in the TOML input. + +### Custom proxy path + +`proxy_path` helps avoid a predictable integration path: ```toml [integrations.didomi] enabled = true +geo_query_parameters = true proxy_path = "my-custom-consent" ``` -With this configuration, requests are served at `/my-custom-consent/*` instead of the default. - -**Format rules:** - -- Must not be empty or just `/` -- Must not end with a trailing slash -- May contain only ASCII letters, numbers, `-`, `_`, `.`, `~`, and `/` path separators -- Must not contain dot-only path segments (`.` or `..`) -- Must not contain percent escapes or consecutive slashes (`//`) -- Leading slash is optional (it is normalized internally) - -**Examples of valid values:** - -- `"consent-proxy"` → serves at `/consent-proxy/*` -- `"privacy/manage"` → serves at `/privacy/manage/*` -- `"/my-cmp-path"` → serves at `/my-cmp-path/*` +This serves Didomi at `/my-custom-consent/*`. The path: -The custom path is automatically passed to the client-side JavaScript bundle via `window.__tsjs_didomi.proxyPath`, so the Didomi SDK URL rewriting continues to work without additional frontend configuration. +- must not be empty, root-only, or end in `/`; +- may contain ASCII letters, numbers, `-`, `_`, `.`, `~`, and `/` separators; +- must not contain `//`, percent escapes, or `.` and `..` path segments; and +- may start with `/`; Trusted Server normalizes the leading slash. -## Endpoints +Trusted Server passes the resolved path to its browser bundle through +`window.__tsjs_didomi.proxyPath`. -### SDK Proxy +## Notice-loader geo flow -**Pattern**: `/integrations/didomi/consent/*` (except `/api/*`) +Geo handling applies only when `geo_query_parameters = true` and the request is +a `GET` whose path after the proxy prefix is exactly +`//loader.js`. -Proxies Didomi SDK resources through first-party domain. +For example, a California request to: -**Example**: - -``` -Original: https://sdk.privacy-center.org/24cd1234/loader.js -Proxied: https://your-domain.com/integrations/didomi/consent/24cd1234/loader.js +```text +/integrations/didomi/consent/example-key/loader.js?target_type=notice&target=example-notice ``` -**Headers Forwarded**: - -- `User-Agent` -- `Accept` -- `Accept-Language` -- `Accept-Encoding` -- `Referer` -- `Origin` -- `Authorization` - -**Geo Headers** (SDK only): - -- `X-Geo-Country` ← `FastlyGeo-CountryCode` -- `X-Geo-Region` ← `FastlyGeo-Region` -- `CloudFront-Viewer-Country` ← `FastlyGeo-CountryCode` - -**CORS Headers** (added to SDK responses): +receives a `307 Temporary Redirect` to: -```http -Access-Control-Allow-Origin: * -Access-Control-Allow-Headers: Content-Type, Authorization, X-Requested-With -Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS -``` - -### API Proxy - -**Pattern**: `/integrations/didomi/consent/api/*` - -Proxies Didomi API requests (consent events, user preferences, etc.). - -**Example**: - -``` -Original: https://api.privacy-center.org/v1/events -Proxied: https://your-domain.com/integrations/didomi/consent/api/v1/events +```text +/integrations/didomi/consent/example-key/loader.js?target_type=notice&target=example-notice&country=US®ion=CA ``` -**Methods**: GET, POST, PUT, DELETE, OPTIONS - -**Note**: API requests do NOT receive CORS headers (handled by Didomi API). +The redirect uses a relative same-origin `Location` and is private and +non-storable. Trusted Server does not contact Didomi for that request. When the +browser follows the canonical URL, Trusted Server proxies the same path and query +to: -## Integration with Trusted Server - -### Consent Validation - -Consent signals, such as the TCF v2 format string recorded by the Didomi CMP, are checked before: - -- Generating EC IDs -- Syncing with identity partners (Lockr) -- Activating measurement pixels -- Sharing data with third parties - -```rust -// Example consent check -if !tcf_consent.has_purpose_consent(1) { - return skip_collection(); -} +```text +https://sdk.privacy-center.org/example-key/loader.js?target_type=notice&target=example-notice&country=US®ion=CA ``` -### TCF 2.2 Support - -Didomi integration supports IAB's Transparency & Consent Framework 2.2: - -- TCF consent strings (TC strings) -- Vendor consent validation -- Purpose consent enforcement -- Special feature consent +The final browser URL includes geo, which prevents a loader cached in one +location from being reused under the same geo-less browser URL after the visitor +moves or changes network location. -## Use Cases +### Trust and precedence -### 1. First-Party Consent Management +Trusted Server obtains geo from `RuntimeServices.geo()` using the trusted client +IP. Browser query parameters and request headers are not geo authorities. -**Problem**: Third-party consent scripts blocked by tracking prevention. +For eligible loader URLs, Trusted Server: -**Solution**: Serve Didomi SDK from your domain via Trusted Server proxy. +- trims ASCII whitespace and uppercases platform country and region; +- accepts a two-letter ASCII country other than `XX` or `ZZ`; +- accepts a one-to-three-character ASCII alphanumeric subdivision; +- converts a matching country-prefixed value such as `US-CA` to `CA`; +- removes every case-insensitive, URL-decoded `country` and `region` query pair; + and +- appends one authoritative `country` followed by one `region`. -**Benefit**: Consent notice loads reliably and consent collection continues. +The canonical upstream request also sets `X-Geo-Country`, `X-Geo-Region`, and +`CloudFront-Viewer-Country` from that same normalized pair. Conflicting caller +query parameters and Fastly-style geo headers cannot override it. -### 2. Regional Consent Enforcement +When geo lookup fails or returns missing or invalid country/region data, the +eligible loader returns a private, non-storable `503 Service Unavailable` without +contacting Didomi. This includes locations for which Fastly supplies no region. +Publishers should verify geo coverage before enabling the option globally. -**Problem**: Different consent requirements per region (GDPR, CCPA, LGPD). +Other SDK assets and all behavior with `geo_query_parameters = false` retain the +existing proxy flow. -**Solution**: Didomi provides region-specific consent flows, Trusted Server forwards geo data. +## Endpoints and caching -**Benefit**: Region-appropriate consent flows driven by forwarded geo data. +### SDK -### 3. Consent-Based Data Activation +All paths under the proxy prefix other than `/api/*` use the SDK origin. Trusted +Server forwards the incoming SDK path and query, except for the authoritative +notice-loader canonicalization described above. -**Problem**: Need to enforce consent before activating analytics/advertising. +SDK responses keep Didomi's `Cache-Control`, `Expires`, validators, age, and CDN +cache headers. Shared caches must include the full path and query in their cache +key and honor the geo-less redirect's `private, no-store` policy. Trusted Server +does not replace Didomi's freshness policy with a hard-coded TTL. -**Solution**: Check Didomi consent status in Trusted Server before data processing. +### API -**Benefit**: Consent status checked before data processing. - -## Implementation - -The Didomi integration is implemented in [crates/trusted-server-core/src/integrations/didomi.rs](https://github.com/IABTechLab/trusted-server/blob/main/crates/trusted-server-core/src/integrations/didomi.rs). - -### Key Components - -**Backend Selection** (line 74-80): - -```rust -fn backend_for_path(&self, consent_path: &str) -> DidomiBackend { - if consent_path.starts_with("/api/") { - DidomiBackend::Api // Route to API origin - } else { - DidomiBackend::Sdk // Route to SDK origin - } -} -``` +Paths under `/api/*` use the API origin. Country and region are not +appended to API URLs. The incoming API path, query, supported method, headers, and +body continue through the proxy. -**Header Forwarding** (line 100-127): +Every API request bypasses the platform outbound cache. Every API response is +returned with `Cache-Control: private, no-store`; freshness validators and +independent edge-cache headers are removed. -- Forwards standard HTTP headers -- Adds geo headers for SDK requests -- Preserves client IP via `X-Forwarded-For` +## Forwarded data -**CORS Management** (line 143-153): +Trusted Server forwards selected HTTP headers needed by Didomi, including +`Accept`, `Accept-Language`, `Accept-Encoding`, `Content-Type`, `User-Agent`, +`Referer`, and `Origin`. It derives `X-Forwarded-For` from trusted client info. -- Adds CORS headers to SDK responses -- Skips CORS for API requests (Didomi API handles it) +Cookies and the publisher's `Authorization` header are not forwarded to Didomi. +The latter can contain publisher-site credentials and is not a Didomi API +credential. -## Frontend Integration - -### Load Didomi SDK - -Replace your direct Didomi SDK reference with the proxied version: - -```html - - - - - -``` - -### Access Consent Status - -Use Didomi's standard JavaScript API: - -```javascript -// Wait for Didomi to load -window.didomiOnReady = window.didomiOnReady || [] -window.didomiOnReady.push(function (Didomi) { - // Check consent for specific purpose - if (Didomi.getUserStatus().purposes.consent.enabled.includes('cookies')) { - // User consented to cookies - initializeAnalytics() - } - - // Listen for consent changes - Didomi.on('consent.changed', function () { - console.log('Consent status changed') - }) -}) -``` - -## Best Practices - -### 1. Configure Didomi ID - -Ensure your Didomi organization ID is in the SDK path: - -```html - -``` - -### 2. Preconnect to Proxy - -Add DNS preconnect for faster loading: - -```html - - -``` - -### 3. Cache SDK Responses - -Configure caching headers for Didomi SDK: +SDK responses receive these CORS headers: ```http -Cache-Control: public, max-age=3600 +Access-Control-Allow-Origin: * +Access-Control-Allow-Headers: Content-Type, Authorization, X-Requested-With +Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS ``` -### 4. Monitor Consent Rate - -Track consent acceptance/rejection rates: - -- Low acceptance → Review consent notice clarity -- Regional variations → Adjust messaging -- Trend analysis → Optimize user experience - -## Troubleshooting - -### Didomi SDK Not Loading - -**Symptoms**: - -- Consent notice doesn't appear -- Console errors about missing Didomi - -**Solutions**: - -- Verify `/integrations/didomi/consent/` path is correct -- Check `sdk_origin` configuration -- Ensure Didomi ID in script path is valid -- Inspect network tab for 404/403 errors - -### CORS Errors - -**Symptoms**: - -- Browser console shows CORS errors -- SDK requests blocked - -**Solutions**: - -- Verify integration adds CORS headers for SDK requests -- Check `Access-Control-Allow-Origin` is present -- Ensure requests go through proxy (not directly to Didomi) - -### API Requests Failing +## Rollout checks -**Symptoms**: +Before enabling `geo_query_parameters`: -- Consent events not recording -- Preference updates failing +1. Confirm the loader redirect remains same-origin and is allowed by the site's + Content Security Policy. +2. Confirm Fastly returns complete country and region values for the publisher's + supported traffic. +3. Confirm every shared cache keys SDK objects by the complete path and query. +4. Test at least two locations that require different notices. +5. Verify repeated requests for one country/region reuse the cached SDK response + and retain Didomi's freshness and validator headers. +6. Verify API requests never enter the outbound or downstream cache. +7. Purge loader and API entries cached before this behavior was enabled. -**Solutions**: +Do not enable `geo_query_parameters` on Cloudflare, Axum, or Spin until those +adapters provide complete trusted geo and their support is documented. -- Check `/integrations/didomi/consent/api/*` routing -- Verify `api_origin` configuration -- Review Authorization headers are forwarded -- Inspect Didomi API credentials - -## Performance - -### Typical Latency - -- SDK load: 100-200ms (first load) -- Cached SDK: <50ms -- API calls: 50-150ms -- Total overhead: ~20ms (proxy layer) - -### Optimization - -- Enable HTTP/2 for multiplexing -- Use CDN caching for SDK files -- Implement service worker for offline consent -- Lazy-load consent notice - -## Security - -### Content Security Policy - -Add Didomi to your CSP: - -```http -Content-Security-Policy: - script-src 'self' /integrations/didomi/; - connect-src 'self' /integrations/didomi/; - frame-src 'self' /integrations/didomi/; -``` +## Troubleshooting -### Data Privacy +If an enabled loader returns `503`, verify that Fastly resolved both a valid +country and region for the trusted client IP. A country-only result is deliberately +rejected. -- Didomi consent data stays first-party -- Data sharing follows recorded consent status -- Consent strings stored locally -- User can withdraw consent anytime +If the browser redirects repeatedly, inspect the full query at every cache layer. +A component that drops, reorders, or rewrites the authoritative parameters can +prevent the URL from reaching its canonical form. -## Next Steps +If the wrong notice appears, verify that the final browser URL contains the +expected pair and that the cache key includes the full query. Purge stale loader +entries after correcting cache configuration. -- Review [GDPR Compliance](/guide/gdpr-compliance) for consent signal handling -- Explore [Lockr Integration](/guide/integrations/lockr) for consent-based identity -- Check [Configuration](/guide/configuration) for advanced setup -- Read [First-Party Proxy](/guide/first-party-proxy) for proxy architecture +If consent events fail, verify `/api/*` routing and `api_origin`. Publisher basic +authentication is intentionally removed before the request reaches Didomi. diff --git a/docs/superpowers/plans/2026-09-07-didomi-geo-forwarding.md b/docs/superpowers/plans/2026-09-07-didomi-geo-forwarding.md new file mode 100644 index 000000000..e65e61249 --- /dev/null +++ b/docs/superpowers/plans/2026-09-07-didomi-geo-forwarding.md @@ -0,0 +1,348 @@ +# Didomi Geo Forwarding 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:** Add trusted country and region parameters to Didomi notice-loader URLs with browser-safe cache partitioning on Fastly. + +**Architecture:** Add an opt-in field to the typed Didomi integration configuration. For eligible loader requests, resolve Fastly geo through `RuntimeServices`, canonicalize the query with authoritative `country` and `region` values, redirect geo-less or non-canonical browser URLs to that same-origin canonical URL, and proxy canonical requests to Didomi. Preserve Didomi SDK cache headers, bypass cache for Didomi API requests, and fail closed when complete trusted geo is unavailable. + +**Tech Stack:** Rust 2024, `edgezero_core`, `http`, `url`, `serde`, `validator`, Fastly Compute, repository test aliases, Markdown/Prettier. + +--- + +### Task 1: Add and round-trip the opt-in configuration + +**Files:** + +- Modify: `crates/trusted-server-core/src/integrations/didomi.rs:24-96,365-654` +- Modify: `crates/trusted-server-core/src/config_payload.rs:50-150` + +- [ ] **Step 1: Write failing configuration tests** + +Add tests proving that an omitted `geo_query_parameters` field is `false`, an explicit `true` is retained by `Settings::integration_config::()`, and `true` survives `Settings` serialization through `BlobEnvelope` and `settings_from_config_blob`. + +The Didomi parsing test should use the real settings path: + +```rust +let settings = Settings::from_toml(&format!( + "{}\n[integrations.didomi]\nenabled = true\ngeo_query_parameters = true\n", + crate_test_settings_str() +)) +.expect("should parse Didomi geo configuration"); +let config = settings + .integration_config::(DIDOMI_INTEGRATION_ID) + .expect("should read Didomi configuration") + .expect("should enable Didomi"); +assert!(config.geo_query_parameters, "should retain geo opt-in"); +``` + +- [ ] **Step 2: Run the focused tests and verify RED** + +Run: + +```bash +cargo test --package trusted-server-core --target aarch64-apple-darwin didomi +cargo test --package trusted-server-core --target aarch64-apple-darwin config_payload +``` + +Expected: compilation or assertions fail because `DidomiIntegrationConfig` has no `geo_query_parameters` field. + +- [ ] **Step 3: Add the configuration field** + +Add the field without a custom default function so missing values deserialize to `false`: + +```rust +/// Add trusted country and region parameters to notice-loader URLs. +#[serde(default)] +pub geo_query_parameters: bool, +``` + +Update every `DidomiIntegrationConfig` literal in tests. Keep the default test helper disabled and add a small helper or explicit assignment for enabled geo tests. + +- [ ] **Step 4: Run the focused tests and verify GREEN** + +Run the two commands from Step 2. Expected: all matching tests pass. + +- [ ] **Step 5: Commit the configuration slice** + +```bash +git add crates/trusted-server-core/src/integrations/didomi.rs crates/trusted-server-core/src/config_payload.rs +git commit -m "Add Didomi geo forwarding configuration" +``` + +### Task 2: Define loader matching, geo normalization, and canonical query behavior + +**Files:** + +- Modify: `crates/trusted-server-core/src/integrations/didomi.rs:98-229,365-654` + +- [ ] **Step 1: Write failing pure-behavior tests** + +Add separate tests for: + +- matching exactly `//loader.js` and rejecting missing keys, API paths, POST handling inputs, suffixes, trailing slashes, and extra segments; +- trimming and uppercasing `us` / `ca` to `US` / `CA`; +- accepting `US-CA` and removing only the matching `US-` prefix; +- rejecting missing region, mismatched prefixes, `XX`/`ZZ`, non-ASCII country, and region characters outside one to three ASCII alphanumerics; +- removing all case-insensitive and percent-decoded `country`/`region` pairs; +- preserving unrelated decoded pairs, order, duplicates, and empty values; +- appending exactly `country` then `region` and producing an idempotent canonical path/query. + +Use a table for invalid paths and geo pairs. Include a query such as: + +```text +target_type=notice&x=1&Country=gb&%72egion=lnd&x=2&empty=&space=a+b&plus=%2B +``` + +and expect canonical WHATWG form serialization with only `country=US®ion=CA` as the final geo pairs. + +- [ ] **Step 2: Run the Didomi tests and verify RED** + +Run `cargo test --package trusted-server-core --target aarch64-apple-darwin didomi`. Expected: compilation fails because the matcher, normalized geo type, and canonicalization helpers do not exist. + +- [ ] **Step 3: Implement private pure helpers** + +Add a private normalized pair: + +```rust +#[derive(Debug, Clone, Eq, PartialEq)] +struct DidomiGeo { + country: String, + region: String, +} +``` + +Implement private helpers that: + +1. recognize the exact loader shape from `consent_path`; +2. normalize `GeoInfo.country` and `GeoInfo.region` under the spec rules; +3. rebuild the browser path/query using `url::form_urlencoded::parse` and `Serializer`; +4. return both the relative canonical browser target and canonical query used for the upstream URL. + +Compare geo names with `eq_ignore_ascii_case` after form decoding. Do not add an ISO registry dependency or decode the path a second time. + +- [ ] **Step 4: Run the Didomi tests and verify GREEN** + +Run `cargo test --package trusted-server-core --target aarch64-apple-darwin didomi`. Expected: all Didomi tests pass. + +- [ ] **Step 5: Commit the pure canonicalization slice** + +```bash +git add crates/trusted-server-core/src/integrations/didomi.rs +git commit -m "Canonicalize Didomi loader geo parameters" +``` + +### Task 3: Redirect non-canonical loaders and proxy canonical loaders + +**Files:** + +- Modify: `crates/trusted-server-core/src/integrations/didomi.rs:153-332,365-654` + +- [ ] **Step 1: Add request-level failing tests** + +Create a local `PlatformGeo` stub in the Didomi test module and build `RuntimeServices` with the existing `StubHttpClient` and `StubBackend`. Add tests proving: + +- disabled mode preserves the existing geo-less upstream request; +- enabled mode redirects a geo-less eligible loader with status 307, a relative `Location`, and `Cache-Control: private, no-store` without calling the HTTP client; +- caller-provided mixed-case or duplicate geo is replaced in the redirect; +- a canonical loader makes exactly one SDK request with the same canonical query; +- canonical loader headers `X-Geo-Country`, `X-Geo-Region`, and `CloudFront-Viewer-Country` all come from normalized platform geo, even when conflicting Fastly-style headers are supplied by the request; +- an unrelated SDK asset retains the existing behavior; +- unavailable, failed, invalid, and country-only geo return 503, remain private/no-store, and do not call upstream. + +Queue a stub response only for canonical proxy tests. Assert call count through `recorded_backend_names`, the URI through `recorded_request_uris`, and outbound headers through `recorded_request_headers`. + +- [ ] **Step 2: Run request-level tests and verify RED** + +Run `cargo test --package trusted-server-core --target aarch64-apple-darwin didomi`. Expected: redirect, authoritative-header, or failure assertions fail because `handle` still proxies every request directly. + +- [ ] **Step 3: Implement the loader decision in `handle`** + +Before backend registration or request body collection: + +1. check `geo_query_parameters`, `GET`, SDK backend, and exact loader shape; +2. call `services.geo().lookup(services.client_info().client_ip)`; +3. return a generic terminal-private 503 on lookup error or incomplete/invalid geo; +4. construct the canonical relative target; +5. return a terminal-private 307 with the relative `Location` when the incoming path/query is non-canonical; +6. carry the normalized pair into canonical SDK header construction and use its canonical query for `build_target_url`. + +Use `crate::response_privacy::enforce_terminal_private_cache_privacy` on generated redirects and failures so operator response headers cannot restore shared caching. Log only the integration name and a bounded reason such as `lookup_failed`, `missing_region`, or `invalid_geo`. + +Refactor `copy_headers` to accept an optional authoritative `DidomiGeo`. On an enabled canonical loader, set all three compatibility headers from that pair. On other SDK requests, retain the existing header-copy behavior. + +- [ ] **Step 4: Run the Didomi tests and verify GREEN** + +Run `cargo test --package trusted-server-core --target aarch64-apple-darwin didomi`. Expected: all request-level and existing Didomi tests pass. + +- [ ] **Step 5: Commit the request behavior** + +```bash +git add crates/trusted-server-core/src/integrations/didomi.rs +git commit -m "Forward trusted geo to Didomi loaders" +``` + +### Task 4: Enforce the Didomi API no-cache contract + +**Files:** + +- Modify: `crates/trusted-server-core/src/integrations/didomi.rs:278-332,365-654` + +- [ ] **Step 1: Write failing API cache tests** + +Add tests proving an API request records `with_cache_bypass()` and its response is terminal-private with `Cache-Control: private, no-store` after upstream `Cache-Control`, `Expires`, `ETag`, `Last-Modified`, `Age`, `Surrogate-Control`, and `Fastly-CDN-Cache-Control` headers are supplied. Add a paired SDK test proving the same origin cache headers remain unchanged there. + +- [ ] **Step 2: Run the tests and verify RED** + +Run `cargo test --package trusted-server-core --target aarch64-apple-darwin didomi`. Expected: the API cache-bypass flag is `false` and upstream cache headers remain. + +- [ ] **Step 3: Implement API cache bypass and response privacy** + +Build the outbound wrapper as follows: + +```rust +let platform_request = PlatformHttpRequest::new(proxy_req, backend_name); +let platform_request = if matches!(backend, DidomiBackend::Api) { + platform_request.with_cache_bypass() +} else { + platform_request +}; +``` + +After receiving an API response, call `enforce_terminal_private_cache_privacy`. Continue adding CORS only to SDK responses and leave SDK origin cache headers intact. + +- [ ] **Step 4: Run the tests and verify GREEN** + +Run `cargo test --package trusted-server-core --target aarch64-apple-darwin didomi`. Expected: all Didomi cache tests pass. + +- [ ] **Step 5: Commit the cache behavior** + +```bash +git add crates/trusted-server-core/src/integrations/didomi.rs +git commit -m "Enforce Didomi API cache privacy" +``` + +### Task 5: Verify canonical URI conversion on Fastly + +**Files:** + +- Modify: `crates/trusted-server-adapter-fastly/src/platform.rs:394-430,1009-1025` + +- [ ] **Step 1: Write the Fastly conversion test** + +Add a unit test that builds an EdgeZero request with the canonical URI emitted by the Didomi serializer, including spaces, literal plus values, percent escapes, duplicates, empty values, and an apostrophe. Convert it through `edge_request_to_fastly` and assert `get_url().query()` or the equivalent Fastly request accessor has the same decoded pairs in the same order and exactly one country/region pair. + +- [ ] **Step 2: Run the focused Fastly test** + +Run: + +```bash +cargo test-fastly edge_request_to_fastly_preserves_canonical_didomi_query +``` + +Expected: pass if the existing adapter conversion is transparent. If it fails, first add a regression assertion showing the exact normalization difference, then make the smallest adapter correction that preserves existing request semantics. + +- [ ] **Step 3: Commit the adapter regression test** + +```bash +git add crates/trusted-server-adapter-fastly/src/platform.rs +git commit -m "Test Didomi query conversion on Fastly" +``` + +### Task 6: Update operator configuration and Didomi documentation + +**Files:** + +- Modify: `trusted-server.example.toml:457-462` +- Modify: `crates/trusted-server-integration-tests/fixtures/configs/trusted-server.integration.toml:53-56` +- Modify: `docs/guide/integrations/didomi.md:23-158,197-240,296-302` +- Modify: `docs/superpowers/specs/2026-09-07-didomi-geo-design.md` + +- [ ] **Step 1: Update the TOML template and fixture** + +Add `geo_query_parameters = false` to the active Didomi blocks. Keep TOML as the primary configuration instructions. If documenting the derived environment override, describe it only as a `ts config validate`/`ts config push` overlay that requires the scalar TOML leaf and does not affect a running deployment. + +- [ ] **Step 2: Rewrite the Didomi guide around actual behavior** + +Document: + +- the new option and Fastly-only support; +- the 307 canonical loader flow and exact loader path; +- trusted platform geo precedence and replacement of caller geo parameters; +- the 503 boundary for missing/invalid/country-only geo; +- full path/query cache keys and preservation of Didomi SDK cache headers; +- API cache bypass and downstream no-store; +- no cookie or publisher `Authorization` forwarding; +- rollout validation and cache purge guidance. + +Remove the current incorrect statement that `Authorization` is forwarded and the hard-coded SDK `Cache-Control: public, max-age=3600` recommendation. Link the current Didomi reverse-proxy page at `https://developers.didomi.io/api-and-platform/domains/reverse-proxy`. + +- [ ] **Step 3: Format and check documentation** + +Run: + +```bash +cd docs && npm run format +``` + +Expected: Prettier completes successfully. + +- [ ] **Step 4: Commit configuration and documentation** + +```bash +git add trusted-server.example.toml crates/trusted-server-integration-tests/fixtures/configs/trusted-server.integration.toml docs/guide/integrations/didomi.md docs/superpowers/specs/2026-09-07-didomi-geo-design.md +git commit -m "Document Didomi geo forwarding" +``` + +### Task 7: Run repository validation and inspect the completed branch + +**Files:** + +- Verify all files changed by Tasks 1-6 + +- [ ] **Step 1: Run format checks** + +```bash +cargo fmt --all -- --check +cd docs && npm run format +``` + +Expected: both commands exit successfully with no formatting changes left. + +- [ ] **Step 2: Run target-matched tests** + +```bash +cargo test-fastly +cargo test-axum +cargo test-cloudflare +cargo test-spin +``` + +Expected: every target-matched test suite passes. Do not substitute bare `cargo test --workspace`. + +- [ ] **Step 3: Run target-matched clippy** + +```bash +cargo clippy-fastly +cargo clippy-axum +cargo clippy-cloudflare +cargo clippy-cloudflare-wasm +cargo clippy-spin-native +cargo clippy-spin-wasm +``` + +Expected: every command exits successfully without warnings. + +- [ ] **Step 4: Review the final diff against issue 85 and the spec** + +```bash +git status --short +git diff main...HEAD --check +git diff --stat main...HEAD +``` + +Confirm each acceptance criterion in the spec has code, automated coverage where possible, or an explicit staging requirement. Confirm no Cloudflare, Axum, Spin, EdgeZero, or production JavaScript behavior changed beyond shared core behavior remaining disabled on unsupported adapters. + +- [ ] **Step 5: Prepare the branch for review** + +Do not merge or push without the user's direction. Report the branch name, commits, validation evidence, and any staging-only checks that remain. diff --git a/docs/superpowers/specs/2026-09-07-didomi-geo-design.md b/docs/superpowers/specs/2026-09-07-didomi-geo-design.md new file mode 100644 index 000000000..3c3659a45 --- /dev/null +++ b/docs/superpowers/specs/2026-09-07-didomi-geo-design.md @@ -0,0 +1,344 @@ +# Didomi loader geo forwarding — issue 85 + +Status: Revised design for publisher review. No runtime implementation is included. + +## Outcome + +When Trusted Server serves a Didomi notice loader, it resolves trusted visitor geo +and redirects the browser to the same first-party loader URL with `country` and +`region` query parameters. It then proxies the canonical request to Didomi while +preserving the SDK origin's cache headers. + +For example, a browser request to: + +```text +https://publisher.example.com/integrations/didomi/consent/example-key/loader.js?target_type=notice&target=example-notice +``` + +with platform geo `US` / `CA` receives a temporary redirect to: + +```text +https://publisher.example.com/integrations/didomi/consent/example-key/loader.js?target_type=notice&target=example-notice&country=US®ion=CA +``` + +Trusted Server proxies that canonical request to the configured SDK origin with +the same path and query. The browser URL, downstream cache key, and upstream cache +key therefore identify the same geographic variant. + +## Requirements and evidence + +Issue 85 asks Trusted Server to produce the country and region query parameters +that Didomi's browser configuration normally adds to the notice-loader URL. + +Didomi's reverse-proxy contract requires: + +- ISO 3166-1 alpha-2 country and ISO 3166-2 region codes on loader requests; +- SDK cache separation by the full path, query, country, and region; +- preservation of the SDK origin's cache and freshness headers downstream; +- no caching for requests to the API origin; +- no cookies forwarded upstream; and +- the visitor IP forwarded in `X-Forwarded-For`. + +Sources: [issue 85](https://github.com/IABTechLab/trusted-server/issues/85) and +[Didomi reverse-proxy guidance](https://developers.didomi.io/api-and-platform/domains/reverse-proxy). +The live vendor contract must be reconfirmed during staging because it can change. + +Current code in `crates/trusted-server-core/src/integrations/didomi.rs` preserves +the incoming query but does not add geo. It copies selected Fastly-style geo +headers to the SDK origin and forwards the trusted client IP from runtime +services. The JavaScript integration only sets `window.didomiConfig.sdkPath`. + +`PlatformGeo` already supplies request-scoped `GeoInfo`. Fastly provides country +and optional region. Cloudflare currently provides country only. Axum and Spin +provide no geo. + +## Design choice + +Use a canonical same-origin redirect for the notice loader. + +| Approach | Benefit | Reason not selected | +| ------------------------------------------- | -------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | +| Populate `window.didomiConfig.user` in HTML | Avoids a redirect | Per-reader geo cannot enter shared HTML templates without a separate assembly design | +| Enrich only the outbound URL | No extra browser request | The browser caches the geo-specific response under a geo-less URL and can reuse the wrong notice after a location change | +| Canonical same-origin redirect — selected | Qualifies browser, CDN, and origin cache keys while keeping HTML shareable | Adds one non-cacheable redirect request when loading the notice | + +Every page continues using the stable, geo-less embed URL. Trusted Server resolves +geo and returns `307 Temporary Redirect` to the same path with canonical geo +parameters. A later location change produces a different target instead of reusing +a loader cached under a geo-less URL. + +## Configuration and compatibility + +Add `geo_query_parameters: bool` to `DidomiIntegrationConfig`, defaulting to +`false`. Existing deployments retain their current behavior. Publishers opt in +after verifying complete platform geo and cache configuration. + +```toml +[integrations.didomi] +enabled = true +geo_query_parameters = true +``` + +This is application configuration, not adapter process configuration. Operators +set it in their private `trusted-server.toml` and publish it with +`ts config push --adapter `. The CLI deserializes and validates the file +as `TrustedServerAppConfig`, serializes the resolved `Settings` into an EdgeZero +blob envelope, and writes that envelope to the adapter's configured app-config +store. The Fastly adapter loads that blob through +`get_settings_from_config_store`, verifies the envelope, reconstructs `Settings`, +and constructs the `IntegrationRegistry`. +`settings.integration_config::()` then deserializes and +validates the Didomi object when the registry registers the integration. + +Document the TOML field as the normal way to configure this feature. The deployed +runtime does not read a `TRUSTED_SERVER__INTEGRATIONS__...` variable. EdgeZero's +CLI can optionally apply the mechanically derived +`TRUSTED_SERVER__INTEGRATIONS__DIDOMI__GEO_QUERY_PARAMETERS` overlay while +validating or pushing config, but that is only a push-time convenience. It works +only when the scalar leaf already exists in the input TOML and does not change a +running deployment by itself. + +Add `geo_query_parameters = false` to the active disabled Didomi stub in +`trusted-server.example.toml`. This gives `ts config init` users a discoverable +field while keeping `ts audit` behavior stable: audit may flip `enabled` when it +detects Didomi, but geo canonicalization remains an explicit publisher choice. + +When disabled, loader handling remains unchanged by this feature. API cache bypass +and downstream API no-store behavior apply regardless because API responses must +never be cached. When enabled, eligible loaders use the redirect contract and fail +closed if complete trusted geo is unavailable. + +The first implementation supports Fastly only. Cloudflare, Axum, and Spin retain +current loader behavior with the option disabled. Configuration documentation must +say that enabling it on those adapters is unsupported. + +## Trust and precedence + +Platform geo is authoritative. Query parameters, request headers, cookies, and +`window.didomiConfig` are not trusted geo inputs. + +For an eligible request with `geo_query_parameters = true`: + +1. Resolve geo with + `services.geo().lookup(services.client_info().client_ip)`. +2. Accept only a complete, structurally valid country/region pair supplied by the + adapter's documented ISO-code contract. +3. Remove every incoming country/region field and append the trusted pair to a + canonical relative URL. +4. Redirect without contacting Didomi when the incoming URL is not canonical. +5. Proxy to Didomi when the incoming URL already equals the canonical form. + +Parameter-name comparison is ASCII case-insensitive after one application of URL +query decoding. Variants such as `Country` and `%63ountry`, including duplicates, +cannot remain beside the authoritative values. + +Publisher URL overrides are intentionally unsupported. Letting browser values +select a shared upstream entry would require a maintained ISO 3166-1/3166-2 +registry and verified Didomi behavior for unsupported pairs. Issue 85 does not +require that configuration surface. + +Normalize the trusted platform pair as follows: + +- trim ASCII whitespace and uppercase both fields; +- country must be exactly two ASCII letters and not `XX` or `ZZ`; +- region must be one to three ASCII alphanumeric characters; +- for a country-prefixed region such as `US-CA`, require the prefix to match and + send only the subdivision portion (`CA`); +- never infer geo from language, city, coordinates, arbitrary headers, or the + forwarded client IP inside core. + +Structural checks guard adapter bugs. ISO membership derives from the adapter's +provider contract, rather than a syntactically plausible browser value. + +## Incomplete geo + +| State | Loader behavior | Cache behavior | +| ------------------------------ | ----------------------------------------- | ------------------------------------ | +| Complete trusted pair | Canonicalize and proxy | Cache final URL using Didomi headers | +| Geo unavailable | Return `503`; do not contact Didomi | `private, no-store` | +| Lookup failure | Log a value-free warning and return `503` | `private, no-store` | +| Invalid or country-only result | Log a value-free warning and return `503` | `private, no-store` | + +Failing closed prevents an incorrect notice and avoids querying Didomi once per +visitor without a safe geo cache key. The response is a small generic integration +error with no location data and no fallback to caller-supplied geo. + +Fastly can legitimately omit region for territories without an ISO subdivision. +Those locations are explicitly unsupported in the first release and receive the +same `503` when the option is enabled. Publishers must accept this boundary before +global enablement. Supporting those territories requires separate Didomi guidance +for a canonical cache-safe representation; this design does not invent one. + +## Loader matching and canonical URL + +Apply the feature only to SDK `GET` requests whose path relative to the configured +proxy prefix is exactly `//loader.js`, where `` is one +nonempty segment. Do not match `/loader.js`, API paths, POST, other SDK assets, +suffix lookalikes, trailing slashes, or deeper paths. Match case-sensitively and do +not perform a second path-decoding pass. + +Do not append `country` or `region` to API-origin requests. Didomi's current +contract uses these query parameters to select the notice loader; API paths retain +their incoming path, query, method, headers, and body while following the separate +no-cache rules below. + +Parse and serialize the query with the existing `url` crate's WHATWG form-URL +encoding behavior. Preserve every unrelated decoded name/value pair, including +order, duplicates, and empty values. Remove every pair whose decoded name is a geo +field, then append exactly one `country` and `region`, in that order. Canonical +serialization may normalize equivalent raw spellings—for example, percent escapes, +spaces, plus signs, and an unescaped apostrophe—but must not change the decoded +unrelated names or values. The request is canonical only when its path and query +equal this serialized form. Canonicalization is idempotent. + +For a non-canonical request, put the constructed path and query in a relative +same-origin `Location`. Never derive an authority from `Host` or forwarded-host +headers. Return `307 Temporary Redirect` with `Cache-Control: private, no-store`; +remove validators and independent edge-cache headers with the shared response +privacy utility. Do not contact either Didomi origin. + +For a canonical request, build the Didomi target from the same serialized path and +query. Passing it through the real Fastly request conversion must produce the same +canonical query; an adapter-level test covers characters such as an apostrophe that +`http::Uri` and `url::Url` represent differently. Generate `X-Geo-Country`, +`X-Geo-Region`, and `CloudFront-Viewer-Country` from the same normalized pair. Do +not copy caller-supplied Fastly geo headers on this path. Continue deriving +`X-Forwarded-For` from `RuntimeServices.client_info`. Do not forward cookies or +publisher `Authorization`. + +Other SDK assets retain current query and header behavior. This issue does not +claim that every existing Didomi SDK path satisfies the vendor's full hosting +contract; that audit remains separate. + +## Cache behavior + +The geo-less entry URL is private and non-storable. The final browser URL contains +geo, so browser and shared-cache entries naturally separate locations by the full +query. Operators must ensure all shared caches retain the full path and query in +their key and honor redirect no-store. A cache rule that drops or normalizes these +parameters is incompatible. + +Send canonical loader requests through the platform's normal outbound cache. The +full enriched URL is the variant key. Preserve Didomi's `Cache-Control`, +`Expires`, `ETag`, `Last-Modified`, `Age`, and CDN cache headers on the response. + +All API-origin requests use `PlatformHttpRequest::with_cache_bypass()`. Their +responses use `Cache-Control: private, no-store`; independent edge-cache headers +and freshness validators are removed with shared response-privacy utilities. SDK +status and body forwarding otherwise remain unchanged, including upstream errors. + +Adapter-specific cache behavior cannot be proven by core unit tests. Staging must +demonstrate distinct variants, reuse within a variant, vendor TTL handling, +conditional validation, and correct response-header propagation. + +## Cloudflare follow-up + +Cloudflare documents `request.cf.regionCode` as an ISO 3166-2 first-level region +code, but pinned EdgeZero v0.0.7 discards `request.cf` during conversion. Its typed +Cloudflare context retains only `Env` and `Context`. + +Cloudflare parity is outside the first implementation and its acceptance gate. A +follow-up must: + +1. extend `edgezero-adapter-cloudflare` to capture `request.cf.country()` and + `request.cf.region_code()` in a private typed request extension before consuming + the Worker request; +2. release and pin the new EdgeZero version; +3. consume that extension in `trusted-server-adapter-cloudflare`; and +4. verify valid, missing-`request.cf`, and missing-region behavior on wasm32 and + native stubs. + +A caller-writable `cf-region-code` header must not become an authority. Until this +follow-up lands, documentation and release notes list Cloudflare as unsupported for +`geo_query_parameters = true`. + +## Errors and observability + +Do not log geo values, client IPs, full URLs, or query strings. Lookup failure and +incomplete geo log one warning containing the integration and a bounded reason +category. Invalid incoming geo is silently replaced because it is not an input. +Existing URL, backend, body-size, and transport failures retain current +`error-stack` handling. + +Do not add location-valued metrics. Bounded failure reasons may use an existing +integration metric if one exists; otherwise logging is sufficient. No adapter name +is required because `RuntimeServices` does not expose one. + +## Implementation boundaries + +- `crates/trusted-server-core/src/integrations/didomi.rs`: configuration flag, + loader matcher, geo resolution and normalization, `url`-canonical redirect, + coherent geo headers, failure response, SDK cache preservation, and API cache + privacy. +- `trusted-server.example.toml`: add the disabled-by-default field to the existing + active Didomi stub. Operator-owned `trusted-server.toml` files remain untracked. +- `crates/trusted-server-core/src/config.rs` and + `crates/trusted-server-core/src/config_payload.rs`: no new configuration + transport; existing typed deploy validation and blob deserialization must + exercise the added field. +- `crates/trusted-server-adapter-fastly/src/platform.rs`: test that canonical query + serialization survives conversion to a Fastly request; no production behavior + change is expected. +- `docs/guide/integrations/didomi.md`: option, redirect flow, precedence, loader + path, Fastly support, excluded geographies, failure behavior, full-query cache + requirement, API no-cache behavior, and rollout checks. Correct the existing + claim that publisher `Authorization` is forwarded. +- No production JavaScript, shared HTML-template, Cloudflare, Axum, Spin, or + EdgeZero changes in the first implementation. + +Keep helpers private. Do not add an ISO registry dependency, publisher override, +general geo endpoint, or shared integration interface. + +## Acceptance criteria + +1. The new option defaults to disabled. Existing loader behavior remains unchanged + in that state. The example template exposes it as `false`; typed TOML and pushed + blob round-trip tests preserve it. Default and custom prefixes work when + enabled. +2. A geo-less `//loader.js` redirects once to its relative same-origin + canonical URL with normalized country and region. The redirect does not call + upstream and is private and non-storable without validators or edge-cache + directives. +3. Incoming lowercase, mixed-case, duplicated, and percent-encoded geo field names + are replaced. Unrelated decoded pairs retain their order, duplicates, empty + values, names, and values after documented WHATWG serialization. +4. A canonical request calls the SDK origin once with the same canonical path and + query. Core and Fastly conversion tests cover percent escapes, spaces, plus + signs, apostrophes, duplicates, and empty values. Its URL and three geo + compatibility headers contain the same trusted pair. Caller geo headers cannot + affect them. +5. Matching rejects `/loader.js`, API paths, other assets, POST, filename + lookalikes, extra segments, and trailing slashes. +6. Missing, failed, invalid, and country-only geo—including a no-subdivision + territory—return a private, non-storable `503` without an upstream call when + enabled. Disabled mode retains current behavior. +7. Canonical SDK responses preserve all upstream cache and validator headers. API + requests bypass outbound cache and return without shared-cache or validator + headers. +8. IP forwarding still uses client info; cookies and publisher Authorization stay + excluded. Existing SDK CORS behavior and unrelated POST body limits remain. +9. A browser-level staging test follows the redirect and executes the Didomi + script under the canonical URL. Changing the same browser's simulated location + yields a different final URL and correct notice instead of prior cached content. +10. Repeated requests for one pair demonstrate cache reuse and vendor TTL/validator + behavior. Same-pair requests with different IPs confirm the loader is + interchangeable. +11. Publisher validation confirms expected notices for at least two locations and + accepts the visible failure boundary for unsupported geo. + +## Verification and rollout + +Implementation verification follows `CLAUDE.md`: target-matched adapter tests and +clippy aliases, Rust formatting, integration parity, JavaScript build/tests/format, +and docs formatting. Do not use bare `cargo test --workspace`. + +Configuration verification must cover the real operator path: initialize or use a +fixture `trusted-server.toml`, validate it through `TrustedServerAppConfig`, verify +the serialized blob retains the Didomi field, and load that blob back into +`Settings`. A process environment variable is not a runtime acceptance path. + +Before production, verify the live Didomi contract, same-origin redirect and CSP +compatibility, complete Fastly geo coverage, the accepted no-subdivision exclusion, +full-query cache keys, cache reuse, conditional requests, and response-header +preservation. Purge previously cached loader and API responses before enabling the +option. Do not enable it on unsupported adapters. diff --git a/trusted-server.example.toml b/trusted-server.example.toml index 3a0e4c61f..4892dc01e 100644 --- a/trusted-server.example.toml +++ b/trusted-server.example.toml @@ -458,6 +458,7 @@ client_side_bidders = [] # bidders running via native Prebid.js adapter # can flip `enabled` to true when Didomi is detected on the audited page. [integrations.didomi] enabled = false +geo_query_parameters = false # sdk_origin = "https://sdk.example.com" # api_origin = "https://api.example.com" From 84dacecb197836f557d1b2052f3b2122dd2ad012 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Mon, 7 Sep 2026 16:27:48 +0530 Subject: [PATCH 07/10] Complete Didomi geo forwarding review --- .../src/integrations/didomi.rs | 37 +++++++++++ .../plans/2026-09-07-didomi-geo-forwarding.md | 66 +++++++++---------- 2 files changed, 70 insertions(+), 33 deletions(-) diff --git a/crates/trusted-server-core/src/integrations/didomi.rs b/crates/trusted-server-core/src/integrations/didomi.rs index c6f47798e..096902dc5 100644 --- a/crates/trusted-server-core/src/integrations/didomi.rs +++ b/crates/trusted-server-core/src/integrations/didomi.rs @@ -834,6 +834,43 @@ mod tests { ); } + #[test] + fn enabled_geo_redirects_loader_under_custom_proxy_prefix() { + let stub = Arc::new(StubHttpClient::new()); + let services = services_with_geo( + Arc::clone(&stub), + GeoResult::Value(Some(geo_info("us", Some("ca")))), + ); + let settings = create_test_settings(); + let integration = DidomiIntegration::new(Arc::new(DidomiIntegrationConfig { + proxy_path: Some("publisher/privacy".to_string()), + ..config_with_geo_query_parameters() + })); + let request = http::Request::builder() + .method(Method::GET) + .uri("https://publisher.example/publisher/privacy/key/loader.js?target_type=notice") + .body(EdgeBody::empty()) + .expect("should build request"); + + let response = + futures::executor::block_on(integration.handle(&settings, &services, request)) + .expect("should return redirect"); + + assert_eq!(response.status(), http::StatusCode::TEMPORARY_REDIRECT); + assert_eq!( + response + .headers() + .get(header::LOCATION) + .and_then(|value| value.to_str().ok()), + Some("/publisher/privacy/key/loader.js?target_type=notice&country=US®ion=CA"), + "should preserve the custom proxy prefix in the canonical target" + ); + assert!( + stub.recorded_backend_names().is_empty(), + "should not contact Didomi before the custom-prefix redirect" + ); + } + #[test] fn enabled_geo_proxies_canonical_loader_with_authoritative_headers() { let stub = Arc::new(StubHttpClient::new()); diff --git a/docs/superpowers/plans/2026-09-07-didomi-geo-forwarding.md b/docs/superpowers/plans/2026-09-07-didomi-geo-forwarding.md index e65e61249..636972957 100644 --- a/docs/superpowers/plans/2026-09-07-didomi-geo-forwarding.md +++ b/docs/superpowers/plans/2026-09-07-didomi-geo-forwarding.md @@ -17,7 +17,7 @@ - Modify: `crates/trusted-server-core/src/integrations/didomi.rs:24-96,365-654` - Modify: `crates/trusted-server-core/src/config_payload.rs:50-150` -- [ ] **Step 1: Write failing configuration tests** +- [x] **Step 1: Write failing configuration tests** Add tests proving that an omitted `geo_query_parameters` field is `false`, an explicit `true` is retained by `Settings::integration_config::()`, and `true` survives `Settings` serialization through `BlobEnvelope` and `settings_from_config_blob`. @@ -36,7 +36,7 @@ let config = settings assert!(config.geo_query_parameters, "should retain geo opt-in"); ``` -- [ ] **Step 2: Run the focused tests and verify RED** +- [x] **Step 2: Run the focused tests and verify RED** Run: @@ -47,7 +47,7 @@ cargo test --package trusted-server-core --target aarch64-apple-darwin config_pa Expected: compilation or assertions fail because `DidomiIntegrationConfig` has no `geo_query_parameters` field. -- [ ] **Step 3: Add the configuration field** +- [x] **Step 3: Add the configuration field** Add the field without a custom default function so missing values deserialize to `false`: @@ -59,11 +59,11 @@ pub geo_query_parameters: bool, Update every `DidomiIntegrationConfig` literal in tests. Keep the default test helper disabled and add a small helper or explicit assignment for enabled geo tests. -- [ ] **Step 4: Run the focused tests and verify GREEN** +- [x] **Step 4: Run the focused tests and verify GREEN** Run the two commands from Step 2. Expected: all matching tests pass. -- [ ] **Step 5: Commit the configuration slice** +- [x] **Step 5: Commit the configuration slice** ```bash git add crates/trusted-server-core/src/integrations/didomi.rs crates/trusted-server-core/src/config_payload.rs @@ -76,7 +76,7 @@ git commit -m "Add Didomi geo forwarding configuration" - Modify: `crates/trusted-server-core/src/integrations/didomi.rs:98-229,365-654` -- [ ] **Step 1: Write failing pure-behavior tests** +- [x] **Step 1: Write failing pure-behavior tests** Add separate tests for: @@ -96,11 +96,11 @@ target_type=notice&x=1&Country=gb&%72egion=lnd&x=2&empty=&space=a+b&plus=%2B and expect canonical WHATWG form serialization with only `country=US®ion=CA` as the final geo pairs. -- [ ] **Step 2: Run the Didomi tests and verify RED** +- [x] **Step 2: Run the Didomi tests and verify RED** Run `cargo test --package trusted-server-core --target aarch64-apple-darwin didomi`. Expected: compilation fails because the matcher, normalized geo type, and canonicalization helpers do not exist. -- [ ] **Step 3: Implement private pure helpers** +- [x] **Step 3: Implement private pure helpers** Add a private normalized pair: @@ -121,11 +121,11 @@ Implement private helpers that: Compare geo names with `eq_ignore_ascii_case` after form decoding. Do not add an ISO registry dependency or decode the path a second time. -- [ ] **Step 4: Run the Didomi tests and verify GREEN** +- [x] **Step 4: Run the Didomi tests and verify GREEN** Run `cargo test --package trusted-server-core --target aarch64-apple-darwin didomi`. Expected: all Didomi tests pass. -- [ ] **Step 5: Commit the pure canonicalization slice** +- [x] **Step 5: Commit the pure canonicalization slice** ```bash git add crates/trusted-server-core/src/integrations/didomi.rs @@ -138,7 +138,7 @@ git commit -m "Canonicalize Didomi loader geo parameters" - Modify: `crates/trusted-server-core/src/integrations/didomi.rs:153-332,365-654` -- [ ] **Step 1: Add request-level failing tests** +- [x] **Step 1: Add request-level failing tests** Create a local `PlatformGeo` stub in the Didomi test module and build `RuntimeServices` with the existing `StubHttpClient` and `StubBackend`. Add tests proving: @@ -152,11 +152,11 @@ Create a local `PlatformGeo` stub in the Didomi test module and build `RuntimeSe Queue a stub response only for canonical proxy tests. Assert call count through `recorded_backend_names`, the URI through `recorded_request_uris`, and outbound headers through `recorded_request_headers`. -- [ ] **Step 2: Run request-level tests and verify RED** +- [x] **Step 2: Run request-level tests and verify RED** Run `cargo test --package trusted-server-core --target aarch64-apple-darwin didomi`. Expected: redirect, authoritative-header, or failure assertions fail because `handle` still proxies every request directly. -- [ ] **Step 3: Implement the loader decision in `handle`** +- [x] **Step 3: Implement the loader decision in `handle`** Before backend registration or request body collection: @@ -171,11 +171,11 @@ Use `crate::response_privacy::enforce_terminal_private_cache_privacy` on generat Refactor `copy_headers` to accept an optional authoritative `DidomiGeo`. On an enabled canonical loader, set all three compatibility headers from that pair. On other SDK requests, retain the existing header-copy behavior. -- [ ] **Step 4: Run the Didomi tests and verify GREEN** +- [x] **Step 4: Run the Didomi tests and verify GREEN** Run `cargo test --package trusted-server-core --target aarch64-apple-darwin didomi`. Expected: all request-level and existing Didomi tests pass. -- [ ] **Step 5: Commit the request behavior** +- [x] **Step 5: Commit the request behavior** ```bash git add crates/trusted-server-core/src/integrations/didomi.rs @@ -188,15 +188,15 @@ git commit -m "Forward trusted geo to Didomi loaders" - Modify: `crates/trusted-server-core/src/integrations/didomi.rs:278-332,365-654` -- [ ] **Step 1: Write failing API cache tests** +- [x] **Step 1: Write failing API cache tests** -Add tests proving an API request records `with_cache_bypass()` and its response is terminal-private with `Cache-Control: private, no-store` after upstream `Cache-Control`, `Expires`, `ETag`, `Last-Modified`, `Age`, `Surrogate-Control`, and `Fastly-CDN-Cache-Control` headers are supplied. Add a paired SDK test proving the same origin cache headers remain unchanged there. +Add tests proving an API request records `with_cache_bypass()` and its response is terminal-private with `Cache-Control: private, no-store` after upstream `Cache-Control`, `Expires`, `ETag`, `Last-Modified`, `Age`, `Surrogate-Control`, and `CDN-Cache-Control` headers are supplied. Add a paired SDK test proving the same origin cache headers remain unchanged there. -- [ ] **Step 2: Run the tests and verify RED** +- [x] **Step 2: Run the tests and verify RED** Run `cargo test --package trusted-server-core --target aarch64-apple-darwin didomi`. Expected: the API cache-bypass flag is `false` and upstream cache headers remain. -- [ ] **Step 3: Implement API cache bypass and response privacy** +- [x] **Step 3: Implement API cache bypass and response privacy** Build the outbound wrapper as follows: @@ -211,11 +211,11 @@ let platform_request = if matches!(backend, DidomiBackend::Api) { After receiving an API response, call `enforce_terminal_private_cache_privacy`. Continue adding CORS only to SDK responses and leave SDK origin cache headers intact. -- [ ] **Step 4: Run the tests and verify GREEN** +- [x] **Step 4: Run the tests and verify GREEN** Run `cargo test --package trusted-server-core --target aarch64-apple-darwin didomi`. Expected: all Didomi cache tests pass. -- [ ] **Step 5: Commit the cache behavior** +- [x] **Step 5: Commit the cache behavior** ```bash git add crates/trusted-server-core/src/integrations/didomi.rs @@ -228,11 +228,11 @@ git commit -m "Enforce Didomi API cache privacy" - Modify: `crates/trusted-server-adapter-fastly/src/platform.rs:394-430,1009-1025` -- [ ] **Step 1: Write the Fastly conversion test** +- [x] **Step 1: Write the Fastly conversion test** Add a unit test that builds an EdgeZero request with the canonical URI emitted by the Didomi serializer, including spaces, literal plus values, percent escapes, duplicates, empty values, and an apostrophe. Convert it through `edge_request_to_fastly` and assert `get_url().query()` or the equivalent Fastly request accessor has the same decoded pairs in the same order and exactly one country/region pair. -- [ ] **Step 2: Run the focused Fastly test** +- [x] **Step 2: Run the focused Fastly test** Run: @@ -242,7 +242,7 @@ cargo test-fastly edge_request_to_fastly_preserves_canonical_didomi_query Expected: pass if the existing adapter conversion is transparent. If it fails, first add a regression assertion showing the exact normalization difference, then make the smallest adapter correction that preserves existing request semantics. -- [ ] **Step 3: Commit the adapter regression test** +- [x] **Step 3: Commit the adapter regression test** ```bash git add crates/trusted-server-adapter-fastly/src/platform.rs @@ -258,11 +258,11 @@ git commit -m "Test Didomi query conversion on Fastly" - Modify: `docs/guide/integrations/didomi.md:23-158,197-240,296-302` - Modify: `docs/superpowers/specs/2026-09-07-didomi-geo-design.md` -- [ ] **Step 1: Update the TOML template and fixture** +- [x] **Step 1: Update the TOML template and fixture** Add `geo_query_parameters = false` to the active Didomi blocks. Keep TOML as the primary configuration instructions. If documenting the derived environment override, describe it only as a `ts config validate`/`ts config push` overlay that requires the scalar TOML leaf and does not affect a running deployment. -- [ ] **Step 2: Rewrite the Didomi guide around actual behavior** +- [x] **Step 2: Rewrite the Didomi guide around actual behavior** Document: @@ -277,7 +277,7 @@ Document: Remove the current incorrect statement that `Authorization` is forwarded and the hard-coded SDK `Cache-Control: public, max-age=3600` recommendation. Link the current Didomi reverse-proxy page at `https://developers.didomi.io/api-and-platform/domains/reverse-proxy`. -- [ ] **Step 3: Format and check documentation** +- [x] **Step 3: Format and check documentation** Run: @@ -287,7 +287,7 @@ cd docs && npm run format Expected: Prettier completes successfully. -- [ ] **Step 4: Commit configuration and documentation** +- [x] **Step 4: Commit configuration and documentation** ```bash git add trusted-server.example.toml crates/trusted-server-integration-tests/fixtures/configs/trusted-server.integration.toml docs/guide/integrations/didomi.md docs/superpowers/specs/2026-09-07-didomi-geo-design.md @@ -300,7 +300,7 @@ git commit -m "Document Didomi geo forwarding" - Verify all files changed by Tasks 1-6 -- [ ] **Step 1: Run format checks** +- [x] **Step 1: Run format checks** ```bash cargo fmt --all -- --check @@ -309,7 +309,7 @@ cd docs && npm run format Expected: both commands exit successfully with no formatting changes left. -- [ ] **Step 2: Run target-matched tests** +- [x] **Step 2: Run target-matched tests** ```bash cargo test-fastly @@ -320,7 +320,7 @@ cargo test-spin Expected: every target-matched test suite passes. Do not substitute bare `cargo test --workspace`. -- [ ] **Step 3: Run target-matched clippy** +- [x] **Step 3: Run target-matched clippy** ```bash cargo clippy-fastly @@ -333,7 +333,7 @@ cargo clippy-spin-wasm Expected: every command exits successfully without warnings. -- [ ] **Step 4: Review the final diff against issue 85 and the spec** +- [x] **Step 4: Review the final diff against issue 85 and the spec** ```bash git status --short @@ -343,6 +343,6 @@ git diff --stat main...HEAD Confirm each acceptance criterion in the spec has code, automated coverage where possible, or an explicit staging requirement. Confirm no Cloudflare, Axum, Spin, EdgeZero, or production JavaScript behavior changed beyond shared core behavior remaining disabled on unsupported adapters. -- [ ] **Step 5: Prepare the branch for review** +- [x] **Step 5: Prepare the branch for review** Do not merge or push without the user's direction. Report the branch name, commits, validation evidence, and any staging-only checks that remain. From 176da5b4167e49b4bd23fc1896437ef0756c5421 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Mon, 7 Sep 2026 16:39:46 +0530 Subject: [PATCH 08/10] Expand Didomi geo regression coverage --- .../src/integrations/didomi.rs | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/crates/trusted-server-core/src/integrations/didomi.rs b/crates/trusted-server-core/src/integrations/didomi.rs index 096902dc5..0171240e7 100644 --- a/crates/trusted-server-core/src/integrations/didomi.rs +++ b/crates/trusted-server-core/src/integrations/didomi.rs @@ -750,7 +750,9 @@ mod tests { }; let canonical = canonical_loader_url( "/integrations/didomi/consent/key/loader.js", - Some("target_type=notice&x=1&Country=gb&%72egion=lnd&x=2&empty=&space=a+b&plus=%2B"), + Some( + "target_type=notice&x=1&Country=gb&%63ountry=de&%72egion=lnd&Region=ny&x=2&empty=&space=a+b&plus=%2B", + ), &geo, ); @@ -1083,12 +1085,15 @@ mod tests { ("Surrogate-Control", "max-age=3600"), ], ); - let services = services_with_geo(Arc::clone(&stub), GeoResult::Value(None)); + let services = services_with_geo( + Arc::clone(&stub), + GeoResult::Value(Some(geo_info("US", Some("CA")))), + ); let settings = create_test_settings(); - let integration = DidomiIntegration::new(Arc::new(config(true))); + let integration = DidomiIntegration::new(Arc::new(config_with_geo_query_parameters())); let request = http::Request::builder() .method(Method::GET) - .uri("https://publisher.example/integrations/didomi/consent/sdk/v1/core.js") + .uri("https://publisher.example/integrations/didomi/consent/key/loader.js?country=US®ion=CA") .body(EdgeBody::empty()) .expect("should build request"); @@ -1099,7 +1104,7 @@ mod tests { assert_eq!( stub.recorded_cache_bypass_flags(), vec![false], - "should retain normal platform caching for SDK requests" + "should retain normal platform caching for canonical SDK loaders" ); for (name, expected) in [ (header::CACHE_CONTROL.as_str(), "public, max-age=3600"), From f058edc11ceb0508d8c0cd4c48533959e6acc28c Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Tue, 8 Sep 2026 18:28:13 +0530 Subject: [PATCH 09/10] Address Didomi PR review feedback --- crates/trusted-server-adapter-fastly/src/platform.rs | 8 ++++---- crates/trusted-server-core/src/config_payload.rs | 4 ++-- .../superpowers/plans/2026-09-07-didomi-geo-forwarding.md | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/crates/trusted-server-adapter-fastly/src/platform.rs b/crates/trusted-server-adapter-fastly/src/platform.rs index 8828a19d1..e97febebc 100644 --- a/crates/trusted-server-adapter-fastly/src/platform.rs +++ b/crates/trusted-server-adapter-fastly/src/platform.rs @@ -1025,22 +1025,22 @@ mod tests { } #[test] - fn edge_request_to_fastly_preserves_canonical_didomi_query() { + fn edge_request_to_fastly_preserves_query_encoding_order_and_duplicates() { let expected_query = "space=a+b&plus=%2B"e=%27&empty=&x=1&x=2&country=US®ion=CA"; let request = request_builder() .method("GET") .uri(format!( - "https://sdk.privacy-center.org/key/loader.js?{expected_query}" + "https://sdk.example.com/key/loader.js?{expected_query}" )) .body(Body::empty()) - .expect("should build canonical Didomi request"); + .expect("should build request with encoded query"); let fastly_req = edge_request_to_fastly(request).expect("should convert request"); assert_eq!( fastly_req.get_url().query(), Some(expected_query), - "should preserve the canonical Didomi query across Fastly conversion" + "should preserve query encoding and order across Fastly conversion" ); assert_eq!( fastly_req diff --git a/crates/trusted-server-core/src/config_payload.rs b/crates/trusted-server-core/src/config_payload.rs index 9aaf1f0ea..5b78d2661 100644 --- a/crates/trusted-server-core/src/config_payload.rs +++ b/crates/trusted-server-core/src/config_payload.rs @@ -111,8 +111,8 @@ mod tests { enabled: true, geo_query_parameters: true, proxy_path: None, - sdk_origin: "https://sdk.privacy-center.org".to_string(), - api_origin: "https://api.privacy-center.org".to_string(), + sdk_origin: "https://sdk.example.com".to_string(), + api_origin: "https://api.example.com".to_string(), }, ) .expect("should insert Didomi configuration"); diff --git a/docs/superpowers/plans/2026-09-07-didomi-geo-forwarding.md b/docs/superpowers/plans/2026-09-07-didomi-geo-forwarding.md index 636972957..ac58f3696 100644 --- a/docs/superpowers/plans/2026-09-07-didomi-geo-forwarding.md +++ b/docs/superpowers/plans/2026-09-07-didomi-geo-forwarding.md @@ -237,7 +237,7 @@ Add a unit test that builds an EdgeZero request with the canonical URI emitted b Run: ```bash -cargo test-fastly edge_request_to_fastly_preserves_canonical_didomi_query +cargo test-fastly edge_request_to_fastly_preserves_query_encoding_order_and_duplicates ``` Expected: pass if the existing adapter conversion is transparent. If it fails, first add a regression assertion showing the exact normalization difference, then make the smallest adapter correction that preserves existing request semantics. From eb4e9431695fdcfcf65cdcb7bd2bc3d6cc72cd2d Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Tue, 8 Sep 2026 18:39:00 +0530 Subject: [PATCH 10/10] Resolve final Didomi review cleanup --- .../trusted-server-core/src/integrations/didomi.rs | 12 +++++------- .../specs/2026-09-07-didomi-geo-design.md | 2 +- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/crates/trusted-server-core/src/integrations/didomi.rs b/crates/trusted-server-core/src/integrations/didomi.rs index 0171240e7..42c843a40 100644 --- a/crates/trusted-server-core/src/integrations/didomi.rs +++ b/crates/trusted-server-core/src/integrations/didomi.rs @@ -588,8 +588,8 @@ mod tests { enabled, geo_query_parameters: false, proxy_path: None, - sdk_origin: default_sdk_origin(), - api_origin: default_api_origin(), + sdk_origin: "https://sdk.example.com".to_string(), + api_origin: "https://api.example.com".to_string(), } } @@ -898,9 +898,7 @@ mod tests { assert_eq!(response.status(), http::StatusCode::OK); assert_eq!( stub.recorded_request_uris(), - vec![ - "https://sdk.privacy-center.org/key/loader.js?target_type=notice&country=US®ion=CA" - ], + vec!["https://sdk.example.com/key/loader.js?target_type=notice&country=US®ion=CA"], "should send the canonical query to Didomi" ); let headers = stub.recorded_request_headers(); @@ -941,7 +939,7 @@ mod tests { assert_eq!(response.status(), http::StatusCode::OK); assert_eq!( stub.recorded_request_uris(), - vec!["https://sdk.privacy-center.org/key/loader.js?target_type=notice"], + vec!["https://sdk.example.com/key/loader.js?target_type=notice"], "should not add geo when the option is disabled" ); } @@ -966,7 +964,7 @@ mod tests { assert_eq!(response.status(), http::StatusCode::OK); assert_eq!( stub.recorded_request_uris(), - vec!["https://sdk.privacy-center.org/sdk/v1/core.js?v=1"], + vec!["https://sdk.example.com/sdk/v1/core.js?v=1"], "should not apply loader geo behavior to other SDK assets" ); } diff --git a/docs/superpowers/specs/2026-09-07-didomi-geo-design.md b/docs/superpowers/specs/2026-09-07-didomi-geo-design.md index 3c3659a45..50cb23738 100644 --- a/docs/superpowers/specs/2026-09-07-didomi-geo-design.md +++ b/docs/superpowers/specs/2026-09-07-didomi-geo-design.md @@ -1,6 +1,6 @@ # Didomi loader geo forwarding — issue 85 -Status: Revised design for publisher review. No runtime implementation is included. +Status: Implemented by this change. ## Outcome