From 82942de4a7d2596337f2e0a51c060bdea22cc20b Mon Sep 17 00:00:00 2001 From: Christian Date: Tue, 10 Nov 2026 20:25:03 -0600 Subject: [PATCH] Extract existing Sourcepoint consent synchronization --- crates/trusted-server-adapter-axum/src/app.rs | 36 +- .../tests/routes.rs | 72 +++ .../src/app.rs | 16 + .../trusted-server-adapter-fastly/src/app.rs | 41 ++ crates/trusted-server-adapter-spin/src/app.rs | 16 + .../src/ec/consent_update.rs | 558 ++++++++++++++++++ crates/trusted-server-core/src/ec/finalize.rs | 9 +- crates/trusted-server-core/src/ec/mod.rs | 1 + .../integrations/sourcepoint/consent_sync.ts | 54 ++ .../integrations/sourcepoint/gpp_capture.ts | 230 ++++++++ .../lib/src/integrations/sourcepoint/index.ts | 110 +++- .../sourcepoint/consent_sync.test.ts | 99 ++++ .../sourcepoint/gpp_capture.test.ts | 457 ++++++++++++++ docs/guide/integrations/sourcepoint.md | 33 +- 14 files changed, 1702 insertions(+), 30 deletions(-) create mode 100644 crates/trusted-server-core/src/ec/consent_update.rs create mode 100644 crates/trusted-server-js/lib/src/integrations/sourcepoint/consent_sync.ts create mode 100644 crates/trusted-server-js/lib/src/integrations/sourcepoint/gpp_capture.ts create mode 100644 crates/trusted-server-js/lib/test/integrations/sourcepoint/consent_sync.test.ts create mode 100644 crates/trusted-server-js/lib/test/integrations/sourcepoint/gpp_capture.test.ts diff --git a/crates/trusted-server-adapter-axum/src/app.rs b/crates/trusted-server-adapter-axum/src/app.rs index 8126a4089c..e45f7807f7 100644 --- a/crates/trusted-server-adapter-axum/src/app.rs +++ b/crates/trusted-server-adapter-axum/src/app.rs @@ -177,6 +177,9 @@ where let services = state.services_for_request(&ctx)?; if ctx.parts().method == Method::POST { let limit = match ctx.parts().uri.path() { + trusted_server_core::ec::consent_update::CONSENT_UPDATE_PATH => { + Some(trusted_server_core::ec::consent_update::MAX_CONSENT_UPDATE_BODY_BYTES) + } "/verify-signature" => { Some(trusted_server_core::request_signing::endpoints::VERIFY_MAX_BODY_BYTES) } @@ -193,6 +196,14 @@ where } let mut req = ctx.into_request()?; apply_native_ingress(&mut req)?; + if req.uri().path() == trusted_server_core::ec::consent_update::CONSENT_UPDATE_PATH { + req = match trusted_server_core::ec::consent_update::prepare_consent_update(&services, req) + .await + { + Ok(req) => req, + Err(response) => return Ok(*response), + }; + } if let Err(error) = trusted_server_core::integrations::gpt_diagnostics::prepare_request( &state.settings, &mut req, @@ -313,6 +324,7 @@ enum EcRoute { Publisher, Auction, PageBids, + ConsentUpdate, } // --------------------------------------------------------------------------- @@ -375,6 +387,9 @@ async fn dispatch_ec_route( registry: Some(&state.partners), }; let result = match route { + EcRoute::ConsentUpdate => Ok( + trusted_server_core::ec::consent_update::handle_consent_update(&req, ec.graph.as_ref()), + ), EcRoute::Auction => { handle_auction( &state.settings, @@ -490,6 +505,7 @@ enum NamedRouteHandler { LegacyAdminDenied, Auction, PageBids, + ConsentUpdate, Identify, FirstPartyProxy, FirstPartyClick, @@ -513,8 +529,23 @@ const LEGACY_ADMIN_DENY_METHODS: &[Method] = &[ Method::DELETE, ]; -fn named_routes() -> [NamedRoute; 18] { +fn named_routes() -> [NamedRoute; 19] { [ + NamedRoute { + path: trusted_server_core::ec::consent_update::CONSENT_UPDATE_PATH, + primary_methods: &[ + Method::GET, + Method::POST, + Method::HEAD, + Method::OPTIONS, + Method::PUT, + Method::PATCH, + Method::DELETE, + Method::TRACE, + Method::CONNECT, + ], + handler: NamedRouteHandler::ConsentUpdate, + }, NamedRoute { path: "/_ts/api/v1/identify", primary_methods: &[Method::GET, Method::OPTIONS], @@ -688,6 +719,9 @@ fn named_route_handler( handle_admin_eids_lookup(&state.partners, &req) } NamedRouteHandler::LegacyAdminDenied => Ok(legacy_admin_alias_denied()), + NamedRouteHandler::ConsentUpdate => { + dispatch_ec_route(&state, &services, req, EcRoute::ConsentUpdate).await + } NamedRouteHandler::Identify => { // Read-only identity lookup never enters generation or // response finalization, including cookie ingestion. diff --git a/crates/trusted-server-adapter-axum/tests/routes.rs b/crates/trusted-server-adapter-axum/tests/routes.rs index 8c8c066096..971e4ee96a 100644 --- a/crates/trusted-server-adapter-axum/tests/routes.rs +++ b/crates/trusted-server-adapter-axum/tests/routes.rs @@ -98,6 +98,77 @@ fn assert_route_registered(method: &str, path: &str) { ); } +#[tokio::test] +async fn consent_route_rejects_other_methods_and_foreign_origins_locally() { + for method in [ + "GET", "HEAD", "OPTIONS", "PUT", "PATCH", "DELETE", "TRACE", "CONNECT", + ] { + assert_route_registered(method, "/_ts/consent"); + let mut service = make_service(); + let response = service + .ready() + .await + .expect("ready") + .call( + Request::builder() + .method(method) + .uri("/_ts/consent") + .header("host", "test-publisher.example.com") + .body(AxumBody::empty()) + .expect("request"), + ) + .await + .expect("response"); + assert_eq!(response.status().as_u16(), 405); + assert_eq!(response.headers()["allow"], "POST"); + let cache = response.headers()["cache-control"] + .to_str() + .expect("cache policy"); + assert!(cache.contains("no-store") && cache.contains("private")); + } + for origin in [None, Some("https://foreign.example")] { + let mut builder = Request::builder() + .method("POST") + .uri("/_ts/consent") + .header("host", "test-publisher.example.com"); + if let Some(origin) = origin { + builder = builder.header("origin", origin); + } + let response = make_service() + .ready() + .await + .expect("ready") + .call(builder.body(AxumBody::empty()).expect("request")) + .await + .expect("response"); + assert_eq!(response.status().as_u16(), 403); + } +} + +#[tokio::test] +async fn consent_route_does_not_falsely_acknowledge_an_unconfigured_store() { + let response = make_service() + .ready() + .await + .expect("ready") + .call( + Request::builder() + .method("POST") + .uri("/_ts/consent") + .header("host", "test-publisher.example.com") + .header("origin", "http://test-publisher.example.com") + .header("content-type", "application/json") + .body(AxumBody::from( + r#"{"gppString":"DBABLA~BVQqAAAAAgA.QA","applicableSections":[7]}"#, + )) + .expect("request"), + ) + .await + .expect("response"); + assert_eq!(response.status().as_u16(), 503); + assert!(!response.headers().contains_key("set-cookie")); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn aps_profile_serves_renderer_through_adapter_fallback() { let mut settings = test_settings(); @@ -153,6 +224,7 @@ fn all_explicit_routes_are_registered() { ("POST", "/admin/keys/rotate"), ("POST", "/admin/keys/deactivate"), ("POST", "/auction"), + ("POST", "/_ts/consent"), // SPA re-auction endpoint, plus its deprecated `/__ts/` alias. Both // paths are spelled out as literals rather than referencing // `PAGE_BIDS_PATH` / `PAGE_BIDS_LEGACY_PATH` so this test pins the diff --git a/crates/trusted-server-adapter-cloudflare/src/app.rs b/crates/trusted-server-adapter-cloudflare/src/app.rs index ae1e8e4ba2..3daf3202b3 100644 --- a/crates/trusted-server-adapter-cloudflare/src/app.rs +++ b/crates/trusted-server-adapter-cloudflare/src/app.rs @@ -531,6 +531,22 @@ fn build_router(state: &Arc) -> RouterService { ) -> Result { let services = state.services_for_request(&ctx); let mut req = ctx.into_request()?; + if req.uri().path() == trusted_server_core::ec::consent_update::CONSENT_UPDATE_PATH { + // Reserve this path without an identity graph; never forward + // consent snapshots or acknowledge an unpersisted revocation. + return Ok( + match trusted_server_core::ec::consent_update::prepare_consent_update( + &services, req, + ) + .await + { + Ok(req) => trusted_server_core::ec::consent_update::handle_consent_update( + &req, None, + ), + Err(response) => *response, + }, + ); + } if let Some(response) = deny_admin_diagnostic_fallback(&req) { return Ok(response); } diff --git a/crates/trusted-server-adapter-fastly/src/app.rs b/crates/trusted-server-adapter-fastly/src/app.rs index f4ccc27478..e0b4d571ec 100644 --- a/crates/trusted-server-adapter-fastly/src/app.rs +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -572,6 +572,9 @@ async fn execute_named( NamedRouteHandler::BatchSync => { Some(trusted_server_core::ec::batch_sync::MAX_BODY_SIZE) } + NamedRouteHandler::ConsentUpdate => { + Some(trusted_server_core::ec::consent_update::MAX_CONSENT_UPDATE_BODY_BYTES) + } _ => None, }; if let Some(limit) = limit { @@ -579,6 +582,14 @@ async fn execute_named( } } let mut req = ctx.into_request()?; + if matches!(handler, NamedRouteHandler::ConsentUpdate) { + req = match trusted_server_core::ec::consent_update::prepare_consent_update(&services, req) + .await + { + Ok(req) => req, + Err(response) => return Ok(*response), + }; + } // S2S batch sync uses Bearer auth (not EC cookies), so it skips EC // context creation entirely — mirroring the dedicated early arm in the @@ -704,6 +715,12 @@ async fn run_named_route( .await } } + NamedRouteHandler::ConsentUpdate => Ok( + trusted_server_core::ec::consent_update::handle_consent_update( + &req, + ec.finalize_kv_graph.as_ref(), + ), + ), NamedRouteHandler::SetTester => handle_set_tester(&state.settings), NamedRouteHandler::ClearTester => handle_clear_tester(&state.settings), NamedRouteHandler::Auction => { @@ -813,6 +830,14 @@ async fn dispatch_fallback( services: &RuntimeServices, mut req: Request, ) -> Response { + if req.uri().path() == trusted_server_core::ec::consent_update::CONSENT_UPDATE_PATH { + return match trusted_server_core::ec::consent_update::prepare_consent_update(services, req) + .await + { + Ok(req) => trusted_server_core::ec::consent_update::handle_consent_update(&req, None), + Err(response) => *response, + }; + } if let Some(response) = deny_admin_diagnostic_fallback(&req) { return response; } @@ -1123,6 +1148,7 @@ enum NamedRouteHandler { LegacyAdminDenied, BatchSync, Identify, + ConsentUpdate, SetTester, ClearTester, Auction, @@ -1155,6 +1181,21 @@ const LEGACY_ADMIN_DENY_METHODS: &[Method] = &[ ]; const NAMED_ROUTES: &[NamedRoute] = &[ + NamedRoute { + path: trusted_server_core::ec::consent_update::CONSENT_UPDATE_PATH, + primary_methods: &[ + Method::GET, + Method::POST, + Method::HEAD, + Method::OPTIONS, + Method::PUT, + Method::PATCH, + Method::DELETE, + Method::TRACE, + Method::CONNECT, + ], + handler: NamedRouteHandler::ConsentUpdate, + }, NamedRoute { path: "/.well-known/trusted-server.json", primary_methods: &[Method::GET], diff --git a/crates/trusted-server-adapter-spin/src/app.rs b/crates/trusted-server-adapter-spin/src/app.rs index df5b74c14c..18b1c4468c 100644 --- a/crates/trusted-server-adapter-spin/src/app.rs +++ b/crates/trusted-server-adapter-spin/src/app.rs @@ -679,6 +679,22 @@ fn build_router(state: Option>) -> RouterService { ) -> Result { let services = state.services_for_request(&ctx); let mut req = ctx.into_request()?; + if req.uri().path() == trusted_server_core::ec::consent_update::CONSENT_UPDATE_PATH { + // Reserve this path without an identity graph; never forward + // consent snapshots or acknowledge an unpersisted revocation. + return Ok( + match trusted_server_core::ec::consent_update::prepare_consent_update( + &services, req, + ) + .await + { + Ok(req) => trusted_server_core::ec::consent_update::handle_consent_update( + &req, None, + ), + Err(response) => *response, + }, + ); + } if let Some(response) = deny_admin_diagnostic_fallback(&req) { return Ok(response); } diff --git a/crates/trusted-server-core/src/ec/consent_update.rs b/crates/trusted-server-core/src/ec/consent_update.rs new file mode 100644 index 0000000000..1ffdb942dc --- /dev/null +++ b/crates/trusted-server-core/src/ec/consent_update.rs @@ -0,0 +1,558 @@ +//! First-party reconciliation after the publisher CMP changes its real consent signal. +//! +//! The browser transports consent through the ordinary consent cookies. This endpoint +//! accepts no client-supplied identity, vendor token, or inferred permission grant. + +use edgezero_core::body::Body as EdgeBody; +use http::{HeaderValue, Method, Request, Response, StatusCode, header}; +use serde::Deserialize; +use url::Url; + +use super::kv::KvIdentityGraph; +use crate::http_util::RequestInfo; +use crate::platform::RuntimeServices; +use crate::response_privacy::enforce_terminal_private_cache_privacy; + +pub const CONSENT_UPDATE_PATH: &str = "/_ts/consent"; +// Two browser-cookie-sized signals plus their JSON representation. This is a +// consent-only transport, not another general auction or identity payload. +pub const MAX_CONSENT_UPDATE_BODY_BYTES: usize = 16 * 1024; + +#[derive(Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct ConsentSnapshot { + #[serde(deserialize_with = "required_nullable_gpp")] + gpp_string: Option, + applicable_sections: Vec, +} + +fn required_nullable_gpp<'de, D: serde::Deserializer<'de>>( + deserializer: D, +) -> Result, D::Error> { + Option::deserialize(deserializer) +} + +#[derive(Clone, Copy, Debug)] +struct ConsentUpdateResponse; + +#[derive(Clone, Copy, Debug)] +struct PreparedConsentUpdate; + +fn response(status: StatusCode) -> Response { + let mut response = Response::new(EdgeBody::empty()); + *response.status_mut() = status; + enforce_terminal_private_cache_privacy(&mut response); + response +} + +/// Marks an accepted consent update whose finalization could not confirm revocation. +/// Ordinary publisher/error responses retain their existing status semantics. +pub(crate) fn mark_reconciliation_failed(response: &mut Response) { + if response + .extensions() + .get::() + .is_some() + { + *response.status_mut() = StatusCode::SERVICE_UNAVAILABLE; + } +} + +fn same_origin(services: &RuntimeServices, req: &Request) -> bool { + let Some(origin) = req + .headers() + .get(header::ORIGIN) + .and_then(|value| value.to_str().ok()) + else { + return false; + }; + let Ok(origin) = Url::parse(origin) else { + return false; + }; + if !origin.username().is_empty() + || origin.password().is_some() + || origin.path() != "/" + || origin.query().is_some() + || origin.fragment().is_some() + { + return false; + } + let info = RequestInfo::from_request(req, services.client_info()); + let Ok(expected) = Url::parse(&format!("{}://{}", info.scheme, info.host)) else { + return false; + }; + // The serving origin may be a reviewed demo origin; use normalized trusted + // ingress, not a hard-coded publisher origin or a viewer-selected header. + origin.origin() == expected.origin() +} + +/// Reads one bounded CMP snapshot before adapters construct the EC context. +/// +/// Each request carries its own snapshot: a withdrawal must not accidentally +/// read a later grant from cookies by the time network dispatch occurs. +/// Only GPP consent headers are replaced; identity and other privacy signals remain. +/// +/// # Errors +/// +/// Returns a private HTTP error for unsupported methods, invalid origins, +/// oversized bodies, or malformed snapshots. Error responses are boxed so the +/// successful request path does not carry a large error variant. +pub async fn prepare_consent_update( + services: &RuntimeServices, + req: Request, +) -> Result, Box>> { + if req.method() != Method::POST { + let mut response = response(StatusCode::METHOD_NOT_ALLOWED); + response + .headers_mut() + .insert(header::ALLOW, HeaderValue::from_static("POST")); + return Err(Box::new(response)); + } + if !same_origin(services, &req) { + return Err(Box::new(response(StatusCode::FORBIDDEN))); + } + let (mut parts, body) = req.into_parts(); + let bytes = body + .into_bytes_bounded(MAX_CONSENT_UPDATE_BODY_BYTES) + .await + .map_err(|_| response(StatusCode::PAYLOAD_TOO_LARGE))?; + let snapshot: ConsentSnapshot = + serde_json::from_slice(&bytes).map_err(|_| response(StatusCode::BAD_REQUEST))?; + if let Some(gpp) = &snapshot.gpp_string { + if gpp.is_empty() + || !gpp + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || b"._~-=".contains(&byte)) + || snapshot + .applicable_sections + .iter() + .any(|id| !(1..=65535).contains(id)) + || crate::consent::gpp::decode_gpp_string(gpp).is_err() + { + return Err(Box::new(response(StatusCode::BAD_REQUEST))); + } + } else if !snapshot.applicable_sections.is_empty() { + return Err(Box::new(response(StatusCode::BAD_REQUEST))); + } + let mut cookies = Vec::new(); + for header in parts.headers.get_all(header::COOKIE) { + let value = header + .to_str() + .map_err(|_| response(StatusCode::BAD_REQUEST))?; + for pair in value + .split(';') + .map(str::trim) + .filter(|pair| !pair.is_empty()) + { + let name = pair.split_once('=').map_or(pair, |(name, _)| name.trim()); + if name != "__gpp" && name != "__gpp_sid" { + cookies.push(pair.to_owned()); + } + } + } + if let Some(gpp) = snapshot.gpp_string { + cookies.push(format!("__gpp={gpp}")); + if !snapshot.applicable_sections.is_empty() { + cookies.push(format!( + "__gpp_sid={}", + snapshot + .applicable_sections + .iter() + .map(i32::to_string) + .collect::>() + .join(",") + )); + } + } + parts.headers.remove(header::COOKIE); + if !cookies.is_empty() { + let value = HeaderValue::from_str(&cookies.join("; ")) + .map_err(|_| response(StatusCode::BAD_REQUEST))?; + parts.headers.insert(header::COOKIE, value); + } + let mut req = Request::from_parts(parts, EdgeBody::empty()); + req.extensions_mut().insert(PreparedConsentUpdate); + Ok(req) +} + +/// Acknowledges a prepared snapshot through the ordinary EC finalization path. +/// +/// Adapters must prepare the snapshot before extracting EC/consent context, run +/// their usual request filters, and finalize exactly once before headers commit. +/// This endpoint never mints an identity or restores EIDs. Thus an older grant +/// response cannot recreate an identity after a concurrent withdrawal. Creation +/// remains on eligible publisher navigation; partner identify remains read-only. +pub fn handle_consent_update( + req: &Request, + graph: Option<&KvIdentityGraph>, +) -> Response { + if req.extensions().get::().is_none() { + return response(StatusCode::BAD_REQUEST); + } + let mut response = response(if graph.is_some() { + StatusCode::NO_CONTENT + } else { + StatusCode::SERVICE_UNAVAILABLE + }); + response.extensions_mut().insert(ConsentUpdateResponse); + response +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::consent::{ConsentContext, jurisdiction::Jurisdiction}; + use crate::ec::{EcContext, finalize::ec_finalize_response, registry::PartnerRegistry}; + use crate::platform::test_support::noop_services; + use crate::test_support::tests::create_test_settings; + + const GPP: &str = "DBABLA~BVQqAAAAAgA.QA"; + const EC: &str = "aaaaaaaabbbbbbbbccccccccddddddddeeeeeeeeffffffff0000000011111111.abc123"; + + fn request( + method: Method, + origin: Option<&str>, + body: impl Into, + ) -> Request { + let mut builder = Request::builder() + .method(method) + .uri("https://publisher.example/_ts/consent") + .header(header::HOST, "publisher.example") + .header("x-forwarded-proto", "https") + .header(header::CONTENT_TYPE, "application/json"); + if let Some(origin) = origin { + builder = builder.header(header::ORIGIN, origin); + } + builder + .body(EdgeBody::from(body.into().to_string())) + .expect("fixture request") + } + + fn snapshot() -> serde_json::Value { + serde_json::json!({ "gppString": GPP, "applicableSections": [7] }) + } + + #[tokio::test] + async fn snapshot_replaces_only_gpp_headers_and_preserves_identity_and_gpc() { + let mut req = request(Method::POST, Some("https://publisher.example"), snapshot()); + req.headers_mut().insert( + header::COOKIE, + HeaderValue::from_str(&format!( + "__gpp=synthetic-later-state; __gpp_sid=2; us_privacy=1YNN; ts-ec={EC}" + )) + .expect("fixture cookie"), + ); + req.headers_mut() + .insert("sec-gpc", HeaderValue::from_static("1")); + let req = prepare_consent_update(&noop_services(), req) + .await + .expect("valid snapshot"); + let cookies = crate::cookies::handle_request_cookies(&req) + .expect("cookies") + .expect("jar"); + assert_eq!(cookies.get("__gpp").expect("GPP").value(), GPP); + assert_eq!(cookies.get("__gpp_sid").expect("sections").value(), "7"); + assert_eq!(cookies.get("ts-ec").expect("identity").value(), EC); + assert_eq!(cookies.get("us_privacy").expect("legacy").value(), "1YNN"); + assert_eq!(req.headers()["sec-gpc"], "1"); + } + + #[tokio::test] + async fn missing_signal_is_not_synthesized_as_an_opt_out_or_grant() { + let mut req = request( + Method::POST, + Some("https://publisher.example"), + serde_json::json!({ + "gppString": null, "applicableSections": [] + }), + ); + req.headers_mut().insert( + header::COOKIE, + HeaderValue::from_static("__gpp=stale; __gpp_sid=7; ordinary=retained"), + ); + let req = prepare_consent_update(&noop_services(), req) + .await + .expect("missing snapshot"); + let jar = crate::cookies::handle_request_cookies(&req) + .expect("cookies") + .expect("jar"); + assert!(jar.get("__gpp").is_none()); + assert!(jar.get("__gpp_sid").is_none()); + assert!(jar.get("ordinary").is_some()); + } + + #[tokio::test] + async fn rejects_missing_or_foreign_origins_and_non_origin_urls() { + for origin in [ + None, + Some("null"), + Some("https://attacker.invalid"), + Some("https://publisher.example/path"), + Some("https://user@publisher.example"), + ] { + let response = + prepare_consent_update(&noop_services(), request(Method::POST, origin, snapshot())) + .await + .expect_err("must reject origin"); + assert_eq!(response.status(), StatusCode::FORBIDDEN); + } + } + + #[tokio::test] + async fn rejects_unclaimed_methods_locally() { + for method in [Method::GET, Method::OPTIONS, Method::HEAD] { + let response = prepare_consent_update( + &noop_services(), + request(method, Some("https://publisher.example"), snapshot()), + ) + .await + .expect_err("must reject method"); + assert_eq!(response.status(), StatusCode::METHOD_NOT_ALLOWED); + assert_eq!(response.headers()[header::ALLOW], "POST"); + } + } + + #[tokio::test] + async fn rejects_malformed_or_inconsistent_snapshots() { + for body in [ + serde_json::json!({}), + serde_json::json!({"gppString": "invalid", "applicableSections": [7]}), + serde_json::json!({"gppString": null, "applicableSections": [7]}), + serde_json::json!({"gppString": GPP, "applicableSections": [-1]}), + serde_json::json!({"gppString": GPP, "applicableSections": [70000]}), + serde_json::json!({"gppString": GPP, "applicableSections": [7], "identity": "not-accepted"}), + ] { + let response = prepare_consent_update( + &noop_services(), + request(Method::POST, Some("https://publisher.example"), body), + ) + .await + .expect_err("must reject snapshot"); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + } + } + + #[tokio::test] + async fn reconciliation_does_not_mint_identity_even_when_creation_is_permitted() { + let settings = create_test_settings(); + let req = prepare_consent_update( + &noop_services(), + request(Method::POST, Some("https://publisher.example"), snapshot()), + ) + .await + .expect("valid snapshot"); + let graph = KvIdentityGraph::in_memory("consent-test"); + let mut ec = EcContext::new_for_test_with_ip( + None, + ConsentContext { + jurisdiction: Jurisdiction::NonRegulated, + ..Default::default() + }, + Some("198.51.100.24".to_owned()), + ); + let mut response = handle_consent_update(&req, Some(&graph)); + ec_finalize_response( + &settings, + &mut ec, + Some(&graph), + &PartnerRegistry::empty(), + None, + None, + &mut response, + ) + .await; + assert_eq!(response.status(), StatusCode::NO_CONTENT); + assert!(ec.ec_value().is_none()); + assert!(!response.headers().contains_key(header::SET_COOKIE)); + } + + #[tokio::test] + async fn real_opt_out_snapshot_tombstones_the_existing_row_despite_later_grant_cookies() { + // Synthetic protocol fixtures only, never visitor consent or identities. + const OPT_OUT: &str = "DBABRg~BVVVVVVVVWA.AA"; + let settings = create_test_settings(); + let services = noop_services(); + let graph = KvIdentityGraph::in_memory("consent-revoke-test"); + let mut original = EcContext::new_for_test_with_ip( + None, + ConsentContext { + jurisdiction: Jurisdiction::NonRegulated, + ..Default::default() + }, + Some("198.51.100.24".to_owned()), + ); + original + .generate_if_needed(&settings, Some(&graph)) + .await + .expect("fixture identity"); + let id = original.ec_value().expect("fixture ID").to_owned(); + let mut req = request( + Method::POST, + Some("https://publisher.example"), + serde_json::json!({ + "gppString": OPT_OUT, "applicableSections": [9] + }), + ); + req.headers_mut().insert( + header::COOKIE, + HeaderValue::from_str(&format!("ts-ec={id}; __gpp={GPP}; __gpp_sid=7")) + .expect("fixture cookie"), + ); + let req = prepare_consent_update(&services, req) + .await + .expect("valid opt-out"); + let geo = crate::geo::GeoInfo::country_region("US", Some("TN".into())); + let mut ec = EcContext::read_from_request_with_geo(&settings, &req, &services, Some(&geo)) + .expect("consent context"); + assert_eq!( + ec.consent() + .gpp + .as_ref() + .and_then(|gpp| gpp.us_sale_opt_out), + Some(true) + ); + let mut response = handle_consent_update(&req, Some(&graph)); + ec_finalize_response( + &settings, + &mut ec, + Some(&graph), + &PartnerRegistry::empty(), + None, + None, + &mut response, + ) + .await; + assert_eq!(response.status(), StatusCode::NO_CONTENT); + assert!( + !graph + .get(&id) + .await + .expect("read") + .expect("tombstone") + .0 + .consent + .ok + ); + + // A concurrent older grant may arrive after withdrawal. This endpoint + // does not generate/recover identities or ingest EIDs, so it cannot + // restore the tombstoned row or issue a live EC cookie. + let grant_req = prepare_consent_update( + &services, + request(Method::POST, Some("https://publisher.example"), snapshot()), + ) + .await + .expect("grant snapshot"); + let mut late = EcContext::new_for_test( + Some(id.clone()), + ConsentContext { + jurisdiction: Jurisdiction::NonRegulated, + ..Default::default() + }, + ); + let mut response = handle_consent_update(&grant_req, Some(&graph)); + ec_finalize_response( + &settings, + &mut late, + Some(&graph), + &PartnerRegistry::empty(), + None, + None, + &mut response, + ) + .await; + assert!( + !graph + .get(&id) + .await + .expect("read") + .expect("tombstone") + .0 + .consent + .ok + ); + assert!( + response + .headers() + .get_all(header::SET_COOKIE) + .iter() + .all(|value| { + value.to_str().is_ok_and(|value| { + !value.starts_with("ts-ec=") || value.contains("Max-Age=0") + }) + }) + ); + } + + #[tokio::test] + async fn bounds_snapshot_body_before_parsing() { + let response = prepare_consent_update(&noop_services(), request(Method::POST, Some("https://publisher.example"), serde_json::json!({ + "gppString": "x".repeat(MAX_CONSENT_UPDATE_BODY_BYTES + 1), "applicableSections": [7] + }))).await.expect_err("oversized snapshot"); + assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE); + } + + #[tokio::test] + async fn unavailable_store_is_not_acknowledged_as_success() { + let req = prepare_consent_update( + &noop_services(), + request(Method::POST, Some("https://publisher.example"), snapshot()), + ) + .await + .expect("valid snapshot"); + let mut response = handle_consent_update(&req, None); + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + let mut settings = create_test_settings(); + settings + .response_headers + .insert("Cache-Control".into(), "public, max-age=300".into()); + crate::response_privacy::apply_response_headers_with_cache_privacy( + &settings, + &mut response, + ); + let cache = response.headers()[header::CACHE_CONTROL] + .to_str() + .expect("cache policy"); + assert!(cache.contains("private") && cache.contains("no-store")); + } + + #[tokio::test] + async fn failed_revocation_returns_failure_while_expiring_the_browser_cookie() { + let settings = create_test_settings(); + let req = prepare_consent_update( + &noop_services(), + request(Method::POST, Some("https://publisher.example"), snapshot()), + ) + .await + .expect("valid snapshot"); + let graph = KvIdentityGraph::failing("unavailable-test-store"); + let mut ec = EcContext::new_for_test( + Some(EC.to_owned()), + ConsentContext { + jurisdiction: Jurisdiction::UsState("TN".to_owned()), + gpc: true, + ..Default::default() + }, + ); + let mut response = handle_consent_update(&req, Some(&graph)); + ec_finalize_response( + &settings, + &mut ec, + Some(&graph), + &PartnerRegistry::empty(), + None, + None, + &mut response, + ) + .await; + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + assert!( + response + .headers() + .get_all(header::SET_COOKIE) + .iter() + .any(|value| value + .to_str() + .is_ok_and(|value| value.starts_with("ts-ec=") && value.contains("Max-Age=0"))) + ); + } +} diff --git a/crates/trusted-server-core/src/ec/finalize.rs b/crates/trusted-server-core/src/ec/finalize.rs index ccb0ef3497..712110e7df 100644 --- a/crates/trusted-server-core/src/ec/finalize.rs +++ b/crates/trusted-server-core/src/ec/finalize.rs @@ -463,8 +463,9 @@ async fn finalize_unusable_consent( // Compute once for the authoritative identity-graph tombstones. let ids_to_withdraw = withdrawal_ec_ids(ec_context); - // The identity-graph tombstone is the authoritative withdrawal marker - // for subsequent EC behavior. + // The identity-graph tombstone is the authoritative withdrawal marker. + // A consent-update acknowledgment must reflect failed/unavailable writes. + let mut withdrawal_failed = !ids_to_withdraw.is_empty() && kv.is_none(); if let Some(graph) = kv { for ec_id in &ids_to_withdraw { // The graph hands back the post-withdrawal snapshot rather than @@ -483,6 +484,7 @@ async fn finalize_unusable_consent( // consented. Report every failure, including the non-active cookie // ID whose outcome is not retained on the request context. if matches!(outcome, EcKvSnapshot::Failed { .. }) { + withdrawal_failed = true; log::warn!( "EC withdrawal tombstone failed for '{}': the identity-graph row may \ still be live with consent granted", @@ -494,6 +496,9 @@ async fn finalize_unusable_consent( } } } + if withdrawal_failed { + super::consent_update::mark_reconciliation_failed(response); + } } fn withdrawal_ec_ids(ec_context: &EcContext) -> HashSet { diff --git a/crates/trusted-server-core/src/ec/mod.rs b/crates/trusted-server-core/src/ec/mod.rs index e90623b2af..a59cc71eb0 100644 --- a/crates/trusted-server-core/src/ec/mod.rs +++ b/crates/trusted-server-core/src/ec/mod.rs @@ -34,6 +34,7 @@ mod auth; pub mod admin; pub mod batch_sync; pub mod consent; +pub mod consent_update; pub mod cookies; pub mod device; pub mod eids; diff --git a/crates/trusted-server-js/lib/src/integrations/sourcepoint/consent_sync.ts b/crates/trusted-server-js/lib/src/integrations/sourcepoint/consent_sync.ts new file mode 100644 index 0000000000..6e84f7ee25 --- /dev/null +++ b/crates/trusted-server-js/lib/src/integrations/sourcepoint/consent_sync.ts @@ -0,0 +1,54 @@ +import { log } from '../../core/log'; + +export interface ConsentSnapshot { + gppString: string | null; + applicableSections: number[]; +} + +/** Publishes each real decision without coalescing away an intervening withdrawal. */ +export class ConsentSync { + private lastSnapshot?: string; + private stopped = false; + + constructor(private readonly fetch: typeof window.fetch) {} + + publish(snapshot: ConsentSnapshot): void { + if (this.stopped) return; + const body = JSON.stringify(snapshot); + if (body === this.lastSnapshot) return; + this.lastSnapshot = body; + // Each request carries its own snapshot. Cookie reads at network dispatch + // must not replace an earlier opt-out with a later decision. The endpoint + // cannot mint or restore identity, so response order cannot revive an EC. + try { + void this.fetch('/_ts/consent', { + method: 'POST', + credentials: 'same-origin', + redirect: 'error', + cache: 'no-store', + keepalive: true, + headers: { 'Content-Type': 'application/json' }, + body, + }) + .then((response) => { + // The endpoint returns no identity data; discard any error body too. + void response.body?.cancel().catch(() => {}); + if (response.status !== 204) this.failed(body); + }) + .catch(() => this.failed(body)); + } catch { + this.failed(body); + } + } + + stop(): void { + // An in-flight withdrawal must survive navigation; do not abort keepalive. + this.stopped = true; + } + + private failed(body: string): void { + // Retry on a subsequent CMP/focus event, not a background retry loop. + if (this.lastSnapshot === body) this.lastSnapshot = undefined; + log.warn('sourcepoint: server consent reconciliation was not confirmed'); + } +} diff --git a/crates/trusted-server-js/lib/src/integrations/sourcepoint/gpp_capture.ts b/crates/trusted-server-js/lib/src/integrations/sourcepoint/gpp_capture.ts new file mode 100644 index 0000000000..3fb97da26a --- /dev/null +++ b/crates/trusted-server-js/lib/src/integrations/sourcepoint/gpp_capture.ts @@ -0,0 +1,230 @@ +import { log } from '../../core/log'; + +export interface GppConsent { + gppString: string; + applicableSections: number[]; +} + +type GppCallback = (data: unknown, success?: boolean) => void; +export type GppApi = (command: string, callback: GppCallback, parameter?: number) => unknown; +export type GppWindow = Window & { __gpp?: GppApi }; + +/** GPP's encoded alphabet, safe as one cookie value without truncation. */ +export function isCookieSafeGppString(value: unknown): value is string { + return typeof value === 'string' && value.length > 0 && !/[^A-Za-z0-9._~=-]/.test(value); +} + +function record(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +/** Observes the existing CMP API. It does not install a CMP or synthesize consent. */ +export class GppConsentCapture { + private api?: GppApi; + private listenerId?: number; + private registrationPending = false; + private generation = 0; + private snapshotEpoch = 0; + private stopped = false; + private restoreProperty?: () => void; + private readonly onScriptLoaded = () => { + if (this.target.__gpp !== this.api) this.refresh(); + }; + + constructor( + private readonly target: GppWindow, + private readonly onConsent: (consent: GppConsent) => void, + private readonly onUnavailable: () => void + ) {} + + start(): void { + if (this.stopped) return; + const original = Object.getOwnPropertyDescriptor(this.target, '__gpp'); + // A plain writable global is how the standard CMP stub publishes its API. + // Preserve custom/non-configurable accessors rather than changing their semantics. + if (!original || (original.configurable && 'value' in original && original.writable)) { + let published = this.target.__gpp; + const getter = (): GppApi | undefined => published; + Object.defineProperty(this.target, '__gpp', { + configurable: true, + enumerable: original?.enumerable ?? true, + get: getter, + set: (value: GppApi | undefined) => { + published = value; + this.refresh(); + }, + }); + this.restoreProperty = () => { + if (Object.getOwnPropertyDescriptor(this.target, '__gpp')?.get !== getter) return; + if (!original && published === undefined) { + delete this.target.__gpp; + } else { + Object.defineProperty(this.target, '__gpp', { + configurable: original?.configurable ?? true, + enumerable: original?.enumerable ?? true, + writable: original && 'writable' in original ? original.writable : true, + value: published, + }); + } + }; + } + // Resource completion also observes SDK publication through a descriptor we + // deliberately did not replace. No timer/polling or synthetic GPP stub. + this.target.document.addEventListener('load', this.onScriptLoaded, true); + this.target.document.addEventListener('DOMContentLoaded', this.onScriptLoaded); + this.refresh(); + } + + hasApi(): boolean { + return typeof this.target.__gpp === 'function'; + } + + refresh(): void { + if (this.stopped) return; + const next = this.target.__gpp; + if (typeof next !== 'function') { + if (this.api) { + this.unsubscribe(); + this.api = undefined; + this.onUnavailable(); + } + return; + } + if (next !== this.api) { + // A queued stub subscription transfers to the real CMP. Registering again + // while it is pending would leave two listeners after the queue drains. + if (!this.registrationPending) this.unsubscribe(); + this.api = next; + } + this.ping(next); + } + + stop(): void { + if (this.stopped) return; + this.stopped = true; + this.unsubscribe(); + this.target.document.removeEventListener('load', this.onScriptLoaded, true); + this.target.document.removeEventListener('DOMContentLoaded', this.onScriptLoaded); + this.restoreProperty?.(); + } + + private subscribe(api: GppApi, version: '1.0' | '1.1'): void { + this.registrationPending = true; + const generation = this.generation; + const onEvent = (event: unknown, success?: boolean) => { + if (this.stopped || generation !== this.generation) return; + this.snapshotEpoch += 1; + if ((success !== true && !(version === '1.0' && success === undefined)) || !record(event)) { + this.unsubscribe(); + this.onUnavailable(); + return; + } + if (event.eventName === 'listenerRegistered') { + if ( + event.data !== true || + typeof event.listenerId !== 'number' || + !Number.isInteger(event.listenerId) + ) { + this.unsubscribe(); + this.onUnavailable(); + return; + } + this.listenerId = event.listenerId; + this.registrationPending = false; + } + if (this.listenerId === undefined) return; + // Event pingData and outstanding asynchronous reads can predate a newer + // choice. Read current state and discard superseded ping callbacks. + if (this.api) this.ping(this.api); + }; + try { + const returned = api.call(this.target, 'addEventListener', onEvent); + // The published 1.1 example stub still returns this acknowledgment + // synchronously. Accept registration only, never synchronous 1.1 consent. + if ( + this.listenerId === undefined && + record(returned) && + returned.eventName === 'listenerRegistered' + ) { + onEvent(returned, true); + } + } catch { + this.unsubscribe(); + this.onUnavailable(); + log.warn('sourcepoint: GPP event subscription failed'); + } + } + + private ping(api: GppApi): void { + const epoch = ++this.snapshotEpoch; + let callbackCalled = false; + const onPing = (data: unknown, success?: boolean) => { + callbackCalled = true; + if (this.stopped || api !== this.api || epoch !== this.snapshotEpoch) return; + if (!success || !record(data) || (data.gppVersion !== '1.0' && data.gppVersion !== '1.1')) { + this.onUnavailable(); + return; + } + if (this.listenerId === undefined) { + if (!this.registrationPending) this.subscribe(api, data.gppVersion); + if (this.listenerId === undefined) this.onUnavailable(); + return; + } + if (data.gppVersion === '1.0') { + // API 1.0 has synchronous getGPPData, not the 1.1 ready ping snapshot. + if (data.cmpStatus !== 'loaded') { + this.onUnavailable(); + return; + } + const legacy = api.call(this.target, 'getGPPData', () => {}); + if (record(legacy)) this.consume(legacy); + else this.onUnavailable(); + } else if (data.signalStatus === 'ready') this.consume(data); + else this.onUnavailable(); + }; + try { + const returned = api.call(this.target, 'ping', onPing); + if (!callbackCalled && record(returned)) { + if (returned.gppVersion === '1.0') onPing(returned, true); + else if (returned.gppVersion === '1.1' && epoch === this.snapshotEpoch) { + this.onUnavailable(); + if (this.listenerId === undefined && !this.registrationPending) + this.subscribe(api, '1.1'); + } + } + } catch { + this.onUnavailable(); + log.warn('sourcepoint: GPP state read failed'); + } + } + + private consume(data: Record): void { + if ( + !isCookieSafeGppString(data.gppString) || + !Array.isArray(data.applicableSections) || + data.applicableSections.length === 0 || + !data.applicableSections.every((id) => Number.isInteger(id) && id > 0 && id <= 65535) + ) { + this.onUnavailable(); + return; + } + this.onConsent({ + gppString: data.gppString, + applicableSections: [...data.applicableSections] as number[], + }); + } + + private unsubscribe(): void { + this.generation++; + this.snapshotEpoch++; + if (this.listenerId !== undefined && this.api) { + try { + this.api.call(this.target, 'removeEventListener', () => {}, this.listenerId); + } catch { + log.warn('sourcepoint: GPP listener cleanup failed'); + } + } + this.listenerId = undefined; + this.registrationPending = false; + } +} diff --git a/crates/trusted-server-js/lib/src/integrations/sourcepoint/index.ts b/crates/trusted-server-js/lib/src/integrations/sourcepoint/index.ts index f323468bbd..9e5194a98e 100644 --- a/crates/trusted-server-js/lib/src/integrations/sourcepoint/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/sourcepoint/index.ts @@ -1,8 +1,10 @@ import { log } from '../../core/log'; +import { ConsentSync } from './consent_sync'; +import { GppConsentCapture, isCookieSafeGppString, type GppWindow } from './gpp_capture'; import { installSourcepointGuard } from './script_guard'; -type SourcepointWindow = Window & { +type SourcepointWindow = GppWindow & { __tsjs_sourcepoint?: { rewriteSdk?: boolean; }; @@ -57,6 +59,10 @@ interface MirroredSourcepointConsent { let initialized = false; let initialRetryDone = false; let retryTimer: ReturnType | undefined; +let readyRetry: (() => void) | undefined; +let gppCapture: GppConsentCapture | undefined; +let apiMirrorSucceeded = false; +let consentSync: ConsentSync | undefined; function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null; @@ -173,13 +179,15 @@ function clearCookie(name: string): void { } function clearSourcepointCookies(): void { - if (!hasSourcepointMarker()) { - return; + try { + if (!hasSourcepointMarker()) return; + clearCookie(GPP_COOKIE_NAME); + clearCookie(GPP_SID_COOKIE_NAME); + clearCookie(GPP_SOURCE_COOKIE_NAME); + consentSync?.publish({ gppString: null, applicableSections: [] }); + } catch { + log.warn('sourcepoint: consent cookie storage is unavailable'); } - - clearCookie(GPP_COOKIE_NAME); - clearCookie(GPP_SID_COOKIE_NAME); - clearCookie(GPP_SOURCE_COOKIE_NAME); } function mirrorOnVisible(): void { @@ -189,12 +197,14 @@ function mirrorOnVisible(): void { } function clearInitialRetryTimer(): void { - if (retryTimer === undefined) { - return; + if (retryTimer !== undefined) { + window.clearTimeout(retryTimer); + retryTimer = undefined; + } + if (readyRetry) { + document.removeEventListener('DOMContentLoaded', readyRetry); + readyRetry = undefined; } - - window.clearTimeout(retryTimer); - retryTimer = undefined; } function scheduleInitialRetry(): void { @@ -213,6 +223,7 @@ function scheduleInitialRetry(): void { }; if (document.readyState === 'loading') { + readyRetry = retry; document.addEventListener('DOMContentLoaded', retry, { once: true }); } @@ -230,26 +241,43 @@ function scheduleInitialRetry(): void { * Returns `true` if cookies were written, `false` otherwise. */ export function mirrorSourcepointConsent(): boolean { - if (typeof localStorage === 'undefined' || typeof document === 'undefined') { - return false; + if (typeof document === 'undefined') return false; + if (gppCapture?.hasApi()) { + apiMirrorSucceeded = false; + gppCapture.refresh(); + return apiMirrorSucceeded; } - - const consent = findSourcepointConsent(); - if (!consent) { + try { + if (typeof localStorage === 'undefined') return false; + const consent = findSourcepointConsent(); + if (!consent) { + clearSourcepointCookies(); + log.debug('sourcepoint: no GPP data found in localStorage'); + return false; + } + return writeMirroredConsent(consent); + } catch { + // Sandboxed frames and browser storage restrictions must not break the + // publisher's scripts or revive an earlier permissive mirror. clearSourcepointCookies(); - log.debug('sourcepoint: no GPP data found in localStorage'); + log.warn('sourcepoint: consent storage is unavailable'); return false; } +} +function writeMirroredConsent(consent: MirroredSourcepointConsent): boolean { const { gppString, applicableSections } = consent; - if (!gppString) { + if ( + !isCookieSafeGppString(gppString) || + applicableSections?.some((id) => !Number.isInteger(id) || id <= 0 || id > 65535) + ) { clearSourcepointCookies(); - log.debug('sourcepoint: gppString is empty'); + log.debug('sourcepoint: GPP snapshot cannot be represented as consent cookies'); return false; } const existingGppCookie = readCookie(GPP_COOKIE_NAME); - if (existingGppCookie && existingGppCookie !== gppString && !hasSourcepointMarker()) { + if (existingGppCookie && !hasSourcepointMarker()) { log.debug('sourcepoint: preserving existing __gpp cookie from another writer'); return false; } @@ -265,6 +293,7 @@ export function mirrorSourcepointConsent(): boolean { initialRetryDone = true; clearInitialRetryTimer(); + consentSync?.publish({ gppString, applicableSections: applicableSections ?? [] }); log.info('sourcepoint: mirrored GPP consent to cookies', { gppLength: gppString.length, @@ -283,15 +312,44 @@ export function initializeSourcepointConsentMirror(): void { } initialized = true; + consentSync = + typeof window.fetch === 'function' ? new ConsentSync(window.fetch.bind(window)) : undefined; + gppCapture = new GppConsentCapture( + window as GppWindow, + (consent) => { + try { + apiMirrorSucceeded = writeMirroredConsent(consent); + } catch { + apiMirrorSucceeded = false; + log.warn('sourcepoint: consent cookie storage is unavailable'); + } + }, + () => { + apiMirrorSucceeded = false; + clearSourcepointCookies(); + } + ); + gppCapture.start(); - if (!mirrorSourcepointConsent()) { - scheduleInitialRetry(); - } + if (!mirrorSourcepointConsent() && !gppCapture.hasApi()) scheduleInitialRetry(); - // Sourcepoint persists consent changes to localStorage. Re-mirror when a - // user returns to the page so session cookies do not remain stale. + // Retain legacy localStorage refresh for CMPs without the GPP API. When an + // API exists, its ready snapshot is authoritative, including same-tab changes. document.addEventListener('visibilitychange', mirrorOnVisible); window.addEventListener('focus', mirrorSourcepointConsent); } +/** Releases this module's listeners without removing or replacing the publisher CMP. */ +export function disposeSourcepointConsentMirror(): void { + gppCapture?.stop(); + gppCapture = undefined; + consentSync?.stop(); + consentSync = undefined; + clearInitialRetryTimer(); + document.removeEventListener('visibilitychange', mirrorOnVisible); + window.removeEventListener('focus', mirrorSourcepointConsent); + initialized = false; + initialRetryDone = false; +} + initializeSourcepointConsentMirror(); diff --git a/crates/trusted-server-js/lib/test/integrations/sourcepoint/consent_sync.test.ts b/crates/trusted-server-js/lib/test/integrations/sourcepoint/consent_sync.test.ts new file mode 100644 index 0000000000..40864e2fc0 --- /dev/null +++ b/crates/trusted-server-js/lib/test/integrations/sourcepoint/consent_sync.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { ConsentSync } from '../../../src/integrations/sourcepoint/consent_sync'; + +const ok = { status: 204, body: null } as Response; + +function snapshot(gppString: string | null) { + return { gppString, applicableSections: gppString ? [7] : [] }; +} + +describe('Sourcepoint server consent reconciliation', () => { + it('sends the exact decision with same-origin credentials and keepalive', () => { + const fetch = vi.fn().mockResolvedValue(ok); + const sync = new ConsentSync(fetch); + sync.publish(snapshot('synthetic-current-gpp')); + expect(fetch).toHaveBeenCalledWith('/_ts/consent', { + method: 'POST', + credentials: 'same-origin', + redirect: 'error', + cache: 'no-store', + keepalive: true, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(snapshot('synthetic-current-gpp')), + }); + }); + + it('deduplicates repeated snapshots but preserves a withdrawal between grants', () => { + const fetch = vi.fn().mockResolvedValue(ok); + const sync = new ConsentSync(fetch); + sync.publish(snapshot('synthetic-allow')); + sync.publish(snapshot('synthetic-allow')); + sync.publish(snapshot('synthetic-deny')); + sync.publish(snapshot('synthetic-allow')); + expect(fetch).toHaveBeenCalledTimes(3); + expect(fetch.mock.calls.map(([, init]) => JSON.parse(init!.body as string).gppString)).toEqual([ + 'synthetic-allow', + 'synthetic-deny', + 'synthetic-allow', + ]); + }); + + it('does not wait for an earlier response before dispatching a withdrawal', () => { + const fetch = vi.fn().mockImplementation(() => new Promise(() => {})); + const sync = new ConsentSync(fetch); + sync.publish(snapshot('synthetic-allow')); + sync.publish(snapshot('synthetic-deny')); + expect(fetch).toHaveBeenCalledTimes(2); + }); + + it('permits a later event to retry an unconfirmed update without a retry timer', async () => { + const fetch = vi + .fn() + .mockResolvedValueOnce({ status: 503, body: null } as Response) + .mockResolvedValue(ok); + const sync = new ConsentSync(fetch); + sync.publish(snapshot('synthetic-deny')); + await Promise.resolve(); + sync.publish(snapshot('synthetic-deny')); + expect(fetch).toHaveBeenCalledTimes(2); + }); + + it.each([200, 202])( + 'does not treat a generic HTTP %i as reconciliation success', + async (status) => { + const fetch = vi + .fn() + .mockResolvedValueOnce({ status, body: null } as Response) + .mockResolvedValue(ok); + const sync = new ConsentSync(fetch); + sync.publish(snapshot('synthetic-deny')); + await Promise.resolve(); + sync.publish(snapshot('synthetic-deny')); + expect(fetch).toHaveBeenCalledTimes(2); + } + ); + + it('contains a synchronous fetch failure and permits a later retry', () => { + const fetch = vi + .fn() + .mockImplementationOnce(() => { + throw new Error('synthetic failure'); + }) + .mockResolvedValue(ok); + const sync = new ConsentSync(fetch); + expect(() => sync.publish(snapshot('synthetic-deny'))).not.toThrow(); + sync.publish(snapshot('synthetic-deny')); + expect(fetch).toHaveBeenCalledTimes(2); + }); + + it('does not cancel an in-flight keepalive update when disposed', () => { + const fetch = vi.fn().mockImplementation(() => new Promise(() => {})); + const sync = new ConsentSync(fetch); + sync.publish(snapshot('synthetic-deny')); + sync.stop(); + sync.publish(snapshot('synthetic-allow')); + expect(fetch).toHaveBeenCalledTimes(1); + expect(fetch.mock.calls[0][1]?.signal).toBeUndefined(); + }); +}); diff --git a/crates/trusted-server-js/lib/test/integrations/sourcepoint/gpp_capture.test.ts b/crates/trusted-server-js/lib/test/integrations/sourcepoint/gpp_capture.test.ts new file mode 100644 index 0000000000..e61a5f4afd --- /dev/null +++ b/crates/trusted-server-js/lib/test/integrations/sourcepoint/gpp_capture.test.ts @@ -0,0 +1,457 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + GppConsentCapture, + type GppApi, + type GppWindow, +} from '../../../src/integrations/sourcepoint/gpp_capture'; + +type Callback = Parameters[1]; +const ready = (gppString = 'synthetic-gpp-allow', applicableSections = [7]) => ({ + gppVersion: '1.1', + cmpStatus: 'loaded', + signalStatus: 'ready', + gppString, + applicableSections, +}); + +function cmp(initial: Record = ready()) { + let state = initial; + let nextId = 1; + const listeners = new Map(); + const api: GppApi = vi.fn((command, callback, parameter) => { + if (command === 'ping') callback(state, true); + else if (command === 'addEventListener') { + const id = nextId++; + listeners.set(id, callback); + callback( + { eventName: 'listenerRegistered', listenerId: id, data: true, pingData: state }, + true + ); + } else if (command === 'removeEventListener') { + callback(listeners.delete(parameter!), true); + } else callback(undefined, false); + }); + return { + api, + listeners, + change(next: Record, eventName = 'sectionChange') { + state = next; + for (const [id, callback] of listeners) { + callback({ eventName, listenerId: id, pingData: state }, true); + } + }, + }; +} + +describe('Sourcepoint GPP API capture', () => { + const target = window as GppWindow; + let capture: GppConsentCapture; + let onConsent: ReturnType; + let onUnavailable: ReturnType; + + beforeEach(() => { + delete target.__gpp; + onConsent = vi.fn(); + onUnavailable = vi.fn(); + capture = new GppConsentCapture(target, onConsent, onUnavailable); + }); + + afterEach(() => { + capture.stop(); + delete target.__gpp; + vi.useRealTimers(); + }); + + it('uses callback ping and listener registration rather than unsupported getGPPData', () => { + const vendor = cmp(); + target.__gpp = vendor.api; + capture.start(); + expect(onConsent).toHaveBeenLastCalledWith({ + gppString: 'synthetic-gpp-allow', + applicableSections: [7], + }); + expect(vendor.listeners.size).toBe(1); + expect(vi.mocked(vendor.api).mock.calls.map(([command]) => command)).not.toContain( + 'getGPPData' + ); + }); + + it('captures an API published long after the old startup retry without polling', () => { + vi.useFakeTimers(); + capture.start(); + vi.advanceTimersByTime(60_000); + expect(onConsent).not.toHaveBeenCalled(); + const vendor = cmp(); + target.__gpp = vendor.api; + expect(onConsent).toHaveBeenLastCalledWith({ + gppString: 'synthetic-gpp-allow', + applicableSections: [7], + }); + expect(vi.getTimerCount()).toBe(0); + }); + + it('does not publish a grant when the event subscription failed', () => { + target.__gpp = (command, callback) => { + if (command === 'addEventListener') callback(undefined, false); + if (command === 'ping') callback(ready(), true); + }; + capture.start(); + expect(onConsent).not.toHaveBeenCalled(); + expect(onUnavailable).toHaveBeenCalled(); + }); + + it('requires the registration data flag, not just a successful command', () => { + target.__gpp = (command, callback) => { + if (command === 'addEventListener') + callback({ eventName: 'listenerRegistered', listenerId: 0, data: false }, true); + if (command === 'ping') callback(ready(), true); + }; + capture.start(); + expect(onConsent).not.toHaveBeenCalled(); + expect(onUnavailable).toHaveBeenCalled(); + }); + + it('preserves the window receiver when invoking the publisher API', () => { + const vendor = cmp(); + const receivers: unknown[] = []; + target.__gpp = function (this: unknown, ...args) { + receivers.push(this); + vendor.api(...args); + }; + capture.start(); + capture.stop(); + expect(receivers.length).toBeGreaterThan(0); + expect(receivers.every((receiver) => receiver === window)).toBe(true); + }); + + it('waits for real readiness rather than accepting an unready snapshot', () => { + const vendor = cmp({ ...ready(), signalStatus: 'not ready' }); + target.__gpp = vendor.api; + capture.start(); + expect(onConsent).not.toHaveBeenCalled(); + expect(onUnavailable).toHaveBeenCalled(); + vendor.change(ready(), 'signalStatus'); + expect(onConsent).toHaveBeenCalledWith({ + gppString: 'synthetic-gpp-allow', + applicableSections: [7], + }); + }); + + it('captures same-tab restrictive changes without focus or reload', () => { + const vendor = cmp(); + target.__gpp = vendor.api; + capture.start(); + vendor.change(ready('synthetic-gpp-opt-out', [7, 22])); + expect(onConsent).toHaveBeenLastCalledWith({ + gppString: 'synthetic-gpp-opt-out', + applicableSections: [7, 22], + }); + }); + + it('reports unavailable state instead of keeping an earlier permissive snapshot', () => { + const vendor = cmp(); + target.__gpp = vendor.api; + capture.start(); + onConsent.mockClear(); + onUnavailable.mockClear(); + vendor.change({ ...ready(), signalStatus: 'not ready' }); + expect(onUnavailable).toHaveBeenCalledOnce(); + expect(onConsent).not.toHaveBeenCalled(); + }); + + it.each([ + { ...ready(), gppString: '' }, + { ...ready(), gppString: 'synthetic-grant; Path=/' }, + { ...ready(), gppString: 'synthetic-grant\n' }, + { ...ready(), applicableSections: [70000] }, + { ...ready(), applicableSections: ['7'] }, + { ...ready(), applicableSections: [7.5] }, + { ...ready(), applicableSections: [] }, + { ...ready(), applicableSections: [-1] }, + { ...ready(), applicableSections: undefined }, + ])('does not accept malformed API consent data: %j', (state) => { + target.__gpp = cmp(state).api; + capture.start(); + expect(onConsent).not.toHaveBeenCalled(); + expect(onUnavailable).toHaveBeenCalled(); + }); + + it('transfers a queued stub subscription without registering twice', () => { + const queued: Array<[string, Callback, number | undefined]> = []; + target.__gpp = (command, callback, parameter) => queued.push([command, callback, parameter]); + capture.start(); + const vendor = cmp(); + target.__gpp = vendor.api; + for (const [command, callback, parameter] of queued) vendor.api(command, callback, parameter); + expect(vendor.listeners.size).toBe(1); + expect(onConsent).toHaveBeenLastCalledWith({ + gppString: 'synthetic-gpp-allow', + applicableSections: [7], + }); + }); + + it('handles the synchronous acknowledgment in the published 1.1 stub without granting from it', () => { + target.__gpp = (command) => { + if (command === 'ping') return { ...ready(), cmpStatus: 'stub' }; + if (command === 'addEventListener') + return { eventName: 'listenerRegistered', listenerId: 1, data: true }; + }; + capture.start(); + expect(onConsent).not.toHaveBeenCalled(); + expect(onUnavailable).toHaveBeenCalled(); + target.__gpp = cmp().api; + expect(onConsent).toHaveBeenCalled(); + }); + + it('uses synchronous API 1.0 getGPPData only after discovering that version', () => { + let state = { gppString: 'synthetic-legacy-gpp', applicableSections: [7] }; + let listener: Callback | undefined; + target.__gpp = (command, callback) => { + if (command === 'ping') return { gppVersion: '1.0', cmpStatus: 'loaded' }; + if (command === 'getGPPData') return state; + if (command === 'addEventListener') { + listener = callback; + return { eventName: 'listenerRegistered', listenerId: 1, data: true }; + } + }; + capture.start(); + expect(onConsent).toHaveBeenLastCalledWith(state); + state = { gppString: 'synthetic-legacy-opt-out', applicableSections: [7] }; + listener!({ eventName: 'sectionChange' }); + expect(onConsent).toHaveBeenLastCalledWith(state); + }); + + it('discards an older ping callback after a newer decision event', () => { + const pending: Callback[] = []; + let listener: Callback | undefined; + target.__gpp = (command, callback) => { + if (command === 'ping') pending.push(callback); + if (command === 'addEventListener') { + listener = callback; + callback({ eventName: 'listenerRegistered', listenerId: 1, data: true }, true); + } + }; + capture.start(); + pending[0](ready(), true); + const obsolete = pending[1]; + listener!({ eventName: 'sectionChange' }, true); + pending[2](ready('synthetic-current-opt-out'), true); + obsolete(ready('synthetic-obsolete-allow'), true); + expect(onConsent).toHaveBeenCalledOnce(); + expect(onConsent).toHaveBeenLastCalledWith({ + gppString: 'synthetic-current-opt-out', + applicableSections: [7], + }); + }); + + it('removes the old listener when an installed CMP API is replaced', () => { + const old = cmp(); + const next = cmp(ready('synthetic-current-gpp')); + target.__gpp = old.api; + capture.start(); + target.__gpp = next.api; + expect(old.listeners.size).toBe(0); + expect(next.listeners.size).toBe(1); + expect(onConsent).toHaveBeenLastCalledWith({ + gppString: 'synthetic-current-gpp', + applicableSections: [7], + }); + old.change(ready('synthetic-stale-gpp')); + expect(onConsent).not.toHaveBeenLastCalledWith({ + gppString: 'synthetic-stale-gpp', + applicableSections: [7], + }); + }); + + it('keeps the publisher API identity and descriptor semantics after disposal', () => { + const vendor = cmp(); + capture.start(); + target.__gpp = vendor.api; + capture.stop(); + expect(vendor.listeners.size).toBe(0); + expect(target.__gpp).toBe(vendor.api); + expect(Object.getOwnPropertyDescriptor(target, '__gpp')).toMatchObject({ + value: vendor.api, + writable: true, + configurable: true, + enumerable: true, + }); + }); + + it('observes SDK replacement through a custom accessor at resource completion', () => { + let published: GppApi | undefined = undefined; + const getter = () => published; + Object.defineProperty(target, '__gpp', { configurable: true, get: getter }); + capture.start(); + published = cmp().api; + document.dispatchEvent(new Event('load')); + expect(onConsent).toHaveBeenCalled(); + expect(Object.getOwnPropertyDescriptor(target, '__gpp')?.get).toBe(getter); + }); + + it('leaves a publisher custom accessor intact', () => { + const vendor = cmp(); + const getter = () => vendor.api; + Object.defineProperty(target, '__gpp', { configurable: true, get: getter }); + capture.start(); + expect(Object.getOwnPropertyDescriptor(target, '__gpp')?.get).toBe(getter); + expect(onConsent).toHaveBeenCalled(); + capture.stop(); + expect(Object.getOwnPropertyDescriptor(target, '__gpp')?.get).toBe(getter); + }); +}); + +describe('Sourcepoint GPP cookie bridge', () => { + const target = window as GppWindow; + let module: typeof import('../../../src/integrations/sourcepoint'); + let previousFetch: PropertyDescriptor | undefined; + let fetchMock: ReturnType>; + + function clearCookies(): void { + for (const cookie of document.cookie.split(';')) { + document.cookie = `${cookie.trim().split('=')[0]}=; Path=/; Max-Age=0`; + } + } + + function cookie(name: string): string | undefined { + return document.cookie + .split('; ') + .find((value) => value.startsWith(`${name}=`)) + ?.slice(name.length + 1); + } + + beforeEach(() => { + vi.resetModules(); + localStorage.clear(); + clearCookies(); + delete target.__gpp; + previousFetch = Object.getOwnPropertyDescriptor(window, 'fetch'); + fetchMock = vi + .fn() + .mockResolvedValue({ status: 204, body: null } as Response); + Object.defineProperty(window, 'fetch', { configurable: true, value: fetchMock }); + Object.assign(window, { __tsjs_sourcepoint: { rewriteSdk: false } }); + }); + + afterEach(() => { + module?.disposeSourcepointConsentMirror(); + delete target.__gpp; + Reflect.deleteProperty(window, '__tsjs_sourcepoint'); + if (previousFetch) Object.defineProperty(window, 'fetch', previousFetch); + else Reflect.deleteProperty(window, 'fetch'); + localStorage.clear(); + clearCookies(); + vi.useRealTimers(); + }); + + it('uses the ready API rather than another property’s stale localStorage record', async () => { + localStorage.setItem( + '_sp_user_consent_old', + JSON.stringify({ gppData: { gppString: 'synthetic-stale-gpp', applicableSections: [8] } }) + ); + target.__gpp = cmp(ready('synthetic-current-gpp')).api; + module = await import('../../../src/integrations/sourcepoint'); + expect(cookie('__gpp')).toBe('synthetic-current-gpp'); + expect(cookie('__gpp_sid')).toBe('7'); + }); + + it('writes actual restrictive updates immediately in the same tab', async () => { + const vendor = cmp(); + target.__gpp = vendor.api; + module = await import('../../../src/integrations/sourcepoint'); + vendor.change(ready('synthetic-gpp-opt-out', [7, 22])); + expect(cookie('__gpp')).toBe('synthetic-gpp-opt-out'); + expect(cookie('__gpp_sid')).toBe('7,22'); + expect(cookie('_ts_gpp_src')).toBe('sp'); + expect( + fetchMock.mock.calls.map(([, init]) => JSON.parse(init!.body as string).gppString) + ).toEqual(['synthetic-gpp-allow', 'synthetic-gpp-opt-out']); + }); + + it('does not fall back to a stale grant while the GPP API is not ready', async () => { + const vendor = cmp(); + target.__gpp = vendor.api; + module = await import('../../../src/integrations/sourcepoint'); + localStorage.setItem( + '_sp_user_consent_old', + JSON.stringify({ gppData: { gppString: 'synthetic-stale-gpp', applicableSections: [7] } }) + ); + vendor.change({ ...ready(), signalStatus: 'not ready' }); + window.dispatchEvent(new Event('focus')); + expect(cookie('__gpp')).toBeUndefined(); + expect(cookie('_ts_gpp_src')).toBeUndefined(); + expect(JSON.parse(fetchMock.mock.calls.at(-1)![1]!.body as string)).toEqual({ + gppString: null, + applicableSections: [], + }); + }); + + it('preserves another writer’s cookies on ready and unavailable events', async () => { + document.cookie = '__gpp=synthetic-other-cmp; Path=/'; + document.cookie = '__gpp_sid=2; Path=/'; + const vendor = cmp(); + target.__gpp = vendor.api; + module = await import('../../../src/integrations/sourcepoint'); + expect(cookie('__gpp')).toBe('synthetic-other-cmp'); + vendor.change({ ...ready(), signalStatus: 'not ready' }); + expect(cookie('__gpp')).toBe('synthetic-other-cmp'); + expect(cookie('__gpp_sid')).toBe('2'); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("does not claim another writer's identical GPP cookie", async () => { + document.cookie = '__gpp=synthetic-gpp-allow; Path=/'; + document.cookie = '__gpp_sid=2; Path=/'; + target.__gpp = cmp().api; + module = await import('../../../src/integrations/sourcepoint'); + expect(cookie('_ts_gpp_src')).toBeUndefined(); + expect(cookie('__gpp_sid')).toBe('2'); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('contains blocked localStorage without breaking publisher scripts', async () => { + vi.useFakeTimers(); + const storage = vi.spyOn(window, 'localStorage', 'get').mockImplementation(() => { + throw new DOMException('Synthetic blocked storage', 'SecurityError'); + }); + try { + module = await import('../../../src/integrations/sourcepoint'); + expect(() => window.dispatchEvent(new Event('focus'))).not.toThrow(); + expect(() => vi.advanceTimersByTime(600)).not.toThrow(); + expect(fetchMock).not.toHaveBeenCalled(); + } finally { + storage.mockRestore(); + } + }); + + it('removes a pending DOM-ready legacy retry when disposed', async () => { + vi.useFakeTimers(); + const loading = vi.spyOn(document, 'readyState', 'get').mockReturnValue('loading'); + try { + module = await import('../../../src/integrations/sourcepoint'); + module.disposeSourcepointConsentMirror(); + localStorage.setItem( + '_sp_user_consent_fixture', + JSON.stringify({ + gppData: { gppString: 'synthetic-after-stop', applicableSections: [7] }, + }) + ); + document.dispatchEvent(new Event('DOMContentLoaded')); + vi.runAllTimers(); + expect(cookie('__gpp')).toBeUndefined(); + expect(fetchMock).not.toHaveBeenCalled(); + } finally { + loading.mockRestore(); + } + }); + + it('captures a late GPP publication after the legacy startup retry expired', async () => { + vi.useFakeTimers(); + module = await import('../../../src/integrations/sourcepoint'); + vi.advanceTimersByTime(5_000); + target.__gpp = cmp().api; + expect(cookie('__gpp')).toBe('synthetic-gpp-allow'); + expect(cookie('__gpp_sid')).toBe('7'); + }); +}); diff --git a/docs/guide/integrations/sourcepoint.md b/docs/guide/integrations/sourcepoint.md index 0253f02290..01a294adcc 100644 --- a/docs/guide/integrations/sourcepoint.md +++ b/docs/guide/integrations/sourcepoint.md @@ -1,6 +1,6 @@ # Sourcepoint Integration -Sourcepoint provides consent and privacy messaging for publishers. This integration proxies the Sourcepoint CDN endpoint through Trusted Server so the browser loads it from a first-party path. +Sourcepoint provides consent and privacy messaging for publishers. This integration captures its GPP signals for Trusted Server and can proxy its SDK through a first-party path. ## Overview @@ -83,6 +83,37 @@ Trusted Server also injects a `window._sp_` property trap that rewrites known So Single-page apps often insert CMP scripts after the initial HTML response. The `sourcepoint` tsjs module installs a DOM insertion guard so dynamically inserted Sourcepoint script and preload URLs are rewritten to first-party paths before the browser fetches them. +## Consent capture + +To capture consent without rewriting the publisher's existing CMP URLs: + +```toml +[integrations.sourcepoint] +enabled = true +rewrite_sdk = false +``` + +The browser module observes the publisher's existing `__gpp` API. With API 1.1 it subscribes to events and reads callback `ping` snapshots only when `signalStatus` is `ready`. With API 1.0 it uses synchronous `ping` and `getGPPData`. It observes late SDK publication without polling, removes replaced listeners, and ignores superseded asynchronous reads. It does not install a CMP or infer permission from Sourcepoint's accept-all flags. + +Actual applicable GPP signals become host-scoped, Secure, SameSite=Lax session cookies named `__gpp` and `__gpp_sid`. The `_ts_gpp_src=sp` marker identifies cookies maintained by this integration. Unready or unusable API state clears only Sourcepoint-owned mirrors. Cookies owned by another writer are not overwritten or cleared. The older localStorage formats remain a compatibility fallback only when no GPP API exists. + +```mermaid +flowchart TD + CMP[Publisher CMP readiness or choice event] --> Read[Read current GPP snapshot] + Read --> Mirror[Update Sourcepoint-owned consent cookies] + Mirror --> POST[POST /_ts/consent with the same snapshot] + POST --> Policy[Existing server consent and Geo policy] + Policy --> Finalize[Finalize EC and persist authoritative withdrawal] +``` + +`POST /_ts/consent` requires a matching same-origin `Origin`. It accepts a bounded JSON snapshot containing `gppString` and `applicableSections`, not identity or partner credentials. Each request carries its own decision so a later cookie update cannot replace an intervening opt-out. It preserves other privacy inputs, including GPC. Missing GPP state is not fabricated into an opt-out or permission grant. + +The endpoint never creates or recovers identities, ingests EIDs, or restores a revoked row. Creation remains on eligible publisher navigation after consent cookies are available. This keeps an older grant response from recreating identity after withdrawal. Explicit withdrawal follows the existing server jurisdiction/consent policy and expires the HttpOnly EC cookie while tombstoning its stored identity. + +Only `204` confirms processing; generic publisher `200` responses do not. Store absence or a failed withdrawal write returns `503`. Responses are private and no-store. Fastly uses its EC identity graph; native reconciliation requires native EC persistence. Adapters without an identity graph reserve the path and report failure rather than forwarding its payload to the publisher. Network failures are reported without logging consent values and can retry on a later CMP or focus event. + +Deploy the server endpoint and persistence support before enabling capture. Reload already-open pages to load the new observer. Validate actual CMP readiness, same-tab opt-out, EC expiry, and the backing tombstone before treating withdrawal as verified. + ## Cookie Forwarding and Caching Trusted Server forwards only Sourcepoint's documented cookie names upstream, plus the optional `auth_cookie_name` when configured. Unrelated publisher cookies are deliberately excluded so first-party application state is not leaked to Sourcepoint.